diff --git a/app/.dockerignore b/app/.dockerignore
new file mode 100644
index 00000000..504d5073
--- /dev/null
+++ b/app/.dockerignore
@@ -0,0 +1,36 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/app/.eslintrc.json b/app/.eslintrc.json
new file mode 100644
index 00000000..7cd65f50
--- /dev/null
+++ b/app/.eslintrc.json
@@ -0,0 +1,14 @@
+{
+ "extends": [
+ "next/core-web-vitals",
+ "plugin:@typescript-eslint/recommended"
+ ],
+ "parser": "@typescript-eslint/parser",
+ "plugins": [
+ "@typescript-eslint"
+ ],
+ "rules": {
+ "@typescript-eslint/no-unused-vars": 1,
+ "@typescript-eslint/no-explicit-any": 1
+ }
+}
diff --git a/app/Dockerfile.dev b/app/Dockerfile.dev
new file mode 100644
index 00000000..17013777
--- /dev/null
+++ b/app/Dockerfile.dev
@@ -0,0 +1,16 @@
+FROM node:20
+
+WORKDIR /app
+
+# copy package.json and package-lock.json
+COPY --link package.json package-lock.json ./
+
+# install dependencies
+RUN npm install
+
+# copy source code
+COPY --link . .
+
+RUN npm install
+
+ENTRYPOINT [ "npm", "run", "dev" ]
\ No newline at end of file
diff --git a/app/README.md b/app/README.md
new file mode 100644
index 00000000..6af57e22
--- /dev/null
+++ b/app/README.md
@@ -0,0 +1,36 @@
+This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3001](http://localhost:3001) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
diff --git a/app/app/cli-auth/page.tsx b/app/app/cli-auth/page.tsx
new file mode 100644
index 00000000..de92e304
--- /dev/null
+++ b/app/app/cli-auth/page.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { useAuth, OrganizationSwitcher } from "@clerk/nextjs";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+} from "@/components/ui/card";
+import toast from "react-hot-toast";
+import { Loader } from "lucide-react";
+
+export default function Page() {
+ return (
+
+
+
+ );
+}
+
+function CliAuth() {
+ const { getToken, orgId, isLoaded } = useAuth();
+
+ const handleGetToken = async () => {
+ const newToken = await getToken({
+ template: "extended-cli-token",
+ });
+
+ if (!newToken) {
+ toast.error("Failed to get token");
+ return;
+ }
+
+ const url = new URL("http://localhost:9999");
+ url.searchParams.append("token", newToken);
+ window.location.href = url.toString();
+ };
+
+ if (!isLoaded) {
+ return ;
+ }
+
+ return (
+
+
+ CLI Authentication
+
+ Select an organization and confirm CLI authentication
+
+
+
+
+
+
+
+ Authenticate CLI
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/configs/[promptId]/edit/page.tsx b/app/app/clusters/[clusterId]/configs/[promptId]/edit/page.tsx
new file mode 100644
index 00000000..40ed8bde
--- /dev/null
+++ b/app/app/clusters/[clusterId]/configs/[promptId]/edit/page.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import {
+ JobMetricsCharts,
+ PromptMetricsCharts,
+} from "@/components/PromptMetricsCharts";
+import { PromptTemplateForm } from "@/components/chat/prompt-template-form";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn, createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { ChevronDownIcon } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "react-hot-toast";
+
+export default function EditPromptTemplate({
+ params,
+}: {
+ params: { clusterId: string; promptId: string };
+}) {
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(true);
+ const [promptTemplate, setPromptTemplate] = useState | null>(null);
+ const { getToken } = useAuth();
+
+ const [selectedVersion, setSelectedVersion] = useState(null);
+
+ const [metrics, setMetrics] = useState | null>(null);
+
+ const fetchPromptTemplate = useCallback(async () => {
+ try {
+ const response = await client.getRunConfig({
+ params: { clusterId: params.clusterId, configId: params.promptId },
+ query: {
+ withPreviousVersions: "true",
+ },
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ });
+
+ if (response.status === 200) {
+ setPromptTemplate(response.body);
+ setSelectedVersion(null);
+ } else {
+ createErrorToast(response, "Failed to fetch prompt template");
+ }
+ } catch (error) {
+ toast.error(
+ `An error occurred while fetching the prompt template: ${error}`,
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }, [params.clusterId, params.promptId, getToken]);
+
+ useEffect(() => {
+ fetchPromptTemplate();
+ }, [fetchPromptTemplate]);
+
+ const handleSubmit = async (formData: {
+ name: string;
+ initialPrompt?: string;
+ systemPrompt?: string;
+ attachedFunctions: string;
+ public: boolean;
+ resultSchema?: string;
+ inputSchema?: string;
+ }) => {
+ try {
+ const response = await client.upsertRunConfig({
+ params: { clusterId: params.clusterId, configId: params.promptId },
+ body: {
+ initialPrompt:
+ formData.initialPrompt === "" ? undefined : formData.initialPrompt,
+ systemPrompt:
+ formData.systemPrompt === "" ? undefined : formData.systemPrompt,
+ name: formData.name === "" ? undefined : formData.name,
+ public: formData.public,
+ resultSchema: formData.resultSchema
+ ? JSON.parse(formData.resultSchema)
+ : undefined,
+ inputSchema: formData.inputSchema
+ ? JSON.parse(formData.inputSchema)
+ : undefined,
+ attachedFunctions: formData.attachedFunctions
+ .split(",")
+ .map((f) => f.trim())
+ .filter((f) => f !== ""),
+ },
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ });
+
+ if (response.status === 200) {
+ toast.success("Run config updated successfully");
+ router.push(`/clusters/${params.clusterId}/configs`);
+ } else {
+ toast.error(`Failed to update prompt template: ${response.status}`);
+ }
+ } catch (error) {
+ toast.error(`An error occurred while updating the run config: ${error}`);
+ }
+ };
+
+ useEffect(() => {
+ const fetchMetrics = async () => {
+ const response = await client.getRunConfigMetrics({
+ params: { clusterId: params.clusterId, configId: params.promptId },
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ });
+
+ if (response.status === 200) {
+ setMetrics(response.body);
+ } else {
+ toast.error(`Failed to fetch metrics: ${response.status}`);
+ }
+ };
+
+ fetchMetrics();
+ }, [params.clusterId, params.promptId, getToken]);
+
+ if (isLoading) {
+ return Loading...
;
+ }
+
+ if (!promptTemplate) {
+ return Prompt template not found
;
+ }
+
+ return (
+
+
+
+ Update Run Configuration
+
+
+
+ Modify your run configuration below.
+
+
+
+
+
+ Switch Version
+
+
+
+
+ {
+ fetchPromptTemplate();
+ toast.success("Switched to current version");
+ }}
+ >
+ Current
+
+ {promptTemplate.versions
+ .sort((a, b) => b.version - a.version)
+ .map((version) => (
+ {
+ setPromptTemplate({
+ ...promptTemplate,
+ name: version.name,
+ initialPrompt: version.initialPrompt,
+ systemPrompt: version.systemPrompt,
+ attachedFunctions: version.attachedFunctions,
+ resultSchema: version.resultSchema,
+ inputSchema: version.inputSchema,
+ public: version.public,
+ });
+ setSelectedVersion(version.version);
+ toast.success(
+ `Switched to version v${version.version}`,
+ );
+ }}
+ >
+ v{version.version}
+
+ ))}
+
+
+
+
{
+ router.push(
+ `/clusters/${params.clusterId}/runs?filters=${encodeURIComponent(
+ JSON.stringify({
+ configId: params.promptId,
+ }),
+ )}`,
+ );
+ }}
+ >
+ Show runs
+
+
+
+
+
+
+
+ {metrics ? (
+ <>
+
+
+
+ >
+ ) : (
+
Loading metrics...
+ )}
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/configs/global/page.tsx b/app/app/clusters/[clusterId]/configs/global/page.tsx
new file mode 100644
index 00000000..689adf7e
--- /dev/null
+++ b/app/app/clusters/[clusterId]/configs/global/page.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import { Loading } from "@/components/loading";
+import { MarkdownEditor } from "@/components/markdown-editor";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { useAuth } from "@clerk/nextjs";
+import "@mdxeditor/editor/style.css";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { useCallback, useEffect, useState } from "react";
+import toast from "react-hot-toast";
+
+// Import necessary plugins
+
+export default function Page({ params }: { params: { clusterId: string } }) {
+ const { getToken } = useAuth();
+ const [clusterContext, setClusterContext] = useState<
+ | ClientInferResponseBody<
+ typeof contract.getCluster,
+ 200
+ >["additionalContext"]
+ | null
+ >(null);
+ const [error, setError] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [activePrompt, setActivePrompt] = useState("");
+ const [wordCount, setWordCount] = useState(0);
+ const [fetched, setFetched] = useState(false);
+
+ const fetchClusterContext = useCallback(async () => {
+ const response = await client.getCluster({
+ params: { clusterId: params.clusterId },
+ headers: { authorization: `Bearer ${await getToken()}` },
+ });
+
+ if (response.status === 200) {
+ setClusterContext(response.body.additionalContext);
+ const withoutHtmlTags =
+ response.body.additionalContext?.current.content.replace(
+ /<[^>]*>?/g,
+ "",
+ );
+ setActivePrompt(withoutHtmlTags ?? "");
+ setFetched(true);
+ } else {
+ throw new Error(`Failed to fetch cluster context: ${response.status}`);
+ }
+
+ setError(null);
+ }, [params.clusterId, getToken]);
+
+ useEffect(() => {
+ fetchClusterContext();
+ }, [fetchClusterContext]);
+
+ useEffect(() => {
+ const words = activePrompt.trim().split(/\s+/).length;
+ setWordCount(words);
+ }, [activePrompt]);
+
+ const handleSave = async () => {
+ const markdown = activePrompt;
+ const currentVersionHasChanged =
+ clusterContext?.current.content !== markdown;
+
+ if (!currentVersionHasChanged) {
+ toast.error("No changes to save");
+ return;
+ }
+
+ const currentVersion = Number(
+ clusterContext?.history
+ .map((version) => version.version)
+ .reduce(
+ (latest, version) => (version > latest ? version : latest),
+ clusterContext.current.version,
+ ) ?? 0,
+ );
+
+ const history = clusterContext
+ ? [
+ ...clusterContext.history,
+ {
+ version: clusterContext.current.version,
+ content: clusterContext.current.content,
+ },
+ ]
+ .sort((a, b) => Number(b.version) - Number(a.version))
+ .slice(0, 5)
+ : [];
+
+ setIsSaving(true);
+
+ try {
+ const token = await getToken();
+ if (!token) throw new Error("No token available");
+
+ const response = await client.updateCluster({
+ params: { clusterId: params.clusterId },
+ headers: { authorization: token },
+ body: {
+ additionalContext: {
+ current: {
+ version: String(currentVersion + 1),
+ content: markdown,
+ },
+ history,
+ },
+ },
+ });
+
+ if (response.status !== 204) {
+ throw new Error("Failed to update cluster context");
+ }
+
+ fetchClusterContext();
+
+ toast.success("Cluster context updated successfully");
+ } catch (err) {
+ console.error(err);
+ toast.error("Failed to update cluster context");
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if (error) {
+ return Error: {error}
;
+ }
+
+ if (!fetched) {
+ return ;
+ }
+
+ return (
+
+
+
+ Edit Global Context
+
+ Update your global context. This will be included in all runs.
+
+
+
+ {clusterContext && (
+
+
Previous Versions
+
+ {clusterContext.history.map((version, index) => (
+ setActivePrompt(version.content)}
+ key={index}
+ variant="outline"
+ size="sm"
+ className="text-xs"
+ >
+ v{version.version}
+
+ ))}
+
+
+ )}
+
+ Current Version (v{clusterContext?.current.version})
+
+ {
+ setActivePrompt(markdown);
+ }}
+ />
+
+
+ {wordCount} {wordCount === 1 ? "word" : "words"}
+ {wordCount > 300 && (
+
+ Warning: Exceeds 300 words
+
+ )}
+
+
+
+ {isSaving ? "Saving..." : "Save Changes"}
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/configs/layout.tsx b/app/app/clusters/[clusterId]/configs/layout.tsx
new file mode 100644
index 00000000..3ef4abdf
--- /dev/null
+++ b/app/app/clusters/[clusterId]/configs/layout.tsx
@@ -0,0 +1,3 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/app/app/clusters/[clusterId]/configs/new/page.tsx b/app/app/clusters/[clusterId]/configs/new/page.tsx
new file mode 100644
index 00000000..0a86f5ab
--- /dev/null
+++ b/app/app/clusters/[clusterId]/configs/new/page.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { client } from "@/client/client";
+import { PromptTemplateForm } from "@/components/chat/prompt-template-form";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { toast } from "react-hot-toast";
+
+export default function NewPromptTemplate({
+ params,
+}: {
+ params: { clusterId: string };
+}) {
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(false);
+ const { getToken } = useAuth();
+ const handleSubmit = async (formData: {
+ name: string;
+ initialPrompt?: string;
+ systemPrompt?: string;
+ public: boolean;
+ attachedFunctions: string;
+ resultSchema?: string;
+ inputSchema?: string;
+ }) => {
+ setIsLoading(true);
+ if (formData.name === "") {
+ toast.error("Please enter a name for the Run Configuration");
+ return;
+ }
+
+ try {
+ const response = await client.createRunConfig({
+ params: { clusterId: params.clusterId },
+ body: {
+ name: formData.name,
+ initialPrompt:
+ formData.initialPrompt === "" ? undefined : formData.initialPrompt,
+ systemPrompt:
+ formData.systemPrompt === "" ? undefined : formData.systemPrompt,
+ public: formData.public,
+ resultSchema: formData.resultSchema
+ ? JSON.parse(formData.resultSchema)
+ : undefined,
+ inputSchema: formData.inputSchema
+ ? JSON.parse(formData.inputSchema)
+ : undefined,
+ attachedFunctions: formData.attachedFunctions
+ .split(",")
+ .map((f) => f.trim())
+ .filter((f) => f !== ""),
+ },
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ });
+
+ if (response.status === 201) {
+ toast.success("Run Configuration created successfully");
+ router.push(
+ `/clusters/${params.clusterId}/configs/${response.body.id}/edit`,
+ );
+ } else {
+ createErrorToast(response, "Failed to create Run Configuration");
+ }
+ } catch (error) {
+ toast.error(
+ `An error occurred while creating the Run Configuration: ${error}`,
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
Create New Run Configuration
+
+
+ Please see our{" "}
+
+ docs
+ {" "}
+ for more information
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/configs/page.tsx b/app/app/clusters/[clusterId]/configs/page.tsx
new file mode 100644
index 00000000..34785fca
--- /dev/null
+++ b/app/app/clusters/[clusterId]/configs/page.tsx
@@ -0,0 +1,220 @@
+"use client";
+
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import { Button } from "@/components/ui/button";
+import { DataTable } from "@/components/ui/data-table";
+import { useAuth } from "@clerk/nextjs";
+import { ColumnDef } from "@tanstack/react-table";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { formatDistanceToNow } from "date-fns";
+import { ArrowUpDown, Globe, PlusIcon } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+
+type Prompt = ClientInferResponseBody<
+ typeof contract.listRunConfigs,
+ 200
+>[number];
+
+const columns: ColumnDef[] = [
+ {
+ accessorKey: "name",
+ header: ({ column }) => {
+ return (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ Name
+
+
+ );
+ },
+ cell: ({ row }) => (
+
+
{row.getValue("name")}
+
+ {row.original.initialPrompt}
+
+ {row.original.attachedFunctions.filter(Boolean).length > 0 && (
+
+ {row.original.attachedFunctions.map((tool) => (
+
+ {tool}
+
+ ))}
+
+ )}
+
+ ),
+ },
+ {
+ id: "id",
+ header: "Configuration ID",
+ cell: ({ row }) => (
+ {row.original.id}
+ ),
+ },
+ {
+ id: "lastUpdated",
+ header: "Last Updated",
+ cell: ({ row }) => (
+
+ {row.original.updatedAt
+ ? formatDistanceToNow(new Date(row.original.updatedAt), {
+ addSuffix: true,
+ })
+ : "N/A"}
+
+ ),
+ },
+ {
+ id: "actions",
+ cell: function Cell({ row }) {
+ const [isDeleting, setIsDeleting] = useState(false);
+ const { getToken } = useAuth();
+ const router = useRouter();
+
+ const handleDelete = async () => {
+ if (
+ confirm("Are you sure you want to delete this Run Configuration?")
+ ) {
+ setIsDeleting(true);
+ try {
+ const token = await getToken();
+ if (!token) throw new Error("No token available");
+
+ const response = await client.deleteRunConfig({
+ params: {
+ clusterId: row.original.clusterId,
+ configId: row.original.id,
+ },
+ headers: { authorization: token },
+ });
+
+ if (response.status !== 204) {
+ throw new Error("Failed to delete Run Configuration");
+ } else {
+ router.refresh();
+ }
+ } catch (err) {
+ console.error(err);
+ alert("An error occurred while deleting the Run Configuration");
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+ };
+
+ return (
+
+
+
+ Run
+
+
+
+
+ Edit
+
+
+
+ {isDeleting ? "Deleting..." : "Delete"}
+
+
+ );
+ },
+ },
+];
+
+export default function Page({ params }: { params: { clusterId: string } }) {
+ const { getToken } = useAuth();
+ const [prompts, setPrompts] = useState<
+ ClientInferResponseBody
+ >([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const router = useRouter();
+
+ useEffect(() => {
+ const fetchPrompts = async () => {
+ setIsLoading(true);
+ try {
+ const token = await getToken();
+
+ const response = await client.listRunConfigs({
+ params: { clusterId: params.clusterId },
+ headers: { authorization: `Bearer ${token}` },
+ });
+
+ if (response.status !== 200) {
+ throw new Error("Failed to fetch run configs");
+ }
+
+ setPrompts(response.body);
+ setError(null);
+ } catch (err) {
+ setError("An error occurred while fetching run configs");
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchPrompts();
+ }, [params.clusterId, getToken]);
+
+ if (isLoading) {
+ return Loading run configurations...
;
+ }
+
+ if (error) {
+ return Error: {error}
;
+ }
+
+ return (
+
+
Saved Run Configurations
+
+ Saved run configurations are reusable configurations that can be used in
+ your next run.
+
+
+
{
+ router.push(`/clusters/${params.clusterId}/configs/new`);
+ }}
+ >
+
+ Create New Run Configuration
+
+
{
+ router.push(`/clusters/${params.clusterId}/configs/global`);
+ }}
+ >
+
+ Global Context
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/integrations/langfuse/page.tsx b/app/app/clusters/[clusterId]/integrations/langfuse/page.tsx
new file mode 100644
index 00000000..00eb494b
--- /dev/null
+++ b/app/app/clusters/[clusterId]/integrations/langfuse/page.tsx
@@ -0,0 +1,232 @@
+"use client";
+
+import { client } from "@/client/client";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useCallback, useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import toast from "react-hot-toast";
+import { z } from "zod";
+import { ArrowLeft } from "lucide-react";
+import Link from "next/link";
+import { Switch } from "@/components/ui/switch";
+import { Loading } from "@/components/loading";
+
+const formSchema = z.object({
+ secretKey: z.string().min(1, "API key is required"),
+ publicKey: z.string().min(1, "Public key is required"),
+ baseUrl: z.string().min(1, "Base URL is required"),
+ sendMessagePayloads: z
+ .boolean()
+ .describe(
+ "Send all message payloads or just the LLM metadata? (LLM metadata includes the LLM response, tokens, and latency)",
+ )
+ .default(false),
+});
+
+export default function LangfuseIntegration({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ const { getToken } = useAuth();
+ const [loading, setLoading] = useState(false);
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ secretKey: "",
+ publicKey: "",
+ baseUrl: "",
+ sendMessagePayloads: false,
+ },
+ });
+
+ const onSubmit = async (data: z.infer) => {
+ const response = await client.upsertIntegrations({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: clusterId,
+ },
+ body: {
+ langfuse: {
+ secretKey: data.secretKey,
+ publicKey: data.publicKey,
+ baseUrl: data.baseUrl,
+ sendMessagePayloads: data.sendMessagePayloads,
+ },
+ },
+ });
+
+ if (response.status === 200) {
+ toast.success("Integration updated");
+ await fetchConfig();
+ return;
+ } else {
+ createErrorToast(response, "Failed to update integration");
+ }
+ };
+
+ const fetchConfig = useCallback(async () => {
+ setLoading(true);
+ const response = await client.getIntegrations({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: clusterId,
+ },
+ });
+ setLoading(false);
+
+ if (response.status === 200) {
+ const result = z
+ .object({
+ secretKey: z.string(),
+ publicKey: z.string(),
+ baseUrl: z.string(),
+ sendMessagePayloads: z.boolean(),
+ })
+ .safeParse(response.body?.langfuse);
+
+ if (result.success) {
+ form.setValue("secretKey", result.data.secretKey);
+ form.setValue("publicKey", result.data.publicKey);
+ form.setValue("baseUrl", result.data.baseUrl);
+ form.setValue("sendMessagePayloads", result.data.sendMessagePayloads);
+ }
+ }
+ }, [clusterId, getToken, form]);
+
+ useEffect(() => {
+ fetchConfig();
+ }, [fetchConfig]);
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Back to integrations
+
+
+
+
+
+
+ 📊
+ Configure Langfuse
+
+
+ Connect your Langfuse account to send LLM telemetry data for
+ monitoring and analytics. You can find your API keys in your
+ Langfuse dashboard.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/integrations/layout.tsx b/app/app/clusters/[clusterId]/integrations/layout.tsx
new file mode 100644
index 00000000..a12db3c1
--- /dev/null
+++ b/app/app/clusters/[clusterId]/integrations/layout.tsx
@@ -0,0 +1,3 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/app/app/clusters/[clusterId]/integrations/page.tsx b/app/app/clusters/[clusterId]/integrations/page.tsx
new file mode 100644
index 00000000..98c68b41
--- /dev/null
+++ b/app/app/clusters/[clusterId]/integrations/page.tsx
@@ -0,0 +1,145 @@
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+} from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import Link from "next/link";
+import { ArrowRight, Trash2 } from "lucide-react";
+import { client } from "@/client/client";
+import { auth } from "@clerk/nextjs";
+import ErrorDisplay from "@/components/error-display";
+import { revalidatePath } from "next/cache";
+
+const config = {
+ toolhouse: {
+ name: "Toolhouse",
+ description:
+ "Connect your toolhouse.ai tools directly to your Inferable Runs",
+ icon: "🛠️",
+ slug: "toolhouse",
+ },
+ langfuse: {
+ name: "Langfuse",
+ description: "Send LLM telemetry to Langfuse for monitoring and analytics",
+ icon: "📊",
+ slug: "langfuse",
+ },
+};
+
+export default async function IntegrationsPage({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ const { getToken } = auth();
+
+ const response = await client.getIntegrations({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId,
+ },
+ });
+
+ if (response.status !== 200) {
+ return ;
+ }
+
+ async function handleUninstall(formData: FormData) {
+ "use server";
+
+ const name = formData.get("name") as string;
+
+ const { getToken } = auth();
+
+ await client.upsertIntegrations({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: { clusterId },
+ body: {
+ [name]: null,
+ },
+ });
+
+ revalidatePath(`/clusters/${clusterId}/integrations`);
+ }
+
+ return (
+
+
+
Integrations
+
+ Connect your Inferable cluster with other tools and services
+
+
+
+ {Object.entries(response.body).map(([key, integration]) => (
+
+
+
+ {key}
+
+
+ {config[key as keyof typeof config]?.description ?? "Unknown"}
+
+
+
+
+
+
+ {integration !== null ? "Configure" : "Install"}
+
+
+
+ {integration !== null && (
+
+ )}
+
+
+
+ ))}
+
+
+
+
+ Zapier
+
+
+ Integrate your Inferable Runs with Zapier
+
+
+
+
+
+ Learn more
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/integrations/toolhouse/page.tsx b/app/app/clusters/[clusterId]/integrations/toolhouse/page.tsx
new file mode 100644
index 00000000..293a7b9c
--- /dev/null
+++ b/app/app/clusters/[clusterId]/integrations/toolhouse/page.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import { client } from "@/client/client";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useCallback, useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import toast from "react-hot-toast";
+import { z } from "zod";
+import { ArrowLeft } from "lucide-react";
+import Link from "next/link";
+import { Loading } from "@/components/loading";
+
+const formSchema = z.object({
+ apiKey: z.string().min(1, "API key is required"),
+});
+
+export default function ToolhouseIntegration({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ const { getToken } = useAuth();
+ const [loading, setLoading] = useState(false);
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ apiKey: "",
+ },
+ });
+
+ const onSubmit = async (data: z.infer) => {
+ const response = await client.upsertIntegrations({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: clusterId,
+ },
+ body: {
+ toolhouse: {
+ apiKey: data.apiKey,
+ },
+ },
+ });
+
+ if (response.status === 200) {
+ toast.success("Integration updated");
+ await fetchConfig();
+ return;
+ } else {
+ console.error(response);
+ createErrorToast(response, "Failed to update integration");
+ }
+ };
+
+ const fetchConfig = useCallback(async () => {
+ setLoading(true);
+ const response = await client.getIntegrations({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: clusterId,
+ },
+ });
+ setLoading(false);
+
+ if (response.status === 200) {
+ const result = z
+ .object({
+ apiKey: z.string(),
+ })
+ .safeParse(response.body?.toolhouse);
+
+ if (result.success) {
+ form.setValue("apiKey", result.data.apiKey);
+ }
+ }
+ }, [clusterId, getToken, form]);
+
+ useEffect(() => {
+ fetchConfig();
+ }, [fetchConfig]);
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Back to integrations
+
+
+
+
+
+
+ 🛠️
+ Configure Toolhouse
+
+
+ Connect your Toolhouse account to use your installed tools in this
+ cluster. For more information, see{" "}
+
+ our docs
+
+ .
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/knowledge/[artifactId]/edit/page.tsx b/app/app/clusters/[clusterId]/knowledge/[artifactId]/edit/page.tsx
new file mode 100644
index 00000000..5258d486
--- /dev/null
+++ b/app/app/clusters/[clusterId]/knowledge/[artifactId]/edit/page.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useAuth } from "@clerk/nextjs";
+import { client } from "@/client/client";
+import toast from "react-hot-toast";
+import { useParams, useRouter } from "next/navigation";
+import {
+ KnowledgeArtifactForm,
+ KnowledgeArtifact,
+} from "@/components/KnowledgeArtifactForm";
+import { createErrorToast } from "@/lib/utils";
+
+export default function EditKnowledgeArtifact() {
+ const { getToken } = useAuth();
+ const params = useParams();
+ const router = useRouter();
+ const clusterId = params?.clusterId as string;
+ const artifactId = params?.artifactId as string;
+ const [artifact, setArtifact] = useState(null);
+
+ useEffect(() => {
+ const fetchArtifact = async () => {
+ try {
+ const token = await getToken();
+ const response = await client.getKnowledgeArtifact({
+ params: { clusterId, artifactId },
+ headers: { authorization: token as string },
+ });
+
+ if (response.status === 200) {
+ setArtifact(response.body);
+ } else {
+ createErrorToast(response, "Artifact not found");
+ router.push(`/clusters/${clusterId}/knowledge`);
+ }
+ } catch (error) {
+ console.error("Error fetching artifact:", error);
+ toast.error("Failed to fetch knowledge artifact");
+ }
+ };
+
+ fetchArtifact();
+ }, [getToken, clusterId, artifactId, router]);
+
+ const handleUpdate = async (updatedArtifact: KnowledgeArtifact) => {
+ try {
+ const token = await getToken();
+
+ const response = await client.upsertKnowledgeArtifact({
+ params: { clusterId, artifactId },
+ headers: { authorization: token as string },
+ body: updatedArtifact,
+ });
+
+ if (response.status === 200) {
+ toast.success("Knowledge artifact updated successfully");
+ } else {
+ createErrorToast(response, "Failed to update knowledge artifact");
+ }
+ } catch (error) {
+ console.error("Error updating artifact:", error);
+ toast.error("Failed to update knowledge artifact");
+ }
+ };
+
+ if (!artifact) {
+ return null;
+ }
+
+ return (
+ router.push(`/clusters/${clusterId}/knowledge`)}
+ submitButtonText="Update Artifact"
+ editing={true}
+ />
+ );
+}
diff --git a/app/app/clusters/[clusterId]/knowledge/layout.tsx b/app/app/clusters/[clusterId]/knowledge/layout.tsx
new file mode 100644
index 00000000..80ef9167
--- /dev/null
+++ b/app/app/clusters/[clusterId]/knowledge/layout.tsx
@@ -0,0 +1,270 @@
+"use client";
+
+import { client } from "@/client/client";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Textarea } from "@/components/ui/textarea";
+import { useAuth } from "@clerk/nextjs";
+import { truncate } from "lodash";
+import { GlobeIcon, PlusIcon, TrashIcon, UploadIcon } from "lucide-react";
+import Link from "next/link";
+import { useParams, useRouter } from "next/navigation";
+import { useCallback, useEffect, useState } from "react";
+import toast from "react-hot-toast";
+
+type KnowledgeArtifact = {
+ id: string;
+ data: string;
+ tags: string[];
+ title: string;
+ similarity?: number;
+};
+
+export default function KnowledgeLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const { getToken } = useAuth();
+ const params = useParams();
+ const router = useRouter();
+ const clusterId = params?.clusterId as string;
+ const [artifacts, setArtifacts] = useState([]);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
+ const [bulkUploadJson, setBulkUploadJson] = useState("");
+
+ const fetchArtifacts = useCallback(async () => {
+ try {
+ const token = await getToken();
+ const response = await client.getKnowledge({
+ params: { clusterId },
+ headers: { authorization: token as string },
+ query: { query: searchQuery, limit: 20 },
+ });
+
+ if (response.status === 200) {
+ setArtifacts(response.body);
+ }
+ } catch (error) {
+ console.error("Error fetching artifacts:", error);
+ toast.error("Failed to fetch knowledge artifacts");
+ }
+ }, [getToken, clusterId, searchQuery]);
+
+ useEffect(() => {
+ fetchArtifacts();
+ }, [fetchArtifacts]);
+
+ const handleSearch = () => {
+ fetchArtifacts();
+ };
+
+ const handleDelete = async (id: string) => {
+ try {
+ const token = await getToken();
+ const response = await client.deleteKnowledgeArtifact({
+ params: { clusterId, artifactId: id },
+ headers: { authorization: token as string },
+ });
+
+ if (response.status === 204) {
+ toast.success("Knowledge artifact deleted successfully");
+ fetchArtifacts();
+ }
+ } catch (error) {
+ console.error("Error deleting artifact:", error);
+ toast.error("Failed to delete knowledge artifact");
+ }
+ };
+
+ const handleBulkUpload = async () => {
+ try {
+ const artifacts = JSON.parse(bulkUploadJson);
+ if (!Array.isArray(artifacts)) {
+ throw new Error("Invalid JSON format. Expected an array of artifacts.");
+ }
+
+ const loading = toast.loading("Uploading knowledge artifacts...");
+
+ const token = await getToken();
+ let successCount = 0;
+ let failCount = 0;
+
+ for (const artifact of artifacts) {
+ try {
+ const response = await client.upsertKnowledgeArtifact({
+ params: { clusterId, artifactId: artifact.id },
+ headers: { authorization: token as string },
+ body: artifact,
+ });
+
+ if (response.status === 201) {
+ successCount++;
+ } else {
+ failCount++;
+ }
+ } catch (error) {
+ console.error("Error creating artifact:", error);
+ failCount++;
+ }
+ }
+
+ toast.remove(loading);
+ toast.success(
+ `Bulk upload completed. Success: ${successCount}, Failed: ${failCount}`,
+ );
+ setIsUploadDialogOpen(false);
+ fetchArtifacts();
+ } catch (error) {
+ console.error("Error parsing JSON:", error);
+ toast.error(
+ "Failed to parse JSON. Please check the format and try again.",
+ );
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ Global Context
+
+
+
+
+ router.push(`/clusters/${clusterId}/knowledge/new`)
+ }
+ size="sm"
+ >
+
+ New
+
+
+
+
+
+
+
+
+
+ Bulk Upload Knowledge Artifacts
+
+
+
+ Paste your JSON array of knowledge artifacts below. Note
+ that items with the same ID will be overwritten.
+
+
+
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search artifacts..."
+ className="mr-2"
+ />
+
+ Search
+
+
+
+ {artifacts.map((artifact) => (
+
+ router.push(
+ `/clusters/${clusterId}/knowledge/${artifact.id}/edit`,
+ )
+ }
+ onDelete={() => handleDelete(artifact.id)}
+ />
+ ))}
+
+
+
+
+
{children}
+
+ );
+}
+
+function ArtifactPill({
+ artifact,
+ onSelect,
+ onDelete,
+}: {
+ artifact: KnowledgeArtifact;
+ onSelect: () => void;
+ onDelete: () => void;
+}) {
+ const tagColor = generateColorFromString(artifact.tags.sort().join(","));
+
+ return (
+
+
+
+
+ {truncate(artifact.title, { length: 100 })}
+
+
{artifact.tags.join(", ")}
+
+
{
+ e.stopPropagation();
+ onDelete();
+ }}
+ size="icon"
+ variant="ghost"
+ >
+
+
+
+ );
+}
+
+function generateColorFromString(str: string): string {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ const hue = hash % 360;
+ return `hsl(${hue}, 70%, 60%)`; // Adjusted lightness to 60% for better visibility
+}
diff --git a/app/app/clusters/[clusterId]/knowledge/new/page.tsx b/app/app/clusters/[clusterId]/knowledge/new/page.tsx
new file mode 100644
index 00000000..63c14e2d
--- /dev/null
+++ b/app/app/clusters/[clusterId]/knowledge/new/page.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useAuth } from "@clerk/nextjs";
+import { client } from "@/client/client";
+import toast from "react-hot-toast";
+import { useParams, useRouter } from "next/navigation";
+import { ulid } from "ulid";
+import {
+ KnowledgeArtifactForm,
+ KnowledgeArtifact,
+} from "@/components/KnowledgeArtifactForm";
+import { createErrorToast } from "@/lib/utils";
+
+export default function NewKnowledgeArtifact() {
+ const { getToken } = useAuth();
+ const params = useParams();
+ const router = useRouter();
+ const clusterId = params?.clusterId as string;
+
+ const handleCreate = async (newArtifact: KnowledgeArtifact) => {
+ try {
+ const token = await getToken();
+ const newId = ulid();
+ const response = await client.upsertKnowledgeArtifact({
+ params: { clusterId, artifactId: newId },
+ headers: { authorization: token as string },
+ body: newArtifact,
+ });
+
+ if (response.status === 200) {
+ toast.success("Knowledge artifact created successfully");
+ router.push(`/clusters/${clusterId}/knowledge`);
+ } else {
+ createErrorToast(response, "Failed to create knowledge artifact");
+ }
+ } catch (error) {
+ console.error("Error creating artifact:", error);
+ toast.error("Failed to create knowledge artifact");
+ }
+ };
+
+ const handleCancel = () => {
+ router.push(`/clusters/${clusterId}/knowledge`);
+ };
+
+ return (
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/knowledge/page.tsx b/app/app/clusters/[clusterId]/knowledge/page.tsx
new file mode 100644
index 00000000..7a0be038
--- /dev/null
+++ b/app/app/clusters/[clusterId]/knowledge/page.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { KnowledgeQuickstart } from "@/components/knowledge-quickstart";
+
+export default function KnowledgeOverview() {
+ return (
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/layout.tsx b/app/app/clusters/[clusterId]/layout.tsx
new file mode 100644
index 00000000..92c4236b
--- /dev/null
+++ b/app/app/clusters/[clusterId]/layout.tsx
@@ -0,0 +1,16 @@
+import { ClusterBreadcrumbs } from "@/components/breadcrumbs";
+
+export default async function Layout({
+ children,
+ params: { clusterId },
+}: {
+ children: React.ReactNode;
+ params: { clusterId: string };
+}) {
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/app/app/clusters/[clusterId]/page.tsx b/app/app/clusters/[clusterId]/page.tsx
new file mode 100644
index 00000000..bce73f46
--- /dev/null
+++ b/app/app/clusters/[clusterId]/page.tsx
@@ -0,0 +1,516 @@
+"use client";
+
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import { LiveAmberCircle, LiveGreenCircle } from "@/components/circles";
+import ErrorDisplay from "@/components/error-display";
+import { Loading } from "@/components/loading";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { useAuth } from "@clerk/nextjs";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { ClipboardCopy } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import { toast } from "react-hot-toast";
+import theme from "react-syntax-highlighter/dist/cjs/styles/hljs/tomorrow";
+import SyntaxHighlighter from "react-syntax-highlighter";
+
+function OnboardingStep({
+ number,
+ title,
+ description,
+ completed,
+ children,
+ grayedOut,
+}: {
+ number: number;
+ title: string;
+ description: string;
+ completed: boolean;
+ grayedOut: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {completed ? "✓" : number}
+
+
+
{title}
+
{description}
+
{children}
+
+
+
+ );
+}
+
+const script = (
+ apiKey: string,
+ localhost: boolean = false,
+) => `echo 'const { Inferable } = require("inferable");
+
+const client = new Inferable({
+ apiSecret: "${apiKey}", ${localhost ? 'endpoint: "http://localhost:4000"' : ""}
+});
+
+client.default.register({
+ name: "userInfo",
+ func: async () => {
+ return require("os").userInfo();
+ },
+});
+
+client.default.start();' > quickstart.js && npm install inferable@latest && node quickstart.js`;
+
+export default function Page({ params }: { params: { clusterId: string } }) {
+ const [cluster, setCluster] = useState | null>(null);
+ const [runs, setRuns] = useState<
+ ClientInferResponseBody
+ >([]);
+ const [apiKeys, setApiKeys] = useState<
+ ClientInferResponseBody
+ >([]);
+ const [error, setError] = useState<{ error: any; status: number } | null>(
+ null,
+ );
+ const [services, setServices] = useState<
+ ClientInferResponseBody
+ >([]);
+ const { getToken } = useAuth();
+
+ const [hasCustomName, setHasCustomName] = useState(false);
+ const [skippedOnboarding, setSkippedOnboarding] = useState(false);
+
+ useEffect(() => {
+ const interval = setInterval(async () => {
+ const services = await client.listServices({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: params.clusterId,
+ },
+ });
+
+ if (services.status === 200) {
+ setServices(services.body);
+ }
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [params.clusterId, getToken]);
+
+ useEffect(() => {
+ async function fetchData() {
+ const token = await getToken();
+
+ const clusterResult = await client.getCluster({
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ params: {
+ clusterId: params.clusterId,
+ },
+ });
+
+ if (clusterResult.status !== 200) {
+ setError({ error: clusterResult.body, status: clusterResult.status });
+ return;
+ }
+
+ const runsResult = await client.listRuns({
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ params: {
+ clusterId: params.clusterId,
+ },
+ });
+
+ if (runsResult.status !== 200) {
+ setError({ error: runsResult.body, status: runsResult.status });
+ return;
+ }
+
+ const apiKeysResult = await client.listApiKeys({
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ params: {
+ clusterId: params.clusterId,
+ },
+ });
+
+ if (apiKeysResult.status !== 200) {
+ setError({ error: apiKeysResult.body, status: apiKeysResult.status });
+ return;
+ }
+
+ setCluster(clusterResult.body);
+ setClusterName(clusterResult.body.name);
+ setRuns(runsResult.body);
+ setApiKeys(apiKeysResult.body);
+ }
+
+ fetchData();
+ }, [params.clusterId, getToken]);
+
+ useEffect(() => {
+ const skipped = localStorage.getItem(
+ `onboarding-skipped-${params.clusterId}`,
+ );
+ if (skipped === "true") {
+ setSkippedOnboarding(true);
+ }
+ }, [params.clusterId]);
+
+ const [clusterName, setClusterName] = useState("");
+
+ const [createdApiKey, setCreatedApiKey] = useState | null>(null);
+
+ const router = useRouter();
+
+ if (error) {
+ return ;
+ }
+
+ if (!cluster) {
+ return ;
+ }
+
+ const hasRuns = runs.length > 0;
+ const hasQuickstartService = services.length > 0;
+
+ const handleRename = async (name: string) => {
+ const result = await client.updateCluster({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: { clusterId: params.clusterId },
+ body: { name },
+ });
+
+ if (result.status === 204) {
+ setClusterName(name);
+ setHasCustomName(true);
+ } else {
+ setError({ error: result.body, status: result.status });
+ }
+ };
+
+ const handleCreateApiKey = async () => {
+ const random = Math.random().toString(36).substring(2, 6);
+ const name = `quickstart-${random}`;
+
+ const result = await client.createApiKey({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: params.clusterId,
+ },
+ body: {
+ name,
+ },
+ });
+
+ if (result.status !== 200) {
+ setError({ error: result.body, status: result.status });
+ return;
+ }
+
+ setCreatedApiKey(result.body);
+
+ // Refresh API keys
+ const apiKeysResult = await client.listApiKeys({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: params.clusterId,
+ },
+ });
+
+ if (apiKeysResult.status === 200) {
+ setApiKeys(apiKeysResult.body);
+ }
+ };
+
+ const handleCreateRun = async () => {
+ const created = await client.createRun({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: params.clusterId,
+ },
+ body: {
+ initialPrompt: "Can you summarise my user information?",
+ name: "My first run",
+ },
+ });
+
+ if (created.status === 201) {
+ localStorage.setItem(`onboarding-completed-${params.clusterId}`, "true");
+ toast.success("🙌 First run created. Redirecting you there...");
+ router.push(`/clusters/${params.clusterId}/runs/${created.body.id}`);
+ } else {
+ setError({ error: created.body, status: created.status });
+ }
+ };
+
+ const handleSkipOnboarding = () => {
+ localStorage.setItem(`onboarding-skipped-${params.clusterId}`, "true");
+ setSkippedOnboarding(true);
+ };
+
+ const skipOnboarding = skippedOnboarding || runs.length > 0;
+
+ if (!skipOnboarding) {
+ return (
+
+ <>
+
+
Welcome to your new cluster
+
+ Skip onboarding
+
+
+
+
+
+ setClusterName(e.target.value)}
+ />
+ handleRename(clusterName)}
+ disabled={clusterName === cluster.name}
+ >
+ Rename
+
+
+
+
+
+
+ {createdApiKey ? (
+
+
+ We created an API key for you. Copy it to your clipboard,
+ because you won't be able to see it later.
+
+
+ {createdApiKey.key}
+
+
{
+ navigator.clipboard.writeText(createdApiKey.key);
+ toast.success("Copied to clipboard");
+ }}
+ >
+ Copy to clipboard
+
+
+
+ ) : (
+
+ Create API key
+
+ )}
+
+
+
+
+
+
+ {hasQuickstartService ? (
+
+
+
+ Quickstart service has been connected
+
+
+ ) : (
+
+
+
+ Waiting for quickstart service to connect...
+
+
+ )}
+
+ {createdApiKey && (
+
+
+ {script(
+ createdApiKey?.key ?? "",
+ window.location.hostname === "localhost",
+ )}
+
+
{
+ navigator.clipboard.writeText(
+ script(
+ createdApiKey?.key ?? "",
+ window.location.hostname === "localhost",
+ ),
+ );
+ toast.success("Copied to clipboard");
+ }}
+ >
+
+
+
+ Copy and paste the script above into your terminal to
+ connect the quickstart function.
+
+
+ )}
+
+
+
+
+
+
+
+ Create run
+
+
+
+
+ >
+
+ );
+ }
+
+ return (
+
+
Cluster Overview
+
+
+
+ Go to my runs
+
+ View and manage your existing runs or create new ones
+
+
+
+ router.push(`/clusters/${params.clusterId}/runs`)}
+ variant="secondary"
+ >
+ View runs
+
+
+
+
+
+
+ Configure cluster
+
+ Manage API keys, services, and cluster settings
+
+
+
+
+ router.push(`/clusters/${params.clusterId}/settings`)
+ }
+ variant="secondary"
+ >
+ Open settings
+
+
+
+
+
+
+ Read the docs
+
+ Learn more about how to use your cluster effectively
+
+
+
+ window.open("https://docs.inferable.ai", "_blank")}
+ variant="secondary"
+ >
+ Open docs
+
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/runs/[runId]/page.tsx b/app/app/clusters/[clusterId]/runs/[runId]/page.tsx
new file mode 100644
index 00000000..397366c7
--- /dev/null
+++ b/app/app/clusters/[clusterId]/runs/[runId]/page.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { EventsOverlayButton } from "@/components/events-overlay";
+import { Run } from "@/components/workflow";
+
+function Page({
+ params: { clusterId, runId },
+}: {
+ params: {
+ clusterId: string;
+ runId: string;
+ };
+}) {
+ return (
+
+ );
+}
+
+export default Page;
diff --git a/app/app/clusters/[clusterId]/runs/layout.tsx b/app/app/clusters/[clusterId]/runs/layout.tsx
new file mode 100644
index 00000000..a771576e
--- /dev/null
+++ b/app/app/clusters/[clusterId]/runs/layout.tsx
@@ -0,0 +1,50 @@
+import { client } from "@/client/client";
+import { ClusterDetails } from "@/components/cluster-details";
+import { RunList } from "@/components/WorkflowList";
+import { auth } from "@clerk/nextjs";
+
+export async function generateMetadata({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ const { getToken } = auth();
+ const token = await getToken();
+
+ const cluster = await client.getCluster({
+ headers: { authorization: `Bearer ${token}` },
+ params: { clusterId },
+ });
+
+ if (cluster.status !== 200) {
+ return { title: "Inferable" };
+ }
+
+ return { title: `${cluster.body?.name}` };
+}
+
+function Home({
+ params: { clusterId },
+ children,
+}: {
+ params: {
+ clusterId: string;
+ };
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+export default Home;
diff --git a/app/app/clusters/[clusterId]/runs/page.tsx b/app/app/clusters/[clusterId]/runs/page.tsx
new file mode 100644
index 00000000..003382f8
--- /dev/null
+++ b/app/app/clusters/[clusterId]/runs/page.tsx
@@ -0,0 +1,67 @@
+import { client } from "@/client/client";
+import { PromptTextarea } from "@/components/chat/prompt-textarea";
+import ErrorDisplay from "@/components/error-display";
+import { ServicesQuickstart } from "@/components/services-quickstart";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { auth } from "@clerk/nextjs";
+import { MessageSquarePlus } from "lucide-react";
+
+export default async function Page({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ const { getToken } = auth();
+ const token = await getToken();
+
+ const response = await client.getCluster({
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ params: {
+ clusterId: clusterId,
+ },
+ });
+
+ // TODO: limit this to one event.
+ const events = await client.listEvents({
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ params: { clusterId },
+ });
+
+ if (response.status !== 200 || events.status !== 200) {
+ return ;
+ }
+
+ if (events.body?.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+ Start with a prompt
+
+
+ Start a new run by entering a prompt, or selecting from the
+ available Run Configurations.
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/settings/api-keys/page.tsx b/app/app/clusters/[clusterId]/settings/api-keys/page.tsx
new file mode 100644
index 00000000..e1a99497
--- /dev/null
+++ b/app/app/clusters/[clusterId]/settings/api-keys/page.tsx
@@ -0,0 +1,9 @@
+import { ApiKeys } from "@/components/api-keys";
+
+export default function ApiKeysPage({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ return ;
+}
diff --git a/app/app/clusters/[clusterId]/settings/danger/page.tsx b/app/app/clusters/[clusterId]/settings/danger/page.tsx
new file mode 100644
index 00000000..695b9430
--- /dev/null
+++ b/app/app/clusters/[clusterId]/settings/danger/page.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { client } from "@/client/client";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { useState, useCallback } from "react";
+import toast from "react-hot-toast";
+
+export default function DangerPage({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ const { getToken } = useAuth();
+ const [deleteConfirmation, setDeleteConfirmation] = useState("");
+
+ const handleDeleteCluster = useCallback(async () => {
+ try {
+ const result = await client.deleteCluster({
+ headers: { authorization: `Bearer ${await getToken()}` },
+ params: { clusterId },
+ });
+
+ if (result.status === 204) {
+ toast.success("Cluster deleted successfully");
+ // Redirect to clusters page
+ window.location.href = "/clusters";
+ } else {
+ createErrorToast(result, "Failed to delete cluster");
+ }
+ } catch (err) {
+ createErrorToast(err, "Failed to delete cluster");
+ }
+ }, [clusterId, getToken]);
+
+ return (
+
+
+ Danger Zone
+
+ Actions here can lead to irreversible changes to your cluster
+
+
+
+
+
+
+
Delete Cluster
+
+ Permanently remove this cluster and all its associated data.
+ This action cannot be undone.
+
+
+
+ Delete Cluster
+
+
+
+
+ Are you absolutely sure?
+
+
+
+ This action cannot be undone. This will permanently
+ delete your cluster and remove all associated data
+ including runs, knowledge bases, and configurations.
+
+
+
+ Please type{" "}
+ delete cluster to
+ confirm.
+
+
+ setDeleteConfirmation(e.target.value)
+ }
+ placeholder="delete cluster"
+ className="max-w-[200px]"
+ />
+
+
+
+
+ Cancel
+
+ Delete Cluster
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/settings/details/page.tsx b/app/app/clusters/[clusterId]/settings/details/page.tsx
new file mode 100644
index 00000000..29ad8dc1
--- /dev/null
+++ b/app/app/clusters/[clusterId]/settings/details/page.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import { client } from "@/client/client";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Switch } from "@/components/ui/switch";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useCallback, useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import toast from "react-hot-toast";
+import { z } from "zod";
+import { Loading } from "@/components/loading";
+import { useRouter } from "next/navigation";
+
+const formSchema = z.object({
+ name: z.string(),
+ description: z.string().default(""),
+ debug: z.boolean().default(false),
+ enableRunConfigs: z.boolean().default(false),
+ enableKnowledgebase: z.boolean().default(false),
+});
+
+export default function DetailsPage({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ const { getToken } = useAuth();
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ });
+ const [isLoading, setIsLoading] = useState(false);
+ const router = useRouter();
+
+ const fetchClusterDetails = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const details = await client.getCluster({
+ headers: { authorization: `Bearer ${await getToken()}` },
+ params: { clusterId },
+ });
+
+ if (details.status === 200) {
+ form.setValue("name", details.body.name);
+ form.setValue("description", details.body.description ?? "");
+ form.setValue("debug", details.body.debug ?? false);
+ form.setValue(
+ "enableRunConfigs",
+ details.body.enableRunConfigs ?? false,
+ );
+ form.setValue(
+ "enableKnowledgebase",
+ details.body.enableKnowledgebase ?? false,
+ );
+ } else {
+ createErrorToast(details, "Failed to fetch cluster details");
+ }
+ } catch (err) {
+ createErrorToast(err, "Failed to fetch cluster details");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [clusterId, getToken, form]);
+
+ const updateClusterDetails = useCallback(
+ async (data: z.infer) => {
+ try {
+ const result = await client.updateCluster({
+ headers: { authorization: `Bearer ${await getToken()}` },
+ params: { clusterId },
+ body: {
+ name: data.name,
+ description: data.description,
+ debug: data.debug,
+ enableRunConfigs: data.enableRunConfigs,
+ enableKnowledgebase: data.enableKnowledgebase,
+ },
+ });
+
+ if (result.status === 204) {
+ toast.success("Cluster details updated successfully");
+ router.refresh();
+ } else {
+ createErrorToast(result, "Failed to update cluster details");
+ }
+ } catch (err) {
+ createErrorToast(err, "Failed to update cluster details");
+ }
+ },
+ [clusterId, getToken, router],
+ );
+
+ useEffect(() => {
+ fetchClusterDetails();
+ }, [fetchClusterDetails]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+
+
+ Cluster Details
+ Update the details of the cluster
+
+
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/settings/layout.tsx b/app/app/clusters/[clusterId]/settings/layout.tsx
new file mode 100644
index 00000000..6fcd914d
--- /dev/null
+++ b/app/app/clusters/[clusterId]/settings/layout.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+export default function SettingsLayout({
+ children,
+ params: { clusterId },
+}: {
+ children: React.ReactNode;
+ params: { clusterId: string };
+}) {
+ const pathname = usePathname() || "";
+ const currentTab = pathname.includes("/details")
+ ? "details"
+ : pathname.includes("/api-keys")
+ ? "api-keys"
+ : pathname.includes("/danger")
+ ? "danger"
+ : "details";
+
+ return (
+
+
+
+
+ Cluster Details
+
+
+ API Keys
+
+
+
+ Danger Zone
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/app/app/clusters/[clusterId]/settings/page.tsx b/app/app/clusters/[clusterId]/settings/page.tsx
new file mode 100644
index 00000000..d6eb2993
--- /dev/null
+++ b/app/app/clusters/[clusterId]/settings/page.tsx
@@ -0,0 +1,13 @@
+import { redirect } from "next/navigation";
+
+export const metadata = {
+ title: "ClusterSettings",
+};
+
+export default function SettingsPage({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ redirect(`/clusters/${clusterId}/settings/details`);
+}
diff --git a/app/app/clusters/[clusterId]/usage/page.tsx b/app/app/clusters/[clusterId]/usage/page.tsx
new file mode 100644
index 00000000..17e4ee52
--- /dev/null
+++ b/app/app/clusters/[clusterId]/usage/page.tsx
@@ -0,0 +1,510 @@
+import { client } from "@/client/client";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { auth } from "@clerk/nextjs";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Progress } from "@/components/ui/progress";
+
+type BillableRate = {
+ input: number;
+ output: number;
+};
+
+const billable: [string, BillableRate][] = [
+ [
+ "claude-3-5-sonnet",
+ {
+ input: 0.003,
+ output: 0.015,
+ },
+ ],
+ [
+ "claude-3-5-haiku",
+ {
+ input: 0.001,
+ output: 0.005,
+ },
+ ],
+ [
+ "unknown",
+ {
+ input: 0.0,
+ output: 0.0,
+ },
+ ],
+];
+
+export default async function UsagePage({
+ params: { clusterId },
+}: {
+ params: { clusterId: string };
+}) {
+ const { getToken } = auth();
+
+ const result = await client.listUsageActivity({
+ headers: { authorization: `Bearer ${await getToken()}` },
+ params: { clusterId },
+ });
+
+ if (result.status !== 200) {
+ throw new Error("Failed to fetch usage data");
+ }
+
+ const { modelUsage, agentRuns } = result.body;
+
+ // Calculate totals for model usage
+ const modelTotals = modelUsage.reduce(
+ (acc, curr) => ({
+ totalInputTokens: acc.totalInputTokens + curr.totalInputTokens,
+ totalOutputTokens: acc.totalOutputTokens + curr.totalOutputTokens,
+ totalModelInvocations:
+ acc.totalModelInvocations + curr.totalModelInvocations,
+ }),
+ {
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalModelInvocations: 0,
+ },
+ );
+
+ // Calculate totals for agent runs
+ const predictionsTotal = agentRuns.reduce(
+ (acc, curr) => acc + curr.totalAgentRuns,
+ 0,
+ );
+
+ // Calculate current billing cycle dates
+ const today = new Date();
+ const currentDay = today.getDate();
+ const currentMonth = today.getMonth();
+ const currentYear = today.getFullYear();
+
+ let cycleStart = new Date(currentYear, currentMonth, 15);
+ let cycleEnd = new Date(currentYear, currentMonth + 1, 14);
+
+ if (currentDay < 15) {
+ cycleStart = new Date(currentYear, currentMonth - 1, 15);
+ cycleEnd = new Date(currentYear, currentMonth, 14);
+ }
+
+ const freeTierRemaining = Math.max(0, 500 - predictionsTotal);
+
+ // Group model usage by model ID
+ const modelGroups = modelUsage.reduce(
+ (acc, curr) => {
+ const modelId = curr.modelId || "Unknown";
+ if (!acc[modelId]) {
+ acc[modelId] = [];
+ }
+ acc[modelId].push(curr);
+ return acc;
+ },
+ {} as Record,
+ );
+
+ // Calculate totals for each model
+ const modelGroupTotals = Object.entries(modelGroups).reduce(
+ (acc, [modelId, usage]) => {
+ acc[modelId] = usage.reduce(
+ (modelAcc, curr) => ({
+ totalInputTokens: modelAcc.totalInputTokens + curr.totalInputTokens,
+ totalOutputTokens:
+ modelAcc.totalOutputTokens + curr.totalOutputTokens,
+ totalModelInvocations:
+ modelAcc.totalModelInvocations + curr.totalModelInvocations,
+ }),
+ {
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalModelInvocations: 0,
+ },
+ );
+ return acc;
+ },
+ {} as Record,
+ );
+
+ // Calculate costs per model
+ const modelCostsBreakdown = Object.entries(modelGroupTotals).reduce(
+ (acc, [modelId, usage]) => {
+ // Special case for unknown models
+ if (modelId.toLowerCase() === "unknown") {
+ acc[modelId] = {
+ inputCost: 0,
+ outputCost: 0,
+ totalCost: 0,
+ rate: {
+ input: 0,
+ output: 0,
+ },
+ };
+ return acc;
+ }
+
+ const matchingRate = billable.find(([model]) => modelId.includes(model));
+ const rate: BillableRate = matchingRate?.[1] ?? {
+ input: 0.003, // default to sonnet rates if no match
+ output: 0.015,
+ };
+
+ // Round down to nearest 1000 tokens before calculating cost
+ const inputTokensRounded =
+ Math.floor(usage.totalInputTokens / 1000) * 1000;
+ const outputTokensRounded =
+ Math.floor(usage.totalOutputTokens / 1000) * 1000;
+
+ const inputTokenCost = (inputTokensRounded / 1000) * rate.input;
+ const outputTokenCost = (outputTokensRounded / 1000) * rate.output;
+
+ acc[modelId] = {
+ inputCost: inputTokenCost,
+ outputCost: outputTokenCost,
+ totalCost: inputTokenCost + outputTokenCost,
+ rate,
+ };
+ return acc;
+ },
+ {} as Record<
+ string,
+ {
+ inputCost: number;
+ outputCost: number;
+ totalCost: number;
+ rate: BillableRate;
+ }
+ >,
+ );
+
+ // Update calculateCosts to use the breakdown
+ const calculateCosts = () => {
+ const paidPredictions = Math.max(0, predictionsTotal - 500);
+ const platformCost = (paidPredictions / 100) * 0.5;
+
+ const modelCosts = Object.values(modelCostsBreakdown).reduce(
+ (acc, curr) => acc + curr.totalCost,
+ 0,
+ );
+
+ const totalModelCost = Math.max(0, modelCosts - 5);
+
+ return {
+ platformCost,
+ rawModelCost: modelCosts,
+ modelCostAfterFreeTier: totalModelCost,
+ totalCost: platformCost + totalModelCost,
+ modelFreeTierRemaining: Math.max(0, 5 - modelCosts),
+ };
+ };
+
+ const costs = calculateCosts();
+
+ return (
+
+
+
+ Billing Cycle Information
+
+ Current billing period: {cycleStart.toLocaleDateString()} -{" "}
+ {cycleEnd.toLocaleDateString()}
+
+
+
+
+
+
+ Free Tier Status
+
+ Monthly free tier allowance and usage
+
+
+
+
+
+ Predictions Free Tier
+
+ {predictionsTotal} / 500 used
+
+
+
+ {freeTierRemaining > 0 && (
+
+ {freeTierRemaining} predictions remaining
+
+ )}
+
+
+
+
+ Model Usage Free Tier
+
+ ${costs.rawModelCost.toFixed(2)} / $5.00 used
+
+
+
+ {costs.modelFreeTierRemaining > 0 && (
+
+ ${costs.modelFreeTierRemaining.toFixed(2)} remaining
+
+ )}
+
+
+
+ Your free tier includes 500 predictions and $5 of model costs each
+ billing cycle.
+
+
+
+
+
+
+ Pay As You Go Usage
+
+ Billable usage beyond free tier allowance
+
+
+
+
+
+ Platform Usage
+
+ {Math.max(0, predictionsTotal - 500).toLocaleString()} paid
+ predictions
+
+ (${costs.platformCost.toFixed(2)})
+
+
+
+
+
+
Model Usage
+
+
+ Billable amount: ${costs.modelCostAfterFreeTier.toFixed(2)}
+
+
+
+
+
+ Total Billable Amount
+ ${costs.totalCost.toFixed(2)}
+
+
+
+
+ Platform fee: $0.50 per 100 predictions after free tier. Models
+ token usage is billed at cost.
+
+
+
+
+
+
+ Model Usage Statistics
+
+ Daily token usage and model invocations
+
+
+
+
+
+ All Models
+ {Object.keys(modelGroups).map((modelId) => (
+
+ {modelId}
+
+ ))}
+
+
+
+
+
+
+ Date
+ Model
+ Input Tokens
+ Output Tokens
+ Model Calls
+
+
+
+ {modelUsage.map((day) => (
+
+ {day.date}
+ {day.modelId || "Unknown"}
+
+ {day.totalInputTokens?.toLocaleString()}
+
+
+ {day.totalOutputTokens?.toLocaleString()}
+
+
+ {day.totalModelInvocations?.toLocaleString()}
+
+
+ ))}
+
+
+
+ Total
+
+ {modelTotals.totalInputTokens.toLocaleString()}
+
+
+ {modelTotals.totalOutputTokens.toLocaleString()}
+
+
+ {modelTotals.totalModelInvocations.toLocaleString()}
+
+
+
+
+
+
+ {Object.entries(modelGroups).map(([modelId, usage]) => (
+
+
+
+
+ Input Token Cost
+
+ ${modelCostsBreakdown[modelId].inputCost.toFixed(2)}
+
+ (${modelCostsBreakdown[modelId].rate.input.toFixed(3)}{" "}
+ per 1K tokens)
+
+
+
+
+ Output Token Cost
+
+ ${modelCostsBreakdown[modelId].outputCost.toFixed(2)}
+
+ ($
+ {modelCostsBreakdown[modelId].rate.output.toFixed(
+ 3,
+ )}{" "}
+ per 1K tokens)
+
+
+
+
+ Total Model Cost
+
+ ${modelCostsBreakdown[modelId].totalCost.toFixed(2)}
+
+
+
+
+
+
+
+ Date
+
+ Input Tokens
+
+
+ Output Tokens
+
+
+ Model Calls
+
+
+
+
+ {usage.map((day) => (
+
+ {day.date}
+
+ {day.totalInputTokens?.toLocaleString()}
+
+
+ {day.totalOutputTokens?.toLocaleString()}
+
+
+ {day.totalModelInvocations?.toLocaleString()}
+
+
+ ))}
+
+
+
+ Total
+
+ {modelGroupTotals[
+ modelId
+ ].totalInputTokens.toLocaleString()}
+
+
+ {modelGroupTotals[
+ modelId
+ ].totalOutputTokens.toLocaleString()}
+
+
+ {modelGroupTotals[
+ modelId
+ ].totalModelInvocations.toLocaleString()}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ Prediction Statistics
+ Daily prediction counts
+
+
+
+
+
+ Date
+ Predictions
+
+
+
+ {agentRuns.map((day) => (
+
+ {day.date}
+
+ {day.totalAgentRuns?.toLocaleString()}
+
+
+ ))}
+
+
+
+ Total
+
+ {predictionsTotal.toLocaleString()}
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/app/clusters/layout.tsx b/app/app/clusters/layout.tsx
new file mode 100644
index 00000000..85560df1
--- /dev/null
+++ b/app/app/clusters/layout.tsx
@@ -0,0 +1,16 @@
+import { Header } from "@/components/header";
+import { OrgList } from "@/components/OrgList";
+
+export default async function Layout({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/app/clusters/page.tsx b/app/app/clusters/page.tsx
new file mode 100644
index 00000000..18fad434
--- /dev/null
+++ b/app/app/clusters/page.tsx
@@ -0,0 +1,70 @@
+import { client } from "@/client/client";
+import { GlobalBreadcrumbs } from "@/components/breadcrumbs";
+import { ClusterCard } from "@/components/cluster-card";
+import { CreateClusterButton } from "@/components/create-cluster-button";
+import { auth } from "@clerk/nextjs";
+import { Lightbulb } from "lucide-react";
+import Link from "next/link";
+
+export const metadata = {
+ title: "Clusters",
+};
+
+async function App() {
+ const response = await client.listClusters({
+ headers: {
+ authorization: `Bearer ${await auth().getToken()}`,
+ },
+ });
+
+ if (response.status !== 200) {
+ return null;
+ }
+
+ const availableClusters = response.body;
+
+ return (
+ <>
+
+
+
+
Clusters
+
+ Select a cluster to view details. You can create clusters from your{" "}
+
+ developer console
+
+ .
+
+
+
+
+ {availableClusters && availableClusters.length > 0 ? (
+
+ {availableClusters.map((cluster) => (
+
+ ))}
+
+
+
+
+ ) : (
+
+
+
+ Create your first cluster
+
+
+ Clusters help you group your functions, machines, and runs.
+
+
+
+
+
+ )}
+
+ >
+ );
+}
+
+export default App;
diff --git a/app/app/favicon.ico b/app/app/favicon.ico
new file mode 100644
index 00000000..cfed5928
Binary files /dev/null and b/app/app/favicon.ico differ
diff --git a/app/app/favicon.ico- b/app/app/favicon.ico-
new file mode 100644
index 00000000..799eddb9
Binary files /dev/null and b/app/app/favicon.ico- differ
diff --git a/app/app/globals.css b/app/app/globals.css
new file mode 100644
index 00000000..3a5cc0c1
--- /dev/null
+++ b/app/app/globals.css
@@ -0,0 +1,137 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 224 71.4% 4.1%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 224 71.4% 4.1%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 224 71.4% 4.1%;
+
+ --primary: 220.9 39.3% 11%;
+ --primary-foreground: 210 20% 98%;
+
+ --secondary: 220 14.3% 95.9%;
+ --secondary-foreground: 220.9 39.3% 11%;
+
+ --muted: 220 14.3% 95.9%;
+ --muted-foreground: 220 8.9% 46.1%;
+
+ --accent: 220 14.3% 95.9%;
+ --accent-foreground: 220.9 39.3% 11%;
+
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 20% 98%;
+
+ --border: 220 13% 91%;
+ --input: 220 13% 91%;
+ --ring: 224 71.4% 4.1%;
+
+ --radius: 0.5rem;
+
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ }
+
+ .dark {
+ --background: 224 71.4% 4.1%;
+ --foreground: 210 20% 98%;
+
+ --card: 224 71.4% 4.1%;
+ --card-foreground: 210 20% 98%;
+
+ --popover: 224 71.4% 4.1%;
+ --popover-foreground: 210 20% 98%;
+
+ --primary: 210 20% 98%;
+ --primary-foreground: 220.9 39.3% 11%;
+
+ --secondary: 215 27.9% 16.9%;
+ --secondary-foreground: 210 20% 98%;
+
+ --muted: 215 27.9% 16.9%;
+ --muted-foreground: 217.9 10.6% 64.9%;
+
+ --accent: 215 27.9% 16.9%;
+ --accent-foreground: 210 20% 98%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 20% 98%;
+
+ --border: 215 27.9% 16.9%;
+ --input: 215 27.9% 16.9%;
+ --ring: 216 12.2% 83.9%;
+
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ }
+}
+
+pre {
+ white-space: pre-wrap; /* Since CSS 2.1 */
+ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
+ white-space: -pre-wrap; /* Opera 4-6 */
+ white-space: -o-pre-wrap; /* Opera 7 */
+ word-wrap: break-word; /* Internet Explorer 5.5+ */
+}
+
+.syntax-highlight .string {
+ color: green;
+}
+.syntax-highlight .number {
+ color: darkorange;
+}
+.syntax-highlight .boolean {
+ color: blue;
+}
+.syntax-highlight .null {
+ color: magenta;
+}
+.syntax-highlight .key {
+ color: red;
+}
+
+input:focus,
+textarea:focus,
+button:focus {
+ outline: none !important;
+ box-shadow: inset 0 0 0 2px var(--ring) !important;
+}
+
+input:focus-visible {
+ outline: none;
+ border: 1px solid hsl(var(--ring));
+}
+
+textarea:focus-visible {
+ outline: none;
+ border: 1px solid hsl(var(--ring));
+}
+
+input[cmdk-input]:focus-visible {
+ outline: none;
+ border: 0 !important;
+}
diff --git a/app/app/layout.tsx b/app/app/layout.tsx
new file mode 100644
index 00000000..ac2b31a3
--- /dev/null
+++ b/app/app/layout.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { PostHogUser } from "@/components/posthog-user";
+import { RollbarUser } from "@/components/rollbar-user";
+import { cn } from "@/lib/utils";
+import { ClerkProvider } from "@clerk/nextjs";
+import { Provider as RollbarProvider } from "@rollbar/react";
+import dynamic from "next/dynamic";
+import { Inter as FontSans } from "next/font/google";
+import posthog from "posthog-js";
+import { useEffect } from "react";
+import { Toaster } from "react-hot-toast";
+import "./globals.css";
+import { PHProvider } from "./providers";
+
+const PostHogPageView = dynamic(() => import("@/components/posthog-pageview"), {
+ ssr: false,
+});
+
+const CrispWithNoSSR = dynamic(() => import("@/components/crisp-chat"), {
+ ssr: false,
+});
+
+const fontSans = FontSans({
+ subsets: ["latin"],
+ variable: "--font-sans",
+});
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ useEffect(() => {
+ if (window.location.host) {
+ process.env.NEXT_PUBLIC_POSTHOG_KEY &&
+ posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
+ api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
+ });
+ }
+ }, []);
+
+ const rollbarConfig = process.env.NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN
+ ? {
+ accessToken: process.env.NEXT_PUBLIC_ROLLBAR_CLIENT_TOKEN,
+ captureUncaught: true,
+ captureUnhandledRejections: true,
+ payload: {
+ environment: process.env.NEXT_PUBLIC_ENVIRONMENT ?? "development",
+ },
+ }
+ : {
+ enabled: false,
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/app/page.tsx b/app/app/page.tsx
new file mode 100644
index 00000000..cd2609c0
--- /dev/null
+++ b/app/app/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function Home() {
+ redirect("/clusters");
+}
diff --git a/app/app/providers.tsx b/app/app/providers.tsx
new file mode 100644
index 00000000..9c80cc36
--- /dev/null
+++ b/app/app/providers.tsx
@@ -0,0 +1,16 @@
+"use client";
+import posthog from "posthog-js";
+import { PostHogProvider } from "posthog-js/react";
+import { useEffect } from "react";
+
+export function PHProvider({ children }: { children: React.ReactNode }) {
+ useEffect(() => {
+ if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
+ posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
+ api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
+ capture_pageview: false, // Disable automatic pageview capture, as we capture manually
+ });
+ }, []);
+
+ return {children} ;
+}
diff --git a/app/app/switch-org/page.tsx b/app/app/switch-org/page.tsx
new file mode 100644
index 00000000..28ba31ee
--- /dev/null
+++ b/app/app/switch-org/page.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import { Loading } from "@/components/loading";
+import { useEffect } from "react";
+
+export default function SwitchOrgPage() {
+ useEffect(() => {
+ window.location.reload();
+ window.location.href = "/clusters";
+ }, []);
+
+ return ;
+}
diff --git a/app/client/client.ts b/app/client/client.ts
new file mode 100644
index 00000000..fa755c39
--- /dev/null
+++ b/app/client/client.ts
@@ -0,0 +1,17 @@
+import { initClient } from "@ts-rest/core";
+import { contract } from "./contract";
+
+const isServer = typeof window === "undefined";
+
+const getBaseUrl = () => {
+ if (isServer) {
+ return `${process.env.NEXT_PUBLIC_INFERABLE_API_URL || "https://api.inferable.ai"}`;
+ }
+
+ return `/api`;
+};
+
+export const client = initClient(contract, {
+ baseUrl: getBaseUrl(),
+ baseHeaders: {},
+});
diff --git a/app/client/contract.ts b/app/client/contract.ts
new file mode 100644
index 00000000..9be2f63b
--- /dev/null
+++ b/app/client/contract.ts
@@ -0,0 +1,1525 @@
+import { initContract } from "@ts-rest/core";
+import { z } from "zod";
+
+const c = initContract();
+
+const machineHeaders = {
+ "x-machine-id": z.string().optional(),
+ "x-machine-sdk-version": z.string().optional(),
+ "x-machine-sdk-language": z.string().optional(),
+ "x-forwarded-for": z.string().optional().optional(),
+ "x-sentinel-no-mask": z.string().optional().optional(),
+ "x-sentinel-unmask-keys": z.string().optional(),
+};
+
+// Alphanumeric, underscore, hyphen, no whitespace. From 6 to 128 characters.
+const userDefinedIdRegex = /^[a-zA-Z0-9-_]{6,128}$/;
+
+const functionReference = z.object({
+ service: z.string(),
+ function: z.string(),
+});
+
+const anyObject = z.object({}).passthrough();
+
+export const interruptSchema = z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal("approval"),
+ }),
+]);
+
+export const blobSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ type: z.enum(["application/json", "image/png", "image/jpeg"]),
+ encoding: z.enum(["base64"]),
+ size: z.number(),
+ createdAt: z.date(),
+ jobId: z.string().nullable(),
+ workflowId: z.string().nullable(),
+});
+
+export const VersionedTextsSchema = z.object({
+ current: z.object({
+ version: z.string(),
+ content: z.string(),
+ }),
+ history: z.array(
+ z.object({
+ version: z.string(),
+ content: z.string(),
+ }),
+ ),
+});
+
+export const integrationSchema = z.object({
+ toolhouse: z
+ .object({
+ apiKey: z.string(),
+ })
+ .optional()
+ .nullable(),
+ langfuse: z
+ .object({
+ publicKey: z.string(),
+ secretKey: z.string(),
+ baseUrl: z.string(),
+ sendMessagePayloads: z.boolean(),
+ })
+ .optional()
+ .nullable(),
+});
+
+export const genericMessageDataSchema = z
+ .object({
+ message: z.string(),
+ details: z.object({}).passthrough().optional(),
+ })
+ .strict();
+
+export const resultDataSchema = z
+ .object({
+ id: z.string(),
+ result: z.object({}).passthrough(),
+ })
+ .strict();
+
+export const learningSchema = z.object({
+ summary: z
+ .string()
+ .describe(
+ "The new information that was learned. Be generic, do not refer to the entities.",
+ ),
+ entities: z
+ .array(
+ z.object({
+ name: z
+ .string()
+ .describe("The name of the entity this learning relates to."),
+ type: z.enum(["tool"]),
+ }),
+ )
+ .describe("The entities this learning relates to."),
+ relevance: z.object({
+ temporality: z
+ .enum(["transient", "persistent"])
+ .describe("How long do you expect this learning to be relevant for."),
+ }),
+});
+
+export const agentDataSchema = z
+ .object({
+ done: z.boolean().optional(),
+ result: anyObject.optional(),
+ message: z.string().optional(),
+ learnings: z.array(learningSchema).optional(),
+ issue: z.string().optional(),
+ invocations: z
+ .array(
+ z.object({
+ id: z.string().optional(),
+ toolName: z.string(),
+ reasoning: z.string().optional(),
+ input: z.object({}).passthrough(),
+ }),
+ )
+ .optional(),
+ })
+ .strict();
+
+export const messageDataSchema = z.union([
+ resultDataSchema,
+ agentDataSchema,
+ genericMessageDataSchema,
+]);
+
+export const FunctionConfigSchema = z.object({
+ cache: z
+ .object({
+ keyPath: z.string(),
+ ttlSeconds: z.number(),
+ })
+ .optional(),
+ retryCountOnStall: z.number().optional(),
+ timeoutSeconds: z.number().optional(),
+ private: z.boolean().default(false).optional(),
+});
+
+export const definition = {
+ createMachine: {
+ method: "POST",
+ path: "/machines",
+ headers: z.object({
+ authorization: z.string(),
+ ...machineHeaders,
+ }),
+ body: z.object({
+ service: z.string().optional(),
+ functions: z
+ .array(
+ z.object({
+ name: z.string(),
+ description: z.string().optional(),
+ schema: z.string().optional(),
+ config: FunctionConfigSchema.optional(),
+ }),
+ )
+ .optional(),
+ }),
+ responses: {
+ 200: z.object({
+ clusterId: z.string(),
+ }),
+ 204: z.undefined(),
+ },
+ },
+ live: {
+ method: "GET",
+ path: "/live",
+ responses: {
+ 200: z.object({
+ status: z.string(),
+ }),
+ },
+ },
+ createCallApproval: {
+ method: "POST",
+ path: "/clusters/:clusterId/calls/:callId/approval",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ callId: z.string(),
+ }),
+ responses: {
+ 204: z.undefined(),
+ 404: z.object({
+ message: z.string(),
+ }),
+ },
+ body: z.object({
+ approved: z.boolean(),
+ }),
+ },
+ createCallBlob: {
+ method: "POST",
+ path: "/clusters/:clusterId/calls/:callId/blobs",
+ headers: z.object({
+ authorization: z.string(),
+ "x-machine-id": z.string(),
+ "x-machine-sdk-version": z.string(),
+ "x-machine-sdk-language": z.string(),
+ "x-forwarded-for": z.string().optional(),
+ "x-sentinel-no-mask": z.string().optional(),
+ }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ callId: z.string(),
+ }),
+ responses: {
+ 201: z.object({
+ id: z.string(),
+ }),
+ 401: z.undefined(),
+ 404: z.object({
+ message: z.string(),
+ }),
+ },
+ body: blobSchema
+ .omit({ id: true, createdAt: true, jobId: true, workflowId: true })
+ .and(
+ z.object({
+ data: z.string(),
+ }),
+ ),
+ },
+ getContract: {
+ method: "GET",
+ path: "/contract",
+ responses: {
+ 200: z.object({
+ contract: z.string(),
+ }),
+ },
+ },
+ listClusters: {
+ method: "GET",
+ path: "/clusters",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ createdAt: z.date(),
+ description: z.string().nullable(),
+ }),
+ ),
+ 401: z.undefined(),
+ },
+ },
+ createCluster: {
+ method: "POST",
+ path: "/clusters",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 204: z.undefined(),
+ },
+ body: z.object({
+ description: z
+ .string()
+ .describe("Human readable description of the cluster"),
+ }),
+ },
+ upsertIntegrations: {
+ method: "PUT",
+ path: "/clusters/:clusterId/integrations",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.undefined(),
+ 401: z.undefined(),
+ 400: z.object({
+ message: z.string(),
+ }),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ body: integrationSchema,
+ },
+ getIntegrations: {
+ method: "GET",
+ path: "/clusters/:clusterId/integrations",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: integrationSchema,
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ deleteCluster: {
+ method: "DELETE",
+ path: "/clusters/:clusterId",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ body: z.undefined(),
+ responses: {
+ 204: z.undefined(),
+ },
+ },
+ updateCluster: {
+ method: "PUT",
+ path: "/clusters/:clusterId",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ },
+ body: z.object({
+ name: z.string().optional(),
+ description: z.string().optional(),
+ additionalContext: VersionedTextsSchema.optional().describe(
+ "Additional cluster context which is included in all runs",
+ ),
+ debug: z
+ .boolean()
+ .optional()
+ .describe(
+ "Enable additional logging (Including prompts and results) for use by Inferable support",
+ ),
+ enableRunConfigs: z.boolean().optional(),
+ enableKnowledgebase: z.boolean().optional(),
+ }),
+ },
+ getCluster: {
+ method: "GET",
+ path: "/clusters/:clusterId",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string().nullable(),
+ additionalContext: VersionedTextsSchema.nullable(),
+ createdAt: z.date(),
+ debug: z.boolean(),
+ enableRunConfigs: z.boolean(),
+ enableKnowledgebase: z.boolean(),
+ lastPingAt: z.date().nullable(),
+ }),
+ 401: z.undefined(),
+ 404: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ listEvents: {
+ method: "GET",
+ path: "/clusters/:clusterId/events",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ type: z.string(),
+ machineId: z.string().nullable(),
+ service: z.string().nullable(),
+ createdAt: z.date(),
+ jobId: z.string().nullable(),
+ targetFn: z.string().nullable(),
+ resultType: z.string().nullable(),
+ status: z.string().nullable(),
+ workflowId: z.string().nullable(),
+ meta: z.any().nullable(),
+ id: z.string(),
+ }),
+ ),
+ 401: z.undefined(),
+ 404: z.undefined(),
+ },
+ query: z.object({
+ type: z.string().optional(),
+ jobId: z.string().optional(),
+ machineId: z.string().optional(),
+ service: z.string().optional(),
+ workflowId: z.string().optional(),
+ includeMeta: z.string().optional(),
+ }),
+ },
+ listUsageActivity: {
+ method: "GET",
+ path: "/clusters/:clusterId/usage",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.object({
+ modelUsage: z.array(
+ z.object({
+ date: z.string(),
+ modelId: z.string().nullable(),
+ totalInputTokens: z.number(),
+ totalOutputTokens: z.number(),
+ totalModelInvocations: z.number(),
+ }),
+ ),
+ agentRuns: z.array(
+ z.object({
+ date: z.string(),
+ totalAgentRuns: z.number(),
+ }),
+ ),
+ }),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ getEventMeta: {
+ method: "GET",
+ path: "/clusters/:clusterId/events/:eventId/meta",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.object({
+ type: z.string(),
+ machineId: z.string().nullable(),
+ service: z.string().nullable(),
+ createdAt: z.date(),
+ jobId: z.string().nullable(),
+ targetFn: z.string().nullable(),
+ resultType: z.string().nullable(),
+ status: z.string().nullable(),
+ meta: z.unknown(),
+ id: z.string(),
+ }),
+ 401: z.undefined(),
+ 404: z.undefined(),
+ },
+ },
+ createRun: {
+ method: "POST",
+ path: "/clusters/:clusterId/runs",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ body: z.object({
+ initialPrompt: z
+ .string()
+ .optional()
+ .describe("An initial 'human' message to trigger the run"),
+ systemPrompt: z
+ .string()
+ .optional()
+ .describe("A system prompt for the run."),
+ name: z
+ .string()
+ .optional()
+ .describe("The name of the run, if not provided it will be generated"),
+ model: z
+ .enum(["claude-3-5-sonnet", "claude-3-haiku"])
+ .optional()
+ .describe("The model identifier for the run"),
+ resultSchema: anyObject
+ .optional()
+ .describe(
+ "A JSON schema definition which the result object should conform to. By default the result will be a JSON object which does not conform to any schema",
+ ),
+ attachedFunctions: z
+ .array(functionReference)
+ .optional()
+ .describe(
+ "An array of functions to make available to the run. By default all functions in the cluster will be available",
+ ),
+ onStatusChange: z
+ .object({
+ function: functionReference.describe(
+ "A function to call when the run status changes",
+ ),
+ })
+ .optional()
+ .describe(
+ "Mechanism for receiving notifications when the run status changes",
+ ),
+ metadata: z
+ .record(z.string())
+ .optional()
+ .describe("Run metadata which can be used to filter runs"),
+ test: z
+ .object({
+ enabled: z.boolean().default(false),
+ mocks: z
+ .record(
+ z.object({
+ output: z
+ .object({})
+ .passthrough()
+ .describe("The mock output of the function"),
+ }),
+ )
+ .optional()
+ .describe(
+ "Function mocks to be used in the run. (Keys should be function in the format _)",
+ ),
+ })
+ .optional()
+ .describe(
+ "When provided, the run will be marked as as a test / evaluation",
+ ),
+ config: z
+ .object({
+ id: z.string().describe("The run configuration ID"),
+ input: z
+ .object({})
+ .passthrough()
+ .describe(
+ "The run configuration input arguments, the schema must match the run configuration input schema",
+ )
+ .optional(),
+ })
+ .optional(),
+ context: anyObject
+ .optional()
+ .describe("Additional context to propogate to all calls in the run"),
+ template: z
+ .object({
+ id: z.string().describe("DEPRECATED"),
+ input: z.object({}).passthrough().optional().describe("DEPRECATED"),
+ })
+ .optional()
+ .describe("DEPRECATED"),
+ reasoningTraces: z
+ .boolean()
+ .default(true)
+ .optional()
+ .describe("Enable reasoning traces"),
+ callSummarization: z
+ .boolean()
+ .default(true)
+ .optional()
+ .describe("Enable summarization of oversized call results"),
+ interactive: z
+ .boolean()
+ .default(true)
+ .describe(
+ "Allow the run to be continued with follow-up messages / message edits",
+ ),
+ }),
+ responses: {
+ 201: z.object({
+ id: z.string().describe("The id of the newly created run"),
+ }),
+ 401: z.undefined(),
+ 400: z.object({
+ message: z.string(),
+ }),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ deleteRun: {
+ method: "DELETE",
+ path: "/clusters/:clusterId/runs/:runId",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ body: z.undefined(),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ runId: z.string(),
+ clusterId: z.string(),
+ }),
+ },
+ createMessage: {
+ method: "POST",
+ path: "/clusters/:clusterId/runs/:runId/messages",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ body: z.object({
+ id: z
+ .string()
+ .length(26)
+ .regex(/^[0-9a-z]+$/i)
+ .optional(),
+ message: z.string(),
+ type: z.enum(["human", "supervisor"]).optional(),
+ }),
+ responses: {
+ 201: z.undefined(),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ runId: z.string(),
+ clusterId: z.string(),
+ }),
+ },
+ listMessages: {
+ method: "GET",
+ path: "/clusters/:clusterId/runs/:runId/messages",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ data: messageDataSchema,
+ type: z.enum([
+ "human",
+ "template",
+ "invocation-result",
+ "agent",
+ "agent-invalid",
+ "supervisor",
+ ]),
+ createdAt: z.date(),
+ pending: z.boolean().default(false),
+ displayableContext: z.record(z.string()).nullable(),
+ }),
+ ),
+ 401: z.undefined(),
+ },
+ },
+ listRuns: {
+ method: "GET",
+ path: "/clusters/:clusterId/runs",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ query: z.object({
+ userId: z.string().optional(),
+ test: z.coerce
+ .string()
+ .transform((value) => value === "true")
+ .optional(),
+ limit: z.coerce.number().min(10).max(50).default(50),
+ metadata: z
+ .string()
+ .optional()
+ .describe("Filter runs by a metadata value (value:key)"),
+ configId: z.string().optional(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ userId: z.string().nullable(),
+ createdAt: z.date(),
+ status: z
+ .enum(["pending", "running", "paused", "done", "failed"])
+ .nullable(),
+ test: z.boolean(),
+ configId: z.string().nullable(),
+ configVersion: z.number().nullable(),
+ feedbackScore: z.number().nullable(),
+ }),
+ ),
+ 401: z.undefined(),
+ },
+ },
+ getRun: {
+ method: "GET",
+ path: "/clusters/:clusterId/runs/:runId",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.object({
+ id: z.string(),
+ userId: z.string().nullable(),
+ status: z
+ .enum(["pending", "running", "paused", "done", "failed"])
+ .nullable(),
+ failureReason: z.string().nullable(),
+ test: z.boolean(),
+ feedbackComment: z.string().nullable(),
+ feedbackScore: z.number().nullable(),
+ result: anyObject.nullable(),
+ metadata: z.record(z.string()).nullable(),
+ attachedFunctions: z.array(z.string()).nullable(),
+ }),
+ 401: z.undefined(),
+ },
+ },
+ createFeedback: {
+ method: "POST",
+ path: "/clusters/:clusterId/runs/:runId/feedback",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ body: z.object({
+ comment: z.string().describe("Feedback comment").nullable(),
+ score: z
+ .number()
+ .describe("Score between 0 and 1")
+ .min(0)
+ .max(1)
+ .nullable(),
+ }),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ 404: z.undefined(),
+ },
+ pathParams: z.object({
+ runId: z.string(),
+ clusterId: z.string(),
+ }),
+ },
+ oas: {
+ method: "GET",
+ path: "/public/oas.json",
+ responses: {
+ 200: z.unknown(),
+ },
+ },
+ updateMessage: {
+ method: "PUT",
+ path: "/clusters/:clusterId/runs/:runId/messages/:messageId",
+ headers: z.object({ authorization: z.string() }),
+ body: z.object({ message: z.string() }),
+ responses: {
+ 200: z.object({
+ data: genericMessageDataSchema,
+ id: z.string(),
+ }),
+ 404: z.object({ message: z.string() }),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ runId: z.string(),
+ messageId: z.string(),
+ }),
+ },
+ storeServiceMetadata: {
+ method: "PUT",
+ path: "/clusters/:clusterId/services/:service/keys/:key",
+ headers: z.object({ authorization: z.string() }),
+ body: z.object({
+ value: z.string(),
+ }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ service: z.string(),
+ key: z.string(),
+ }),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ },
+ },
+ getClusterExport: {
+ method: "GET",
+ path: "/clusters/:clusterId/export",
+ headers: z.object({ authorization: z.string() }),
+ pathParams: z.object({ clusterId: z.string() }),
+ responses: {
+ 200: z.object({
+ data: z.string(),
+ }),
+ },
+ },
+ consumeClusterExport: {
+ method: "POST",
+ path: "/clusters/:clusterId/import",
+ headers: z.object({ authorization: z.string() }),
+ body: z.object({ data: z.string() }),
+ pathParams: z.object({ clusterId: z.string() }),
+ responses: {
+ 200: z.object({ message: z.string() }),
+ 400: z.undefined(),
+ },
+ },
+ getCall: {
+ method: "GET",
+ path: "/clusters/:clusterId/calls/:callId",
+ headers: z.object({ authorization: z.string() }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ callId: z.string(),
+ }),
+ responses: {
+ 200: z.object({
+ id: z.string(),
+ status: z.string(),
+ targetFn: z.string(),
+ service: z.string(),
+ executingMachineId: z.string().nullable(),
+ targetArgs: z.string(),
+ result: z.string().nullable(),
+ resultType: z.string().nullable(),
+ createdAt: z.date(),
+ blobs: z.array(blobSchema),
+ }),
+ },
+ },
+ listRunReferences: {
+ method: "GET",
+ path: "/clusters/:clusterId/runs/:runId/references",
+ headers: z.object({ authorization: z.string() }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ runId: z.string(),
+ }),
+ query: z.object({
+ token: z.string(),
+ before: z.string(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ result: z.string().nullable(),
+ createdAt: z.date(),
+ status: z.string(),
+ targetFn: z.string(),
+ service: z.string(),
+ executingMachineId: z.string().nullable(),
+ }),
+ ),
+ },
+ },
+ createApiKey: {
+ method: "POST",
+ path: "/clusters/:clusterId/api-keys",
+ headers: z.object({ authorization: z.string() }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ body: z.object({
+ name: z.string(),
+ }),
+ responses: {
+ 200: z.object({
+ id: z.string(),
+ key: z.string(),
+ }),
+ },
+ },
+ listApiKeys: {
+ method: "GET",
+ path: "/clusters/:clusterId/api-keys",
+ headers: z.object({ authorization: z.string() }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ createdAt: z.date(),
+ createdBy: z.string(),
+ revokedAt: z.date().nullable(),
+ }),
+ ),
+ },
+ },
+ revokeApiKey: {
+ method: "DELETE",
+ path: "/clusters/:clusterId/api-keys/:keyId",
+ headers: z.object({ authorization: z.string() }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ keyId: z.string(),
+ }),
+ body: z.undefined(),
+ responses: {
+ 204: z.undefined(),
+ },
+ },
+
+ listMachines: {
+ method: "GET",
+ path: "/clusters/:clusterId/machines",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ lastPingAt: z.date(),
+ ip: z.string(),
+ }),
+ ),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+
+ listServices: {
+ method: "GET",
+ path: "/clusters/:clusterId/services",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ name: z.string(),
+ description: z.string().optional(),
+ functions: z
+ .array(
+ z.object({
+ name: z.string(),
+ description: z.string().optional(),
+ schema: z.string().optional(),
+ config: FunctionConfigSchema.optional(),
+ }),
+ )
+ .optional(),
+ }),
+ ),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ getRunTimeline: {
+ method: "GET",
+ path: "/clusters/:clusterId/runs/:runId/timeline",
+ headers: z.object({ authorization: z.string() }),
+ query: z.object({
+ messagesAfter: z.string().default("0"),
+ activityAfter: z.string().default("0"),
+ jobsAfter: z.string().default("0"),
+ }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ runId: z.string(),
+ }),
+ responses: {
+ 404: z.undefined(),
+ 200: z.object({
+ messages: z.array(
+ z.object({
+ id: z.string(),
+ data: messageDataSchema,
+ type: z.enum([
+ // TODO: Remove 'template' type
+ "template",
+ "invocation-result",
+ "human",
+ "agent",
+ "agent-invalid",
+ "supervisor",
+ ]),
+ createdAt: z.date(),
+ pending: z.boolean().default(false),
+ displayableContext: z.record(z.string()).nullable(),
+ }),
+ ),
+ activity: z.array(
+ z.object({
+ id: z.string(),
+ type: z.string(),
+ machineId: z.string().nullable(),
+ service: z.string().nullable(),
+ createdAt: z.date(),
+ jobId: z.string().nullable(),
+ targetFn: z.string().nullable(),
+ }),
+ ),
+ jobs: z.array(
+ z.object({
+ id: z.string(),
+ status: z.string(),
+ targetFn: z.string(),
+ service: z.string(),
+ resultType: z.string().nullable(),
+ createdAt: z.date(),
+ approved: z.boolean().nullable(),
+ approvalRequested: z.boolean().nullable(),
+ }),
+ ),
+ run: z.object({
+ id: z.string(),
+ userId: z.string().nullable(),
+ status: z
+ .enum(["pending", "running", "paused", "done", "failed"])
+ .nullable(),
+ failureReason: z.string().nullable(),
+ test: z.boolean(),
+ feedbackComment: z.string().nullable(),
+ feedbackScore: z.number().nullable(),
+ attachedFunctions: z.array(z.string()).nullable(),
+ name: z.string().nullable(),
+ }),
+ blobs: z.array(blobSchema),
+ }),
+ },
+ },
+ getBlobData: {
+ method: "GET",
+ path: "/clusters/:clusterId/blobs/:blobId/data",
+ headers: z.object({ authorization: z.string() }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ blobId: z.string(),
+ }),
+ responses: {
+ 200: z.any(),
+ 404: z.undefined(),
+ },
+ },
+ upsertFunctionMetadata: {
+ method: "PUT",
+ path: "/clusters/:clusterId/:service/:function/metadata",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ service: z.string(),
+ function: z.string(),
+ }),
+ body: z.object({
+ additionalContext: z.string().optional(),
+ }),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ },
+ },
+ getFunctionMetadata: {
+ method: "GET",
+ path: "/clusters/:clusterId/:service/:function/metadata",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ service: z.string(),
+ function: z.string(),
+ }),
+ responses: {
+ 200: z.object({
+ additionalContext: z.string().nullable(),
+ }),
+ 401: z.undefined(),
+ 404: z.object({ message: z.string() }),
+ },
+ },
+ deleteFunctionMetadata: {
+ method: "DELETE",
+ path: "/clusters/:clusterId/:service/:function/metadata",
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ service: z.string(),
+ function_name: z.string(),
+ }),
+ body: z.undefined(),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ },
+ },
+ getRunConfig: {
+ method: "GET",
+ path: "/clusters/:clusterId/run-configs/:configId",
+ headers: z.object({ authorization: z.string() }),
+ responses: {
+ 200: z.object({
+ id: z.string(),
+ clusterId: z.string(),
+ name: z.string(),
+ initialPrompt: z.string().nullable(),
+ systemPrompt: z.string().nullable(),
+ attachedFunctions: z.array(z.string()),
+ resultSchema: anyObject.nullable(),
+ inputSchema: anyObject.nullable(),
+ public: z.boolean(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ versions: z.array(
+ z.object({
+ version: z.number(),
+ name: z.string(),
+ initialPrompt: z.string().nullable(),
+ systemPrompt: z.string().nullable(),
+ attachedFunctions: z.array(z.string()),
+ resultSchema: anyObject.nullable(),
+ inputSchema: anyObject.nullable(),
+ public: z.boolean(),
+ }),
+ ),
+ }),
+ 401: z.undefined(),
+ 404: z.object({ message: z.string() }),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ configId: z.string(),
+ }),
+ query: z.object({
+ withPreviousVersions: z.enum(["true", "false"]).default("false"),
+ }),
+ },
+ createRunConfig: {
+ method: "POST",
+ path: "/clusters/:clusterId/run-configs",
+ headers: z.object({ authorization: z.string() }),
+ body: z.object({
+ name: z.string(),
+ initialPrompt: z.string().optional(),
+ systemPrompt: z
+ .string()
+ .optional()
+ .describe("The initial system prompt for the run."),
+ attachedFunctions: z.array(z.string()).optional(),
+ resultSchema: anyObject.optional(),
+ inputSchema: z.object({}).passthrough().optional().nullable(),
+ public: z.boolean(),
+ }),
+ responses: {
+ 201: z.object({ id: z.string() }),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ upsertRunConfig: {
+ method: "PUT",
+ path: "/clusters/:clusterId/run-configs/:configId",
+ headers: z.object({ authorization: z.string() }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ configId: z.string().regex(userDefinedIdRegex),
+ }),
+ body: z.object({
+ name: z.string().optional(),
+ initialPrompt: z.string().optional(),
+ systemPrompt: z
+ .string()
+ .optional()
+ .describe("The initial system prompt for the run."),
+ attachedFunctions: z.array(z.string()).optional(),
+ resultSchema: z.object({}).passthrough().optional().nullable(),
+ inputSchema: z.object({}).passthrough().optional().nullable(),
+ public: z.boolean(),
+ }),
+ responses: {
+ 200: z.object({
+ id: z.string(),
+ clusterId: z.string(),
+ name: z.string(),
+ initialPrompt: z.string().nullable(),
+ systemPrompt: z.string().nullable(),
+ attachedFunctions: z.array(z.string()),
+ resultSchema: z.unknown().nullable(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ }),
+ 401: z.undefined(),
+ 404: z.object({ message: z.string() }),
+ },
+ },
+ deleteRunConfig: {
+ method: "DELETE",
+ path: "/clusters/:clusterId/run-configs/:configId",
+ headers: z.object({ authorization: z.string() }),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ 404: z.object({ message: z.string() }),
+ },
+ body: z.undefined(),
+ pathParams: z.object({
+ clusterId: z.string(),
+ configId: z.string(),
+ }),
+ },
+ listRunConfigs: {
+ method: "GET",
+ path: "/clusters/:clusterId/run-configs",
+ headers: z.object({ authorization: z.string() }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ clusterId: z.string(),
+ name: z.string(),
+ initialPrompt: z.string().nullable(),
+ systemPrompt: z.string().nullable(),
+ attachedFunctions: z.array(z.string()),
+ resultSchema: z.unknown().nullable(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ }),
+ ),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ searchRunConfigs: {
+ method: "GET",
+ path: "/clusters/:clusterId/run-configs/search",
+ headers: z.object({ authorization: z.string() }),
+ query: z.object({
+ search: z.string(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ clusterId: z.string(),
+ name: z.string(),
+ initialPrompt: z.string(),
+ attachedFunctions: z.array(z.string()),
+ resultSchema: z.unknown().nullable(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ similarity: z.number(),
+ }),
+ ),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ getRunConfigMetrics: {
+ method: "GET",
+ path: "/clusters/:clusterId/run-configs/:configId/metrics",
+ headers: z.object({ authorization: z.string() }),
+ responses: {
+ 200: z.array(
+ z.object({
+ createdAt: z.date(),
+ feedbackScore: z.number().nullable(),
+ jobFailureCount: z.number(),
+ timeToCompletion: z.number(),
+ jobCount: z.number(),
+ }),
+ ),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ configId: z.string(),
+ }),
+ },
+ createClusterKnowledgeArtifact: {
+ method: "POST",
+ path: "/clusters/:clusterId/knowledge",
+ headers: z.object({ authorization: z.string() }),
+ body: z.object({
+ artifacts: z.array(
+ z.object({
+ id: z.string(),
+ data: z.string(),
+ tags: z
+ .array(z.string())
+ .transform((tags) => tags.map((tag) => tag.toLowerCase().trim())),
+ title: z.string(),
+ }),
+ ),
+ }),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ getKnowledge: {
+ method: "GET",
+ path: "/clusters/:clusterId/knowledge",
+ headers: z.object({ authorization: z.string() }),
+ query: z.object({
+ query: z.string(),
+ limit: z.coerce.number().min(1).max(50).default(5),
+ tag: z.string().optional(),
+ }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ data: z.string(),
+ tags: z.array(z.string()),
+ title: z.string(),
+ similarity: z.number(),
+ }),
+ ),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ upsertKnowledgeArtifact: {
+ method: "PUT",
+ path: "/clusters/:clusterId/knowledge/:artifactId",
+ headers: z.object({ authorization: z.string() }),
+ body: z.object({
+ data: z.string(),
+ tags: z
+ .array(z.string())
+ .transform((tags) => tags.map((tag) => tag.toLowerCase().trim())),
+ title: z.string(),
+ }),
+ responses: {
+ 200: z.object({
+ id: z.string(),
+ }),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ artifactId: z.string().regex(userDefinedIdRegex),
+ }),
+ },
+ deleteKnowledgeArtifact: {
+ method: "DELETE",
+ path: "/clusters/:clusterId/knowledge/:artifactId",
+ headers: z.object({ authorization: z.string() }),
+ body: z.undefined(),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ },
+ },
+ getAllKnowledgeArtifacts: {
+ method: "GET",
+ path: "/clusters/:clusterId/knowledge-export",
+ headers: z.object({ authorization: z.string() }),
+ responses: {
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ data: z.string(),
+ tags: z.array(z.string()),
+ title: z.string(),
+ }),
+ ),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+ createRunRetry: {
+ method: "POST",
+ path: "/clusters/:clusterId/runs/:runId/retry",
+ headers: z.object({ authorization: z.string() }),
+ body: z.object({
+ messageId: z.string(),
+ }),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ },
+ },
+ createCall: {
+ method: "POST",
+ path: "/clusters/:clusterId/calls",
+ query: z.object({
+ waitTime: z.coerce
+ .number()
+ .min(0)
+ .max(20)
+ .default(0)
+ .describe(
+ "Time in seconds to keep the request open waiting for a response",
+ ),
+ }),
+ headers: z.object({
+ authorization: z.string(),
+ }),
+ body: z.object({
+ service: z.string(),
+ function: z.string(),
+ input: z.object({}).passthrough(),
+ }),
+ responses: {
+ 401: z.undefined(),
+ 200: z.object({
+ id: z.string(),
+ result: z.any().nullable(),
+ resultType: z.enum(["resolution", "rejection", "interrupt"]).nullable(),
+ status: z.enum(["pending", "running", "success", "failure", "stalled"]),
+ }),
+ },
+ },
+ createCallResult: {
+ method: "POST",
+ path: "/clusters/:clusterId/calls/:callId/result",
+ headers: z.object({
+ authorization: z.string(),
+ ...machineHeaders,
+ }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ callId: z.string(),
+ }),
+ responses: {
+ 204: z.undefined(),
+ 401: z.undefined(),
+ },
+ body: z.object({
+ result: z.any(),
+ resultType: z.enum(["resolution", "rejection", "interrupt"]),
+ meta: z.object({
+ functionExecutionTime: z.number().optional(),
+ }),
+ }),
+ },
+ listCalls: {
+ method: "GET",
+ path: "/clusters/:clusterId/calls",
+ query: z.object({
+ service: z.string(),
+ status: z
+ .enum(["pending", "running", "paused", "done", "failed"])
+ .default("pending"),
+ limit: z.coerce.number().min(1).max(20).default(10),
+ acknowledge: z.coerce
+ .boolean()
+ .default(false)
+ .describe("Should calls be marked as running"),
+ }),
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ headers: z.object({
+ authorization: z.string(),
+ ...machineHeaders,
+ }),
+ responses: {
+ 401: z.undefined(),
+ 410: z.object({
+ message: z.string(),
+ }),
+ 200: z.array(
+ z.object({
+ id: z.string(),
+ function: z.string(),
+ input: z.any(),
+ authContext: z.any().nullable(),
+ runContext: z.any().nullable(),
+ approved: z.boolean(),
+ }),
+ ),
+ },
+ },
+ getKnowledgeArtifact: {
+ method: "GET",
+ path: "/clusters/:clusterId/knowledge/:artifactId",
+ headers: z.object({ authorization: z.string() }),
+ responses: {
+ 200: z.object({
+ id: z.string(),
+ data: z.string(),
+ tags: z.array(z.string()),
+ title: z.string(),
+ }),
+ 401: z.undefined(),
+ 404: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ artifactId: z.string(),
+ }),
+ },
+ createStructuredOutput: {
+ method: "POST",
+ path: "/clusters/:clusterId/structured-output",
+ headers: z.object({ authorization: z.string() }),
+ body: z.object({
+ prompt: z.string(),
+ resultSchema: anyObject
+ .optional()
+ .describe(
+ "A JSON schema definition which the result object should conform to. By default the result will be a JSON object which does not conform to any schema",
+ ),
+ modelId: z.enum(["claude-3-5-sonnet", "claude-3-haiku"]),
+ temperature: z
+ .number()
+ .optional()
+ .describe("The temperature to use for the model")
+ .default(0.5),
+ }),
+ responses: {
+ 200: z.unknown(),
+ 401: z.undefined(),
+ },
+ pathParams: z.object({
+ clusterId: z.string(),
+ }),
+ },
+} as const;
+
+export const contract = c.router(definition);
diff --git a/app/components.json b/app/components.json
new file mode 100644
index 00000000..c98782ed
--- /dev/null
+++ b/app/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "app/globals.css",
+ "baseColor": "gray",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils"
+ }
+}
\ No newline at end of file
diff --git a/app/components/FakeProgress.tsx b/app/components/FakeProgress.tsx
new file mode 100644
index 00000000..0b884aef
--- /dev/null
+++ b/app/components/FakeProgress.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import { useNProgress } from "@tanem/react-nprogress";
+import { useDebounce } from "@uidotdev/usehooks";
+
+export function FakeProgress({
+ status,
+ timeConstant = 10000,
+}: {
+ status: string;
+ timeConstant?: number;
+}) {
+ const { animationDuration, isFinished, progress } = useNProgress({
+ isAnimating: status !== "Ready",
+ });
+
+ return (
+
+ );
+}
diff --git a/app/components/KnowledgeArtifactForm.tsx b/app/components/KnowledgeArtifactForm.tsx
new file mode 100644
index 00000000..fb04d123
--- /dev/null
+++ b/app/components/KnowledgeArtifactForm.tsx
@@ -0,0 +1,149 @@
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Card,
+ CardHeader,
+ CardContent,
+ CardFooter,
+} from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { MarkdownEditor } from "@/components/markdown-editor";
+import { snakeCase } from "lodash";
+
+export type KnowledgeArtifact = {
+ id: string;
+ data: string;
+ tags: string[];
+ title: string;
+ createdAt?: string;
+};
+
+type KnowledgeArtifactFormProps = {
+ initialArtifact?: KnowledgeArtifact;
+ onSubmit: (artifact: KnowledgeArtifact) => void;
+ onCancel: () => void;
+ submitButtonText: string;
+ editing: boolean;
+};
+
+export function KnowledgeArtifactForm({
+ initialArtifact,
+ onSubmit,
+ onCancel,
+ submitButtonText,
+ editing,
+}: KnowledgeArtifactFormProps) {
+ const [artifact, setArtifact] = useState(
+ initialArtifact || {
+ id: "",
+ data: "",
+ tags: [],
+ title: "",
+ },
+ );
+ const [wordCount, setWordCount] = useState(0);
+
+ useEffect(() => {
+ const words = artifact.data.trim().split(/\s+/).length;
+ setWordCount(words);
+ }, [artifact.data]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ const sanitized = {
+ ...artifact,
+ tags: artifact.tags.map((tag) => snakeCase(tag).toLowerCase().trim()),
+ };
+
+ setArtifact(sanitized);
+
+ onSubmit(sanitized);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/app/components/OrgList.tsx b/app/components/OrgList.tsx
new file mode 100644
index 00000000..e79dba40
--- /dev/null
+++ b/app/components/OrgList.tsx
@@ -0,0 +1,23 @@
+import { OrganizationList, auth } from "@clerk/nextjs";
+
+export async function OrgList() {
+ const { orgId } = await auth();
+
+ if (orgId) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/app/components/PromptMetricsCharts.tsx b/app/components/PromptMetricsCharts.tsx
new file mode 100644
index 00000000..88339dc3
--- /dev/null
+++ b/app/components/PromptMetricsCharts.tsx
@@ -0,0 +1,446 @@
+"use client";
+
+import * as React from "react";
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ XAxis,
+ YAxis,
+ Line,
+ LineChart,
+ LabelList,
+} from "recharts";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { contract } from "@/client/contract";
+
+export const description = "An interactive bar chart";
+
+const chartConfig = {
+ runCount: {
+ label: "Runs",
+ color: "hsl(210, 100%, 50%)", // Blue
+ },
+ jobCount: {
+ label: "Calls",
+ color: "hsl(120, 100%, 30%)", // Green
+ },
+ feedbackScore: {
+ label: "Score",
+ color: "hsl(200, 100%, 40%)", // Light Blue
+ },
+ feedbackSubmissions: {
+ label: "Submissions",
+ color: "hsl(140, 100%, 30%)", // Light Green
+ },
+} satisfies ChartConfig;
+
+const jobChartConfig = {
+ totalExecutionTime: {
+ label: "Total Exec Time (s)",
+ color: "hsl(210, 100%, 50%)", // Blue
+ },
+ averageExecutionTime: {
+ label: "Avg Exec Time (s)",
+ color: "hsl(120, 100%, 30%)", // Green
+ },
+} satisfies ChartConfig;
+
+type PromptMetrics = ClientInferResponseBody<
+ typeof contract.getRunConfigMetrics
+>;
+
+function formatTime(seconds: number): string {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const remainingSeconds = Math.floor(seconds % 60);
+
+ const parts = [];
+ if (hours > 0) parts.push(`${hours}h`);
+ if (minutes > 0) parts.push(`${minutes}m`);
+ if (remainingSeconds > 0 || parts.length === 0)
+ parts.push(`${remainingSeconds}s`);
+
+ return parts.slice(0, 2).join(" ");
+}
+
+export function PromptMetricsCharts({ metrics }: { metrics: PromptMetrics }) {
+ const [activeChart, setActiveChart] =
+ React.useState("runCount");
+
+ const total = React.useMemo(
+ () => ({
+ runCount: metrics.length,
+ feedbackSubmissions: metrics.filter((m) => m.feedbackScore !== null)
+ .length,
+ feedbackScore: Number(
+ (
+ metrics
+ .filter((m) => m.feedbackScore !== null)
+ .reduce((acc, curr) => acc + Number(curr.feedbackScore), 0) /
+ metrics.filter((m) => m.feedbackScore !== null).length
+ ).toFixed(2),
+ ),
+ }),
+ [metrics],
+ );
+
+ const metricsByDay = metrics.reduce(
+ (acc, curr) => {
+ const date = new Date(curr.createdAt).toISOString().split("T")[0];
+ acc[date] = acc[date] || {};
+ acc[date].runCount = (acc[date].runCount || 0) + 1;
+ acc[date].feedbackScoreSum =
+ (acc[date].feedbackScoreSum || 0) + (Number(curr.feedbackScore) || 0);
+ acc[date].feedbackSubmissions =
+ (acc[date].feedbackSubmissions || 0) +
+ (curr.feedbackScore === null ? 0 : 1);
+ acc[date].jobFailureCount =
+ (acc[date].jobFailureCount || 0) + (curr.jobFailureCount || 0);
+ acc[date].timeToCompletionSum =
+ (acc[date].timeToCompletionSum || 0) + (curr.timeToCompletion || 0);
+ acc[date].jobCount = (acc[date].jobCount || 0) + (curr.jobCount || 0);
+ return acc;
+ },
+ {} as Record<
+ string,
+ {
+ runCount: number;
+ feedbackScoreSum: number;
+ feedbackSubmissions: number;
+ jobFailureCount: number;
+ timeToCompletionSum: number;
+ jobCount: number;
+ }
+ >,
+ );
+
+ const chartData = React.useMemo(() => {
+ const last30Days = Array.from({ length: 30 }, (_, i) => {
+ const date = new Date();
+ date.setDate(date.getDate() - i);
+ return date.toISOString().split("T")[0];
+ }).reverse();
+
+ return last30Days.map((date) => {
+ const dayMetrics = metricsByDay[date] || {
+ runCount: 0,
+ jobCount: 0,
+ feedbackScoreSum: 0,
+ feedbackSubmissions: 0,
+ };
+ return {
+ date,
+ runCount: dayMetrics.runCount,
+ jobCount: dayMetrics.jobCount,
+ feedbackScore:
+ dayMetrics.feedbackScoreSum / dayMetrics.feedbackSubmissions,
+ feedbackSubmissions: dayMetrics.feedbackSubmissions,
+ };
+ });
+ }, [metricsByDay]);
+
+ console.log(chartData);
+
+ return (
+
+
+
+ Prompt Quality
+
+ Showing the prompt quality over time
+
+
+
+ {(["runCount", "feedbackScore", "feedbackSubmissions"] as const).map(
+ (key) => {
+ const chart = key;
+ return (
+ setActiveChart(chart)}
+ >
+
+ {chartConfig[chart].label}
+
+
+ {total[chart]}
+
+
+ );
+ },
+ )}
+
+
+
+
+ {activeChart === "feedbackScore" ? (
+
+
+ {
+ const date = new Date(value);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ });
+ }}
+ />
+
+ {
+ return new Date(value).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ }}
+ />
+ }
+ />
+
+ {
+ return value.toFixed(2);
+ }}
+ />
+
+
+ ) : (
+
+
+ {
+ const date = new Date(value);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ });
+ }}
+ />
+ {
+ return new Date(value).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ }}
+ />
+ }
+ />
+
+
+ )}
+
+
+
+ );
+}
+
+export function JobMetricsCharts({ metrics }: { metrics: PromptMetrics }) {
+ const [activeChart, setActiveChart] =
+ React.useState("totalExecutionTime");
+
+ const total = React.useMemo(
+ () => ({
+ totalExecutionTime: metrics.reduce(
+ (acc, curr) => acc + (Number(curr.timeToCompletion) || 0),
+ 0,
+ ),
+ averageExecutionTime:
+ metrics.reduce(
+ (acc, curr) => acc + (Number(curr.timeToCompletion) || 0),
+ 0,
+ ) / metrics.length,
+ }),
+ [metrics],
+ );
+
+ const metricsByDay = metrics.reduce(
+ (acc, curr) => {
+ const date = new Date(curr.createdAt).toISOString().split("T")[0];
+ if (!acc[date]) {
+ acc[date] = { totalExecutionTime: 0, count: 0 };
+ }
+ acc[date].totalExecutionTime += Number(curr.timeToCompletion) || 0;
+ acc[date].count += 1;
+ return acc;
+ },
+ {} as Record,
+ );
+
+ const chartData = React.useMemo(() => {
+ const last30Days = Array.from({ length: 30 }, (_, i) => {
+ const date = new Date();
+ date.setDate(date.getDate() - i);
+ return date.toISOString().split("T")[0];
+ }).reverse();
+
+ return last30Days.map((date) => {
+ const dayMetrics = metricsByDay[date] || {
+ totalExecutionTime: 0,
+ count: 0,
+ };
+ return {
+ date,
+ totalExecutionTime: dayMetrics.totalExecutionTime,
+ averageExecutionTime:
+ dayMetrics.count > 0
+ ? dayMetrics.totalExecutionTime / dayMetrics.count
+ : 0,
+ };
+ });
+ }, [metricsByDay]);
+
+ return (
+
+
+
+ Prompt Performance
+
+ Showing execution times for the last 30 days
+
+
+
+ {(
+ Object.keys(jobChartConfig) as Array
+ ).map((key) => (
+ setActiveChart(key)}
+ >
+
+ {jobChartConfig[key].label}
+
+
+ {formatTime(total[key])}
+
+
+ ))}
+
+
+
+
+
+
+ {
+ const date = new Date(value);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ });
+ }}
+ />
+ formatTime(value)}
+ />
+ {
+ return new Date(value).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ }}
+ />
+ }
+ />
+
+
+
+
+
+ );
+}
diff --git a/app/components/ThinkingIndicator.tsx b/app/components/ThinkingIndicator.tsx
new file mode 100644
index 00000000..713ab750
--- /dev/null
+++ b/app/components/ThinkingIndicator.tsx
@@ -0,0 +1,44 @@
+import React, { useState, useEffect } from "react";
+
+const messages = [
+ "Thinking",
+ "Consulting the model",
+ "Processing some data",
+ "Analyzing",
+ "Contemplating on next steps",
+ "Thinking deeply about the problem",
+];
+
+export function ThinkingIndicator() {
+ const [currentMessage, setCurrentMessage] = useState(messages[0]);
+ const [dots, setDots] = useState("");
+
+ useEffect(() => {
+ const changeMessage = () => {
+ setCurrentMessage(messages[Math.floor(Math.random() * messages.length)]);
+ // Set next interval randomly between 1000ms and 5000ms
+ const nextInterval = Math.floor(Math.random() * 4000) + 1000;
+ setTimeout(changeMessage, nextInterval);
+ };
+
+ // Start the first interval
+ const initialInterval = Math.floor(Math.random() * 4000) + 1000;
+ const initialTimeout = setTimeout(changeMessage, initialInterval);
+
+ const dotInterval = setInterval(() => {
+ setDots((prev) => (prev.length < 3 ? prev + "." : ""));
+ }, 500);
+
+ return () => {
+ clearTimeout(initialTimeout);
+ clearInterval(dotInterval);
+ };
+ }, []);
+
+ return (
+
+ {currentMessage}
+ {dots}
+
+ );
+}
diff --git a/app/components/WorkflowList.tsx b/app/components/WorkflowList.tsx
new file mode 100644
index 00000000..7e35aa5c
--- /dev/null
+++ b/app/components/WorkflowList.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth, useUser } from "@clerk/nextjs";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { PlayIcon, PlusIcon, UserIcon, XIcon } from "lucide-react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useEffect, useState } from "react";
+import { z } from "zod";
+import { PromptTextarea } from "./chat/prompt-textarea";
+import { Badge } from "./ui/badge";
+import { RunTab } from "./workflow-tab";
+import { ServerConnectionStatus } from "./server-connection-pane";
+
+type WorkflowListProps = {
+ clusterId: string;
+};
+
+const runFiltersSchema = z.object({
+ configId: z.string().optional(),
+ test: z.boolean().optional(),
+});
+
+export function RunList({ clusterId }: WorkflowListProps) {
+ const router = useRouter();
+ const { getToken, userId } = useAuth();
+ const user = useUser();
+ const [runToggle, setRunToggle] = useState("all");
+ const [limit, setLimit] = useState(20);
+ const [hasMore, setHasMore] = useState(true);
+ const [workflows, setWorkflows] = useState<
+ ClientInferResponseBody
+ >([]);
+ const [showNewRn, setShowNewRun] = useState(false);
+ const goToCluster = useCallback(
+ (c: string) => {
+ router.push(`/clusters/${c}/runs`);
+ },
+ [router],
+ );
+
+ const goToWorkflow = useCallback(
+ (c: string, w: string) => {
+ router.push(`/clusters/${c}/runs/${w}`);
+ },
+ [router],
+ );
+
+ const searchParams = useSearchParams();
+ const runFiltersQuery = searchParams?.get("filters");
+
+ const [runFilters, setRunFilters] = useState<
+ z.infer
+ >({});
+ const path = usePathname();
+
+ useEffect(() => {
+ if (!runFiltersQuery) {
+ return;
+ }
+
+ const parsedFilters = runFiltersSchema.safeParse(
+ JSON.parse(runFiltersQuery),
+ );
+ if (parsedFilters.success) {
+ setRunFilters(parsedFilters.data);
+ } else {
+ createErrorToast(parsedFilters.error, "Invalid filters specified");
+ }
+ }, [runFiltersQuery]);
+
+ const fetchWorkflows = useCallback(async () => {
+ if (!clusterId || !user.isLoaded) {
+ return;
+ }
+
+ const result = await client.listRuns({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ query: {
+ test: runFilters.test ? "true" : undefined,
+ userId: (runToggle === "mine" ? userId : undefined) ?? undefined,
+ limit: Math.min(limit, 500), // Ensure limit doesn't exceed 500
+ configId: runFilters.configId,
+ },
+ params: {
+ clusterId: clusterId,
+ },
+ });
+
+ if (result.status === 200) {
+ setWorkflows(result.body);
+ setHasMore(result.body.length === limit && limit < 50);
+ } else {
+ ServerConnectionStatus.addEvent({
+ type: "listRuns",
+ success: false,
+ });
+ }
+ }, [
+ clusterId,
+ getToken,
+ runToggle,
+ userId,
+ user.isLoaded,
+ limit,
+ runFilters,
+ ]);
+
+ useEffect(() => {
+ if (runFilters?.configId) {
+ fetchWorkflows();
+ }
+ }, [runFilters?.configId, fetchWorkflows]);
+
+ useEffect(() => {
+ fetchWorkflows();
+ const interval = setInterval(fetchWorkflows, 5000);
+ return () => clearInterval(interval);
+ }, [fetchWorkflows, runFilters?.configId]);
+
+ const loadMore = () => {
+ if (limit < 50) {
+ setLimit((prevLimit) => Math.min(prevLimit + 10, 50));
+ } else {
+ setHasMore(false);
+ }
+ };
+
+ return (
+
+
+ {(!!runFilters.configId || !!runFilters.test) && (
+
+ {runFilters.configId && (
+ {
+ setRunFilters({});
+ if (path) {
+ router.push(path);
+ }
+ }}
+ >
+ Filtering by Prompt
+
+
+ )}
+ {runFilters.test && (
+ {
+ setRunFilters({});
+ if (path) {
+ router.push(path);
+ }
+ }}
+ >
+ Filtering By Test Runs
+
+
+ )}
+
+ )}
+
+
{
+ if (value) setRunToggle(value);
+ setShowNewRun(false);
+ }}
+ variant="outline"
+ size="sm"
+ >
+
+
+ All Runs
+
+
+
+ My Runs
+
+
+
setShowNewRun(true)}
+ className="ml-2"
+ >
+
+
+
+
+ {showNewRn ? (
+
+ ) : (
+ <>
+
+ {hasMore && (
+
+ Load More
+
+ )}
+ {!hasMore && limit >= 50 && (
+
+ Maximum number of runs loaded. Delete some runs to load older
+ ones.
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/app/components/api-keys.tsx b/app/components/api-keys.tsx
new file mode 100644
index 00000000..41ccfab1
--- /dev/null
+++ b/app/components/api-keys.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useState, useCallback, useEffect } from "react";
+import { useAuth } from "@clerk/nextjs";
+import { client } from "@/client/client";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { formatRelative } from "date-fns";
+import { CreateApiKey } from "@/components/create-api-key";
+import { createErrorToast } from "@/lib/utils";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+type ApiKey = {
+ id: string;
+ name: string;
+ createdAt: Date;
+ createdBy: string;
+ revokedAt: Date | null;
+};
+
+export function ApiKeys({ clusterId }: { clusterId: string }) {
+ const { getToken } = useAuth();
+ const [apiKeys, setApiKeys] = useState([]);
+
+ const fetchApiKeys = useCallback(async () => {
+ try {
+ const result = await client.listApiKeys({
+ headers: { authorization: `Bearer ${await getToken()}` },
+ params: { clusterId },
+ });
+
+ if (result.status === 200) {
+ setApiKeys(result.body as ApiKey[]);
+ } else {
+ createErrorToast(result, "Failed to fetch API keys");
+ }
+ } catch (err) {
+ createErrorToast(err, "Failed to fetch API keys");
+ }
+ }, [clusterId, getToken]);
+
+ const handleRevoke = useCallback(
+ async (keyId: string) => {
+ try {
+ await client.revokeApiKey({
+ headers: { authorization: `Bearer ${await getToken()}` },
+ params: { clusterId, keyId },
+ });
+ fetchApiKeys();
+ } catch (err) {
+ createErrorToast(err, "Failed to revoke API key");
+ }
+ },
+ [clusterId, getToken, fetchApiKeys],
+ );
+
+ useEffect(() => {
+ fetchApiKeys();
+ }, [fetchApiKeys]);
+
+ return (
+
+
+
+ API Keys
+
+ Create and manage API keys for your cluster.
+
+
+
+
+
+
+ {apiKeys
+ .sort(
+ (a, b) =>
+ new Date(b.createdAt).getTime() -
+ new Date(a.createdAt).getTime(),
+ )
+ .map((apiKey) => (
+
+
+
+ {apiKey.name}
+
+ ({apiKey.id})
+
+
+
+ Created{" "}
+ {formatRelative(new Date(apiKey.createdAt), new Date())}
+
+
+
+
+ {apiKey.revokedAt ? "Revoked" : "Active"}
+
+ {!apiKey.revokedAt && (
+ handleRevoke(apiKey.id)}
+ variant="destructive"
+ size="sm"
+ className="text-xs"
+ >
+ Revoke
+
+ )}
+
+
+ ))}
+ {apiKeys.length === 0 && (
+
+
+ API keys are used to authenticate machines (workers) with your
+ cluster. Workers use these keys to:
+
+
+ Register functions that can be used in workflows
+ Create and manage runs
+ Report function execution results
+ Report health metrics
+
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/breadcrumbs.tsx b/app/components/breadcrumbs.tsx
new file mode 100644
index 00000000..300095ad
--- /dev/null
+++ b/app/components/breadcrumbs.tsx
@@ -0,0 +1,97 @@
+import { client } from "@/client/client";
+import { auth } from "@clerk/nextjs";
+import {
+ BookOpen,
+ ExternalLink,
+ Network,
+ PlayCircle,
+ Settings,
+ Hammer,
+ Plug,
+ Bot,
+ NetworkIcon,
+ ChevronRight,
+ BarChart2,
+} from "lucide-react";
+import Link from "next/link";
+import ErrorDisplay from "./error-display";
+
+interface ClusterBreadcrumbsProps {
+ clusterId: string;
+}
+
+const linkStyles =
+ "px-3 py-1.5 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-md transition-all relative after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 hover:after:w-full after:transition-all after:bg-gray-300 after:duration-300 flex items-center";
+
+export async function ClusterBreadcrumbs({
+ clusterId,
+}: ClusterBreadcrumbsProps) {
+ const { getToken } = auth();
+
+ const clusterDetails = await client.getCluster({
+ headers: { authorization: `Bearer ${await getToken()}` },
+ params: { clusterId },
+ });
+
+ if (clusterDetails.status !== 200) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {clusterDetails.body.name}
+
+
+
+
+
Runs
+
+ {clusterDetails.body.enableRunConfigs && (
+
+
Run Configs
+
+ )}
+ {clusterDetails.body.enableKnowledgebase && (
+
+
Knowledge
+
+ )}
+
+
Integrations
+
+
+
Usage
+
+
+
Settings
+
+
+ );
+}
+
+export async function GlobalBreadcrumbs() {
+ return (
+
+
+ Clusters
+
+
+ Docs
+
+
+ );
+}
diff --git a/app/components/bug-report-dialog.tsx b/app/components/bug-report-dialog.tsx
new file mode 100644
index 00000000..f78a317f
--- /dev/null
+++ b/app/components/bug-report-dialog.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import { client } from "@/client/client";
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Textarea } from "@/components/ui/textarea";
+import { cn, createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import {
+ BugIcon,
+ MessageCircle,
+ ThumbsDownIcon,
+ ThumbsUpIcon,
+} from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import toast from "react-hot-toast";
+
+// Import the CSS file for the animation
+import "@/styles/button-animations.css";
+
+export function FeedbackDialog({
+ runId,
+ clusterId,
+ userName,
+ comment: existingComment,
+ score: existingScore,
+}: {
+ runId: string;
+ clusterId: string;
+ userName: string;
+ comment?: string | null;
+ score?: number | null;
+}) {
+ const [feedbackComment, setFeedbackComment] = useState(existingComment || "");
+ const [bugReport, setBugReport] = useState("");
+ const [isFeedbackOpen, setIsFeedbackOpen] = useState(false);
+ const [isBugReportOpen, setIsBugReportOpen] = useState(false);
+ const { getToken } = useAuth();
+ const [thumbsUpClicked, setThumbsUpClicked] = useState(existingScore === 1);
+ const [thumbsDownClicked, setThumbsDownClicked] = useState(
+ existingScore === 0,
+ );
+ const [selectedScore, setSelectedScore] = useState(
+ existingScore ?? null,
+ );
+ const [animateGood, setAnimateGood] = useState(false);
+ const [animateBad, setAnimateBad] = useState(false);
+
+ const handleSubmitFeedback = useCallback(
+ async (score: number, comment?: string) => {
+ try {
+ const feedbackResult = await client.createFeedback({
+ body: {
+ comment: comment || null,
+ score,
+ },
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: clusterId,
+ runId,
+ },
+ });
+
+ if (feedbackResult?.status !== 204) {
+ createErrorToast(feedbackResult, "Failed to submit feedback");
+ } else {
+ toast.success("Thank you for your feedback!");
+ }
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to submit feedback");
+ } finally {
+ setIsFeedbackOpen(false);
+ setFeedbackComment("");
+ }
+ },
+ [clusterId, runId, getToken],
+ );
+
+ const handleSubmitBugReport = async () => {
+ if (!bugReport) {
+ toast.error("Please provide a description for the bug report");
+ return;
+ }
+
+ const toastId = toast.loading("Submitting bug report...");
+
+ try {
+ const bugReportResult = await fetch(
+ "https://inferable-subtleblushaardwolf.web.val.run/create-issue",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ description: [
+ bugReport,
+ `Cluster ID: ${clusterId}`,
+ `Run ID: ${runId}`,
+ `User Name: ${userName}`,
+ `Comment: ${feedbackComment}`,
+ `Score: ${selectedScore}`,
+ `URL: ${window.location.href}`,
+ `Browser: ${window.navigator.userAgent}`,
+ ].join("\n"),
+ }),
+ },
+ );
+
+ if (!bugReportResult.ok) {
+ toast.error("Failed to submit bug report", { id: toastId });
+ } else {
+ toast.success("Bug report submitted successfully!", { id: toastId });
+ }
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to submit bug report", { id: toastId });
+ } finally {
+ setIsBugReportOpen(false);
+ setBugReport("");
+ }
+ };
+
+ const handleGoodResult = useCallback(async () => {
+ setThumbsUpClicked(true);
+ setThumbsDownClicked(false);
+ setSelectedScore(1);
+ setAnimateGood(true);
+ await handleSubmitFeedback(1);
+ }, [handleSubmitFeedback]);
+
+ const handleBadResult = useCallback(async () => {
+ setThumbsDownClicked(true);
+ setThumbsUpClicked(false);
+ setSelectedScore(0);
+ setAnimateBad(true);
+ await handleSubmitFeedback(0);
+ }, [handleSubmitFeedback]);
+
+ useEffect(() => {
+ if (animateGood) {
+ const timer = setTimeout(() => setAnimateGood(false), 1500); // 1.5 seconds animation
+ return () => clearTimeout(timer);
+ }
+ }, [animateGood]);
+
+ useEffect(() => {
+ if (animateBad) {
+ const timer = setTimeout(() => setAnimateBad(false), 1500); // 1.5 seconds animation
+ return () => clearTimeout(timer);
+ }
+ }, [animateBad]);
+
+ const handleCommentSubmit = useCallback(() => {
+ if (selectedScore !== null) {
+ handleSubmitFeedback(selectedScore, feedbackComment);
+ }
+ }, [selectedScore, feedbackComment, handleSubmitFeedback]);
+
+ return (
+
+
+
+ {thumbsUpClicked ? "Good Result" : ""}
+
+
+
+ {thumbsDownClicked ? "Bad Result" : ""}
+
+ {(thumbsUpClicked || thumbsDownClicked) && (
+
+
+
+
+ {existingComment ? "Edit Comment" : "Add Comment"}
+
+
+
+
+
+ {existingComment ? "Edit Comment" : "Add Comment"}
+
+
+ Your feedback will help us improve our workflows. We appreciate
+ your input!
+
+
+
+
+ )}
+
+
+
+
+ Bug Report
+
+
+
+
+
Submit Bug Report
+
+ This report will be sent directly to our developers for
+ investigation and resolution.
+
+
+
+
+
+ );
+}
diff --git a/app/components/chat/ResultTable.css b/app/components/chat/ResultTable.css
new file mode 100644
index 00000000..6863434b
--- /dev/null
+++ b/app/components/chat/ResultTable.css
@@ -0,0 +1,11 @@
+.ht_clone_top {
+ z-index: 50 !important;
+}
+
+.ht_clone_left {
+ z-index: 50 !important;
+}
+
+.ht_clone_top_left_corner {
+ z-index: 50 !important;
+}
diff --git a/app/components/chat/Summarizable.tsx b/app/components/chat/Summarizable.tsx
new file mode 100644
index 00000000..fb1a3213
--- /dev/null
+++ b/app/components/chat/Summarizable.tsx
@@ -0,0 +1,19 @@
+import React from "react";
+
+export function Summarizable({
+ text,
+ createdAt,
+ children,
+}: {
+ text: string;
+ createdAt: Date;
+ children: React.ReactNode;
+}) {
+ const fifteenSecondsAgo = new Date(Date.now() - 15000);
+
+ if (new Date(createdAt) < fifteenSecondsAgo) {
+ return {text}
;
+ }
+
+ return children;
+}
diff --git a/app/components/chat/ToolContextButton.tsx b/app/components/chat/ToolContextButton.tsx
new file mode 100644
index 00000000..2d0ebf0c
--- /dev/null
+++ b/app/components/chat/ToolContextButton.tsx
@@ -0,0 +1,227 @@
+import { useEffect, useState } from "react";
+import { SquareFunction } from "lucide-react";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { Button } from "../ui/button";
+import { Textarea } from "../ui/textarea";
+import { client } from "@/client/client";
+import { useAuth } from "@clerk/nextjs";
+import toast from "react-hot-toast";
+import { Label } from "../ui/label";
+import { Input } from "../ui/input";
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "../ui/card";
+import { ReadOnlyJSON } from "../read-only-json";
+
+interface ToolContextButtonProps {
+ clusterId: string;
+ service: string;
+ functionName: string;
+}
+
+const ToolContextButton: React.FC = ({
+ clusterId,
+ service,
+ functionName,
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [context, setContext] = useState("");
+ const { getToken } = useAuth();
+ const [services, setServices] = useState<
+ Array<{
+ name: string;
+ functions?: Array<{
+ name: string;
+ description?: string;
+ schema?: string;
+ }>;
+ }>
+ >([]);
+ const [functionDetails, setFunctionDetails] = useState<{
+ name: string;
+ description?: string;
+ schema?: string;
+ } | null>(null);
+
+ useEffect(() => {
+ const fetchServices = async () => {
+ const token = await getToken();
+ const headers = { authorization: `Bearer ${token}` };
+ const params = { clusterId };
+
+ try {
+ const servicesResult = await client.listServices({
+ headers,
+ params,
+ });
+ if (servicesResult.status === 200) {
+ setServices(servicesResult.body);
+ }
+ } catch (error) {
+ console.error("Error fetching services:", error);
+ }
+ };
+
+ fetchServices();
+ }, [clusterId, getToken]);
+
+ useEffect(() => {
+ const findFunctionDetails = () => {
+ const serviceObj = services.find((s) => s.name === service);
+ if (serviceObj && serviceObj.functions) {
+ const func = serviceObj.functions.find((f) => f.name === functionName);
+ if (func) {
+ setFunctionDetails(func);
+ }
+ }
+ };
+
+ if (services.length > 0) {
+ findFunctionDetails();
+ }
+ }, [services, service, functionName]);
+
+ const fetchContext = async () => {
+ const token = await getToken();
+ try {
+ const result = await client.getFunctionMetadata({
+ params: { clusterId, service, function: functionName },
+ headers: { authorization: `Bearer ${token}` },
+ });
+
+ if (result.status === 200) {
+ setContext(result.body?.additionalContext || "");
+ } else {
+ throw new Error("Failed to fetch tool context");
+ }
+ } catch (error) {
+ console.error("Error fetching tool context:", error);
+ toast.error("Failed to fetch tool context. Please try again.");
+ }
+ };
+
+ const updateContext = async () => {
+ const token = await getToken();
+ try {
+ const result = await client.upsertFunctionMetadata({
+ params: { clusterId, service, function: functionName },
+ headers: { authorization: `Bearer ${token}` },
+ body: {
+ additionalContext: context,
+ },
+ });
+
+ if (result.status === 204) {
+ toast.success("Tool context updated successfully.");
+ setIsOpen(false);
+ } else {
+ throw new Error("Failed to update tool context");
+ }
+ } catch (error) {
+ console.error("Error updating tool context:", error);
+ toast.error("Failed to update tool context. Please try again.");
+ }
+ };
+
+ return (
+
+
+ {
+ fetchContext();
+ setIsOpen(true);
+ }}
+ >
+
+
+
+
+
+
+
+ {service}.{functionName}
+
+
+ Details for {service}.{functionName}
+
+
+
+
+
+ Context
+
+
+
+
Context
+
+ This context is used to guide the tools action globally.
+
+
+
+
+ Save Context
+
+
+
+ {functionDetails && (
+
+
+ Metadata
+
+
+
+ Function Name
+
+
+ {functionDetails.description && (
+
+ Description
+
+
+ )}
+ {functionDetails.schema && (
+
+ Schema
+
+
+ )}
+
+
+ )}
+
+
+
+ );
+};
+
+export default ToolContextButton;
diff --git a/app/components/chat/ai-message.tsx b/app/components/chat/ai-message.tsx
new file mode 100644
index 00000000..9d3fc88b
--- /dev/null
+++ b/app/components/chat/ai-message.tsx
@@ -0,0 +1,127 @@
+import { agentDataSchema } from "@/client/contract";
+import { formatRelative } from "date-fns";
+import { startCase } from "lodash";
+import { AlertTriangle, Brain, CheckCircleIcon } from "lucide-react";
+import { ReactNode } from "react";
+import ReactMarkdown from "react-markdown";
+import { z } from "zod";
+import { JsonForm } from "../json-form";
+import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
+import { MessageContainerProps } from "./workflow-event";
+
+interface DataSectionProps {
+ title: string;
+ icon: React.ComponentType;
+ content: ReactNode;
+}
+
+const DataSection = ({ title, icon: Icon, content }: DataSectionProps) => (
+
+
+
+ {title}
+
+ {content}
+
+);
+
+const basicResultSchema = z.record(z.string());
+
+const ResultSection = ({ result }: { result: object }) => {
+ const { success: basic, data: basicData } =
+ basicResultSchema.safeParse(result);
+
+ if (basic) {
+ return (
+
+ {Object.entries(basicData).map(([key, value]) => (
+
+
+ {startCase(key)}
+
+
+ {value.split("\n").map((v, index) => (
+
+ {v}
+
+ ))}
+
+
+ ))}
+
+ );
+ }
+
+ return ;
+};
+
+export const AiMessage = ({ data, createdAt }: MessageContainerProps) => {
+ const parsedData = agentDataSchema.parse(data);
+ const { issue, result, message, invocations, learnings } = parsedData;
+
+ const hasReasoning = invocations?.find((invocation) => invocation.reasoning);
+ if (!hasReasoning && !message && !result && !issue && !learnings) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
Inferable AI
+
+ {formatRelative(createdAt, new Date())}
+
+
+
+
+
+ {message && (
+
+ {message}
+
+ }
+ />
+ )}
+ {result && }
+ {issue && (
+ {issue}}
+ />
+ )}
+ {hasReasoning &&
+ invocations?.map((invocation, index) => (
+
+ Invoking {invocation.toolName}: {invocation.reasoning}
+
+ }
+ />
+ ))}
+ {learnings?.map((learning, index) => (
+
+ {`${learning.summary} (${learning.entities.map((e: any) => e.name).join(", ")})`}
+
+ }
+ />
+ ))}
+
+
+ );
+};
diff --git a/app/components/chat/blob.tsx b/app/components/chat/blob.tsx
new file mode 100644
index 00000000..ca800bf4
--- /dev/null
+++ b/app/components/chat/blob.tsx
@@ -0,0 +1,64 @@
+import { client } from "@/client/client";
+import { BlobDetails } from "@/lib/types";
+import { useAuth } from "@clerk/nextjs";
+import { useCallback, useEffect, useState } from "react";
+import { ReadOnlyJSON } from "../read-only-json";
+
+export function Blob({
+ blob,
+ clusterId,
+}: {
+ blob: BlobDetails;
+ clusterId: string;
+}) {
+ const { getToken } = useAuth();
+ const [fetching, setFetching] = useState(true);
+ const [data, setData] = useState(null);
+
+ const fetchBlobData = useCallback(async () => {
+ const response = await client.getBlobData({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ blobId: blob.id,
+ clusterId,
+ },
+ });
+
+ if (response.status === 200) {
+ setData(response.body);
+ }
+ setFetching(false);
+ }, [blob.id, clusterId, getToken]);
+
+ useEffect(() => {
+ fetchBlobData();
+ }, [blob.id, blob.name, fetchBlobData]);
+
+ return (
+
+
+ {fetching ? "Loading..." : blob.name}
+
+ {data && blob.type === "application/json" && (
+
+
+
+ )}
+ {data && blob.type === "image/png" && (
+
+ )}
+
+ );
+}
diff --git a/app/components/chat/event-indicator.tsx b/app/components/chat/event-indicator.tsx
new file mode 100644
index 00000000..ea2b1bf3
--- /dev/null
+++ b/app/components/chat/event-indicator.tsx
@@ -0,0 +1,37 @@
+import { useEffect, useState } from "react";
+
+export default function EventIndicator({
+ lastEventAt,
+ hasPendingJobs,
+}: {
+ lastEventAt: number;
+ hasPendingJobs?: boolean;
+}) {
+ const [hasRecentEvent, setHasRecentEvent] = useState(false);
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ if (Date.now() - lastEventAt < 1000) {
+ setHasRecentEvent(true);
+ } else {
+ setHasRecentEvent(false);
+ }
+ }, 500);
+
+ return () => clearInterval(timer);
+ });
+
+ const loading = hasPendingJobs || hasRecentEvent;
+
+ return (
+
+ );
+}
diff --git a/app/components/chat/function-call.tsx b/app/components/chat/function-call.tsx
new file mode 100644
index 00000000..34ee6bb8
--- /dev/null
+++ b/app/components/chat/function-call.tsx
@@ -0,0 +1,229 @@
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import {
+ SmallDeadGrayCircle,
+ SmallDeadGreenCircle,
+ SmallDeadRedCircle,
+ SmallLiveAmberCircle,
+ SmallLiveBlueCircle,
+} from "@/components/circles";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { WorkflowJob } from "@/lib/types";
+import { unpack } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { Cpu } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { JsonForm } from "../json-form";
+import { Button } from "../ui/button";
+import ToolContextButton from "./ToolContextButton";
+
+const statusCircle = (
+ status: WorkflowJob["status"],
+ resultType: WorkflowJob["resultType"],
+) => {
+ if (status === "pending") {
+ return ;
+ }
+ if (status === "success") {
+ if (resultType === "rejection") {
+ return ;
+ }
+
+ return ;
+ }
+ if (status === "failure") {
+ return ;
+ }
+ if (status === "stalled") {
+ return ;
+ }
+ if (status === "running") {
+ return ;
+ }
+};
+
+function FunctionCall({
+ clusterId,
+ jobId,
+ isFocused,
+ onFocusChange,
+ service,
+ resultType,
+ status,
+ targetFn,
+ approved,
+ approvalRequested,
+ submitApproval,
+}: {
+ clusterId: string;
+ jobId: string;
+ isFocused: boolean;
+ onFocusChange: (isFocused: boolean) => void;
+ service: string;
+ resultType: WorkflowJob["resultType"];
+ status: WorkflowJob["status"];
+ targetFn: string;
+ approved: boolean | null;
+ approvalRequested: boolean | null;
+ submitApproval: (approved: boolean) => void;
+}) {
+ const [isExpanded, setExpanded] = useState(isFocused);
+
+ useEffect(() => {
+ onFocusChange(isExpanded);
+ }, [isExpanded, onFocusChange, setExpanded]);
+
+ useEffect(() => {
+ setExpanded(isFocused);
+ onFocusChange(isFocused);
+ }, [isFocused, onFocusChange, setExpanded]);
+
+ const [editing, setEditing] = useState(false);
+
+ const [job, setJob] = useState
+ > | null>(null);
+
+ const completedWithRejection =
+ job?.resultType === "rejection" && job?.status === "success";
+
+ const { getToken } = useAuth();
+
+ const getJobDetail = useCallback(async () => {
+ const result = await client.getCall({
+ params: {
+ callId: jobId,
+ clusterId,
+ },
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ });
+
+ if (result.status === 200) {
+ setJob(result.body);
+ } else {
+ setJob(null);
+ }
+ }, [jobId, clusterId, getToken]);
+
+ useEffect(() => {
+ let interval: NodeJS.Timeout;
+
+ if (editing) {
+ getJobDetail();
+
+ interval = setInterval(getJobDetail, 1000);
+ }
+
+ return () => {
+ interval && clearInterval(interval);
+ };
+ }, [jobId, clusterId, getToken, getJobDetail, editing]);
+
+ useEffect(() => {
+ if (status === "success") {
+ getJobDetail();
+ }
+ }, [status, getJobDetail]);
+
+ return (
+
+
setEditing(o)}>
+
+
+
+
+
+
+
+ {service}.{targetFn}
+
+ {statusCircle(status, resultType)}
+
+
+
+
+
+
+
+ {approvalRequested && !approved && (
+
+ submitApproval(true)}
+ >
+ Approve
+
+ submitApproval(false)}
+ >
+ Deny
+
+
+ )}
+
+ {completedWithRejection && (
+
+ Inferable was able to run this tool, but it resulted in an
+ error.
+
+ )}
+
+
+
+
+
+ {service}.{targetFn}()
+
+ {jobId}
+
+
+
+
Metadata
+
+ Input
+ {job?.targetArgs && (
+
+ )}
+ Result
+ {(job?.result && (
+
+ )) ||
+ "Waiting..."}
+
+
+
+
+ );
+}
+
+export default FunctionCall;
diff --git a/app/components/chat/human-message.tsx b/app/components/chat/human-message.tsx
new file mode 100644
index 00000000..8f29935c
--- /dev/null
+++ b/app/components/chat/human-message.tsx
@@ -0,0 +1,166 @@
+import { client } from "@/client/client";
+import { genericMessageDataSchema } from "@/client/contract";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { formatRelative } from "date-fns";
+import { PencilLineIcon, RefreshCcw } from "lucide-react";
+import { useCallback, useState } from "react";
+import toast from "react-hot-toast";
+import { Button } from "../ui/button";
+import { Textarea } from "../ui/textarea";
+import { MessageContainerProps } from "./workflow-event";
+
+export function HumanMessage({
+ clusterId,
+ isEditable,
+ runId,
+ data,
+ id: messageId,
+ onPreMutation,
+ pending,
+ createdAt,
+}: MessageContainerProps) {
+ data = genericMessageDataSchema.parse(data);
+
+ const [editing, setEditing] = useState(false);
+ const [editedValue, setEditedValue] = useState(data.message);
+ const { getToken } = useAuth();
+
+ const sendMessage = useCallback(async () => {
+ if (!editedValue) return;
+
+ await client
+ .updateMessage({
+ body: {
+ message: editedValue,
+ },
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId,
+ runId,
+ messageId,
+ },
+ })
+ .then((result) => {
+ if (result.status === 200) {
+ toast.success("Success!");
+ } else {
+ createErrorToast(result, "Failed to update message");
+ }
+ })
+ .catch((error) => {
+ createErrorToast(error, "Failed to update message");
+ });
+ }, [editedValue, clusterId, runId, messageId, getToken]);
+
+ const updateMessage = useCallback(async () => {
+ const id = toast.loading("Updating message...");
+
+ await sendMessage();
+
+ toast.dismiss(id);
+ }, [sendMessage]);
+
+ const retryMessage = useCallback(async () => {
+ const id = toast.loading("Retrying message...");
+
+ await sendMessage();
+
+ toast.dismiss(id);
+ }, [sendMessage]);
+
+ const onSubmit = useCallback(async () => {
+ if (editedValue === data.message) {
+ setEditing(false);
+ return;
+ }
+
+ updateMessage().then(() => {
+ setEditing(false);
+ setEditedValue(data.message);
+ });
+ }, [editedValue, data, updateMessage, setEditing]);
+
+ if (editing) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
Human
+
+ {formatRelative(createdAt, new Date())}
+
+
+
+
{data.message}
+
+ {isEditable ? (
+
+
setEditing(true)}
+ onMouseEnter={() => onPreMutation(true)}
+ onMouseLeave={() => onPreMutation(false)}
+ >
+
+
+
onPreMutation(true)}
+ onMouseLeave={() => onPreMutation(false)}
+ >
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/app/components/chat/input-fields.tsx b/app/components/chat/input-fields.tsx
new file mode 100644
index 00000000..94337e0b
--- /dev/null
+++ b/app/components/chat/input-fields.tsx
@@ -0,0 +1,71 @@
+import React, { useState, useEffect } from "react";
+import { Button } from "../ui/button";
+import { Input } from "../ui/input";
+import { Label } from "../ui/label";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import { PencilIcon } from "lucide-react";
+
+interface InputFieldsProps {
+ inputs: string[];
+ onApply: (inputValues: Record) => void;
+}
+
+export function InputFields({ inputs, onApply }: InputFieldsProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [inputValues, setInputValues] = useState>({});
+ const [allInputsFilled, setAllInputsFilled] = useState(false);
+
+ useEffect(() => {
+ const filled = inputs.every((input) => inputValues[input]?.trim());
+ setAllInputsFilled(filled);
+ }, [inputValues, inputs]);
+
+ const handleInputChange = (input: string, newValue: string) => {
+ setInputValues((prev) => ({ ...prev, [input]: newValue }));
+ };
+
+ const applyInputs = () => {
+ onApply(inputValues);
+ setIsOpen(false);
+ };
+
+ return (
+
+
+
+
+ Inputs ({inputs.length})
+ {!allInputsFilled && (
+
+ )}
+ {allInputsFilled && (
+
+ )}
+
+
+
+
+
Fill Input Fields
+ {inputs.map((input, index) => (
+
+
+ {input.replace(/[{}]/g, "")}
+
+ handleInputChange(input, e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ applyInputs();
+ }
+ }}
+ />
+
+ ))}
+
Apply Inputs
+
+
+
+ );
+}
diff --git a/app/components/chat/prompt-template-form.tsx b/app/components/chat/prompt-template-form.tsx
new file mode 100644
index 00000000..b87dd118
--- /dev/null
+++ b/app/components/chat/prompt-template-form.tsx
@@ -0,0 +1,291 @@
+import React, { useEffect, useRef } from "react";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { FileWarning } from "lucide-react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
+import { Button } from "../ui/button";
+import { Input } from "../ui/input";
+import { Textarea } from "../ui/textarea";
+import { StructuredOutputEditor } from "./structured-output-editor";
+import { Switch } from "../ui/switch";
+import Link from "next/link";
+
+const formSchema = z.object({
+ name: z.string().min(1, "Name is required"),
+ initialPrompt: z.string().optional(),
+ systemPrompt: z.string().optional(),
+ attachedFunctions: z.string(),
+ resultSchema: z.string().optional(),
+ inputSchema: z.string().optional(),
+ public: z.boolean().default(false),
+});
+
+type PromptTemplateFormProps = {
+ initialData: {
+ name: string;
+ initialPrompt?: string;
+ systemPrompt?: string;
+ attachedFunctions: string[];
+ resultSchema?: unknown;
+ inputSchema?: unknown;
+ public: boolean;
+ };
+ onSubmit: (data: z.infer) => Promise;
+ isLoading: boolean;
+};
+
+export function PromptTemplateForm({
+ initialData,
+ onSubmit,
+ isLoading,
+}: PromptTemplateFormProps) {
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: initialData.name,
+ initialPrompt: initialData.initialPrompt,
+ systemPrompt: initialData.systemPrompt,
+ attachedFunctions: initialData.attachedFunctions?.join(", "),
+ resultSchema: JSON.stringify(initialData.resultSchema, null, 2),
+ inputSchema: JSON.stringify(initialData.inputSchema, null, 2),
+ public: initialData.public,
+ },
+ });
+
+ const textareaRef = useRef(null);
+
+ useEffect(() => {
+ const textarea = textareaRef.current;
+ if (textarea) {
+ const adjustHeight = () => {
+ textarea.style.height = "auto";
+ textarea.style.height = `${textarea.scrollHeight}px`;
+ };
+
+ adjustHeight();
+ textarea.addEventListener("input", adjustHeight);
+
+ return () => {
+ textarea.removeEventListener("input", adjustHeight);
+ };
+ }
+ }, []);
+
+ return (
+
+
+ );
+}
diff --git a/app/components/chat/prompt-textarea.tsx b/app/components/chat/prompt-textarea.tsx
new file mode 100644
index 00000000..fea1f632
--- /dev/null
+++ b/app/components/chat/prompt-textarea.tsx
@@ -0,0 +1,265 @@
+"use client";
+
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import { Textarea } from "@/components/ui/textarea";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { ClientInferRequest } from "@ts-rest/core";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Button } from "../ui/button";
+import { InputFields } from "./input-fields";
+import RunConfig from "./run-config";
+import Commands from "./sdk-commands";
+import { TemplateSearch } from "./template-search";
+
+export type RunConfiguration = {
+ attachedFunctions: string[];
+ structuredOutput: string | null;
+ reasoningTraces: boolean;
+ prompt: string;
+ template?: {
+ id: string;
+ input: Record;
+ };
+};
+
+export function PromptTextarea({ clusterId }: { clusterId: string }) {
+ const textareaRef = useRef(null);
+ const [inputs, setInputs] = useState([]);
+ const [prompt, setPrompt] = useState("");
+ const [selectedTemplate, setSelectedTemplate] = useState<{
+ id: string;
+ name: string;
+ } | null>(null);
+ const { getToken } = useAuth();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = "auto";
+ textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
+ }
+ // Update inputs when value changes
+ const newInputs = prompt.match(/{{.*?}}/g) || [];
+ setInputs(newInputs);
+ }, [prompt]);
+
+ const [runConfig, setRunConfig] = useState({
+ attachedFunctions: [] as string[],
+ structuredOutput: null as string | null,
+ reasoningTraces: true,
+ });
+
+ const handleConfigChange = (newConfig: {
+ attachedFunctions: string[];
+ structuredOutput: string | null;
+ reasoningTraces: boolean;
+ }) => {
+ setRunConfig(newConfig);
+ };
+
+ const [storedInputs, setStoredInputs] = useState>({});
+
+ const onSubmit = useCallback(
+ async (config: RunConfiguration) => {
+ const body: ClientInferRequest["body"] = {};
+
+ if (config.template?.id) {
+ body.template = {
+ id: config.template.id,
+ input: Object.fromEntries(
+ Object.entries(config.template.input).map(([key, value]) => [
+ key.replaceAll("{{", "").replaceAll("}}", ""),
+ value,
+ ]),
+ ),
+ };
+ } else {
+ body.initialPrompt = config.prompt.replace(/{{.*?}}/g, "");
+ }
+
+ if (config.structuredOutput) {
+ body.resultSchema = JSON.parse(config.structuredOutput);
+ }
+
+ if (config.attachedFunctions && config.attachedFunctions.length > 0) {
+ body.attachedFunctions = config.attachedFunctions?.map((fn) => {
+ const [service, functionName] = fn.split("_");
+
+ return {
+ service,
+ function: functionName,
+ };
+ });
+ }
+
+ body.reasoningTraces = config.reasoningTraces;
+
+ body.interactive = true;
+
+ const result = await client.createRun({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ body,
+ params: {
+ clusterId: clusterId,
+ },
+ });
+
+ if (result.status !== 201) {
+ createErrorToast(result, "Failed to create workflow");
+ return;
+ } else {
+ // clear everything
+ setPrompt("");
+ setSelectedTemplate(null);
+ setStoredInputs({});
+ setRunConfig({
+ attachedFunctions: [],
+ structuredOutput: null,
+ reasoningTraces: true,
+ });
+ }
+
+ router.push(`/clusters/${clusterId}/runs/${result.body.id}`);
+ },
+ [clusterId, getToken, router],
+ );
+
+ const submit = () => {
+ let updatedPrompt = prompt;
+
+ if (!selectedTemplate) {
+ updatedPrompt = prompt;
+
+ Object.entries(storedInputs).forEach(([input, inputValue]) => {
+ if (inputValue) {
+ updatedPrompt = updatedPrompt.replace(
+ new RegExp(input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
+ `${input.replace(/[{}]/g, "")}: ${inputValue} `,
+ );
+ }
+ });
+
+ setPrompt(updatedPrompt);
+ }
+
+ onSubmit({
+ attachedFunctions: runConfig.attachedFunctions,
+ structuredOutput: runConfig.structuredOutput,
+ reasoningTraces: runConfig.reasoningTraces,
+ prompt: updatedPrompt,
+ template: selectedTemplate
+ ? {
+ id: selectedTemplate.id,
+ input: storedInputs,
+ }
+ : undefined,
+ });
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ submit();
+ }
+ };
+
+ const selectConfig = (template: {
+ id: string;
+ name: string;
+ attachedFunctions: string[];
+ structuredOutput?: unknown;
+ initialPrompt?: string | null;
+ }) => {
+ setRunConfig({
+ attachedFunctions: template.attachedFunctions,
+ structuredOutput: template.structuredOutput
+ ? JSON.stringify(template.structuredOutput)
+ : null,
+ reasoningTraces: true,
+ });
+ template.initialPrompt && setPrompt(template.initialPrompt);
+ setSelectedTemplate({
+ id: template.id,
+ name: template.name,
+ });
+ };
+
+ const searchParams = useSearchParams();
+ const promptIdQuery = searchParams?.get("promptId");
+ const promptQuery = searchParams?.get("prompt");
+
+ useEffect(() => {
+ const fetchPrompt = async (configId: string) => {
+ const result = await client.getRunConfig({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: clusterId,
+ configId,
+ },
+ });
+
+ if (result.status === 200) {
+ selectConfig(result.body);
+ }
+ };
+
+ if (promptIdQuery) {
+ fetchPrompt(promptIdQuery);
+ } else if (promptQuery) {
+ setPrompt(promptQuery);
+ }
+ }, [promptIdQuery, promptQuery, clusterId, getToken]);
+
+ return (
+
+
{
+ setPrompt(e.target.value);
+ setSelectedTemplate(null);
+ }}
+ onKeyDown={handleKeyDown}
+ className="resize-none overflow-hidden"
+ />
+ {selectedTemplate && (
+
+ Using run config: {selectedTemplate.name}
+
+ )}
+
+
+ Start Run
+
+
+ {inputs.length > 0 && (
+
+ )}
+ {prompt.length > 3 && (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/chat/run-config.tsx b/app/components/chat/run-config.tsx
new file mode 100644
index 00000000..c4e96e0c
--- /dev/null
+++ b/app/components/chat/run-config.tsx
@@ -0,0 +1,132 @@
+import { SettingsIcon } from "lucide-react";
+import React, { useState } from "react";
+import { toast } from "react-hot-toast";
+import { Button } from "../ui/button";
+import { Label } from "../ui/label";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import { Textarea } from "../ui/textarea";
+import { ToolInput } from "./tool-input";
+import { Checkbox } from "../ui/checkbox";
+
+type RunConfigProps = {
+ config: {
+ attachedFunctions: string[];
+ structuredOutput: string | null;
+ reasoningTraces: boolean;
+ };
+ onConfigChange: (newConfig: {
+ attachedFunctions: string[];
+ structuredOutput: string | null;
+ reasoningTraces: boolean;
+ }) => void;
+};
+
+const RunConfig: React.FC = ({ config, onConfigChange }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [attachedFunctions, setAttachedFunctions] = useState(
+ config.attachedFunctions.filter(Boolean),
+ );
+
+ const [structuredOutput, setStructuredOutput] = useState(
+ config.structuredOutput,
+ );
+
+ const [reasoningTraces, setReasoningTraces] = useState(
+ config.reasoningTraces,
+ );
+
+ const hasConfig = attachedFunctions.length > 0 || !!structuredOutput;
+
+ const handleApplyConfig = () => {
+ if (structuredOutput) {
+ try {
+ JSON.parse(structuredOutput);
+ setJsonInvalid(false);
+ } catch (e) {
+ toast.error("Invalid JSON in structured output");
+ return;
+ }
+ }
+
+ onConfigChange({
+ attachedFunctions,
+ structuredOutput,
+ reasoningTraces,
+ });
+ setIsOpen(false);
+ };
+
+ const [jsonInvalid, setJsonInvalid] = useState(false);
+
+ return (
+
+
+
+
+ Run Config
+ {hasConfig && (
+
+ )}
+
+
+
+
+
Run Configuration
+
+ setReasoningTraces(!!checked)}
+ >
+ Enable Reasoning Traces
+
+
+
Attached Functions
+
+ Select the functions that the run will be able to use. If none
+ selected, all functions will be available.
+
+
+
+
+
+ Structured Output (JSON Schema)
+
+
+ A{" "}
+
+ JSON Schema
+ {" "}
+ that describes the structured output the run must return.
+
+
setStructuredOutput(e.target.value || null)}
+ onBlur={() => {
+ if (structuredOutput) {
+ try {
+ JSON.parse(structuredOutput);
+ setJsonInvalid(false);
+ } catch (e) {
+ setJsonInvalid(true);
+ }
+ } else {
+ setJsonInvalid(false);
+ }
+ }}
+ />
+ {jsonInvalid && (
+ Invalid JSON
+ )}
+
+
Apply Configuration
+
+
+
+ );
+};
+
+export default RunConfig;
diff --git a/app/components/chat/sdk-commands.tsx b/app/components/chat/sdk-commands.tsx
new file mode 100644
index 00000000..c1712bb3
--- /dev/null
+++ b/app/components/chat/sdk-commands.tsx
@@ -0,0 +1,175 @@
+import React, { useState } from "react";
+import { Button } from "../ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
+import { CodeIcon, CopyIcon, CheckIcon } from "lucide-react";
+import SyntaxHighlighter from "react-syntax-highlighter";
+import theme from "react-syntax-highlighter/dist/cjs/styles/hljs/tomorrow";
+
+type SDKCommandsProps = {
+ clusterId: string;
+ config: {
+ attachedFunctions: string[];
+ structuredOutput: string | null;
+ prompt: string;
+ };
+};
+
+const SDKCommands: React.FC = ({ clusterId, config }) => {
+ const [copiedCurl, setCopiedCurl] = useState(false);
+ const [copiedCLI, setCopiedCLI] = useState(false);
+
+ const getCurlCommand = () => {
+ const body: {
+ message: string;
+ result?: {
+ schema: unknown;
+ };
+ attachedFunctions?: string[];
+ } = {
+ message: config.prompt,
+ };
+
+ if (config.structuredOutput) {
+ body.result = {
+ schema: config.structuredOutput,
+ };
+ }
+
+ if (config.attachedFunctions.length > 0) {
+ body.attachedFunctions = config.attachedFunctions;
+ }
+
+ // Generate curl command based on config
+ return `curl -X POST "https://api.inferable.ai/clusters/${clusterId}/runs" \\
+ -H "Content-Type: application/json" \\
+ -H "Authorization: Bearer $INFERABLE_API_KEY" \\
+ -d '${JSON.stringify(body, null, 2)}'`;
+ };
+
+ const getCLICommand = () => {
+ let command = `inf runs create --cluster '${clusterId}' `;
+
+ if (config.attachedFunctions.length > 0) {
+ command += `\\\n --attachedFunctions '${config.attachedFunctions.join(",")}' `;
+ }
+
+ if (config.structuredOutput) {
+ command += `\\\n --resultSchema '${JSON.stringify(JSON.parse(config.structuredOutput), null, 2)}' `;
+ }
+
+ command += `"${config.prompt}" `;
+
+ return command;
+ };
+
+ const copyToClipboard = (
+ text: string,
+ setCopied: React.Dispatch>,
+ ) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ cURL
+ CLI
+
+
+
+ Execute this prompt via the{" "}
+
+ Inferable HTTP API
+
+
+
+
+ {getCurlCommand()}
+
+
+ copyToClipboard(getCurlCommand(), setCopiedCurl)}
+ >
+ {copiedCurl ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy to Clipboard
+ >
+ )}
+
+
+
+
+ Execute this prompt via the{" "}
+
+ Inferable CLI
+
+
+
+
+ {getCLICommand()}
+
+
+ copyToClipboard(getCLICommand(), setCopiedCLI)}
+ >
+ {copiedCLI ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy to Clipboard
+ >
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default SDKCommands;
diff --git a/app/components/chat/structured-output-editor.tsx b/app/components/chat/structured-output-editor.tsx
new file mode 100644
index 00000000..5d47af79
--- /dev/null
+++ b/app/components/chat/structured-output-editor.tsx
@@ -0,0 +1,293 @@
+import { z } from "zod";
+import { Textarea } from "../ui/textarea";
+import { useState, useEffect, useCallback } from "react";
+import { Button } from "../ui/button";
+import { Input } from "../ui/input";
+import { Checkbox } from "../ui/checkbox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import debounce from "lodash/debounce";
+import { cn } from "@/lib/utils";
+import { X, Plus } from "lucide-react";
+
+const basicStructuredOutputSchema = z.object({
+ type: z.literal("object"),
+ properties: z.record(
+ z.object({
+ type: z.literal("string"),
+ description: z.string().optional(),
+ }),
+ ),
+ required: z.array(z.string()).optional(),
+ additionalProperties: z.boolean().default(false),
+});
+
+interface Property {
+ key: string;
+ type: string;
+ description: string;
+ required: boolean;
+}
+
+const emptyBasicSchema = {
+ type: "object",
+ properties: {},
+ required: [],
+};
+
+export function StructuredOutputEditor({
+ value,
+ onChange,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ const [properties, setProperties] = useState([]);
+ const [editorType, setEditorType] = useState<"basic" | "advanced">("basic");
+ const [advancedValue, setAdvancedValue] = useState(value);
+
+ const initializeEditor = useCallback(
+ ({
+ schemaValue,
+ editorType,
+ }:
+ | {
+ schemaValue: z.infer;
+ editorType: "basic";
+ }
+ | {
+ schemaValue: object;
+ editorType: "advanced";
+ }) => {
+ setEditorType(editorType);
+
+ try {
+ if (Object.keys(schemaValue).length === 0) {
+ setProperties([]);
+ onChange(JSON.stringify(emptyBasicSchema, null, 2));
+ return;
+ }
+ } catch (error) {
+ console.error(error);
+ }
+
+ if (editorType === "basic") {
+ const props: Property[] = Object.entries(schemaValue.properties).map(
+ ([key, prop]) => ({
+ key,
+ type: prop.type,
+ description: prop.description || "",
+ required: schemaValue.required?.includes(key) || false,
+ }),
+ );
+ setProperties(props);
+ } else {
+ setAdvancedValue(JSON.stringify(schemaValue, null, 2));
+ }
+ },
+ [onChange],
+ );
+
+ useEffect(() => {
+ try {
+ const parsedValue = JSON.parse(value);
+
+ if (basicStructuredOutputSchema.safeParse(parsedValue).success) {
+ initializeEditor({ schemaValue: parsedValue, editorType: "basic" });
+ } else {
+ console.log(basicStructuredOutputSchema.safeParse(value).error);
+ initializeEditor({ schemaValue: parsedValue, editorType: "advanced" });
+ }
+ } catch (error) {
+ // Do nothing
+ }
+ }, [value, initializeEditor]);
+
+ const debouncedUpdateSchema = useCallback(
+ debounce((newProperties: Property[]) => {
+ if (newProperties.length === 0) {
+ onChange("");
+ return;
+ }
+ const schema = {
+ type: "object",
+ properties: Object.fromEntries(
+ newProperties.map(({ key, description }) => [
+ key,
+ {
+ type: "string",
+ description,
+ },
+ ]),
+ ),
+ required: newProperties.filter((p) => p.required).map((p) => p.key),
+ additionalProperties: false,
+ };
+ onChange(JSON.stringify(schema, null, 2));
+ }, 300),
+ [onChange],
+ );
+
+ const updateProperty = useCallback(
+ (index: number, field: keyof Property, value: string | boolean) => {
+ setProperties((prevProperties) => {
+ const updatedProperties = [...prevProperties];
+ updatedProperties[index] = {
+ ...updatedProperties[index],
+ [field]: value,
+ };
+ debouncedUpdateSchema(updatedProperties);
+ return updatedProperties;
+ });
+ },
+ [debouncedUpdateSchema],
+ );
+
+ const addProperty = () => {
+ setProperties([
+ ...properties,
+ {
+ key: "",
+ type: "string",
+ description: "",
+ required: false,
+ },
+ ]);
+ };
+
+ const handleEditorTypeChange = (newType: "basic" | "advanced") => {
+ setEditorType(newType);
+ if (newType === "basic") {
+ initializeEditor({
+ schemaValue: JSON.parse(value || JSON.stringify(emptyBasicSchema)),
+ editorType: "basic",
+ });
+ } else {
+ setAdvancedValue(value);
+ }
+ };
+
+ const handleAdvancedChange = (newValue: string) => {
+ setAdvancedValue(newValue);
+ onChange(newValue);
+ };
+
+ const removeProperty = (index: number) => {
+ setProperties((prevProperties) => {
+ const updatedProperties = prevProperties.filter((_, i) => i !== index);
+ debouncedUpdateSchema(updatedProperties);
+ return updatedProperties;
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+ Basic
+ Advanced
+
+
+ {editorType === "basic" && (
+
{
+ e.stopPropagation();
+ e.preventDefault();
+ addProperty();
+ }}
+ className="h-8 text-xs"
+ variant="outline"
+ size="sm"
+ >
+ Add Property
+
+ )}
+
+
+ {editorType === "advanced" ? (
+
handleAdvancedChange(e.target.value)}
+ className="flex-grow min-h-[540px] font-mono text-sm"
+ />
+ ) : (
+
+ {properties.map((prop, index) => (
+
+
+ Property
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ removeProperty(index);
+ }}
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 hover:bg-destructive/20"
+ >
+
+
+
+
+
updateProperty(index, "key", e.target.value)}
+ className={cn(
+ "flex-grow",
+ prop.key.trim() === "" &&
+ "border-destructive focus-visible:ring-destructive",
+ )}
+ placeholder="Enter property name"
+ />
+
+
+ updateProperty(index, "required", !!checked)
+ }
+ />
+
+ Required
+
+
+
+
Description
+
+ updateProperty(index, "description", e.target.value)
+ }
+ className={cn(
+ "mt-1",
+ prop.description.trim() === "" &&
+ "border-orange-500 focus-visible:ring-orange-500",
+ )}
+ placeholder="Enter property description"
+ rows={3}
+ />
+
+ ))}
+ {properties.length === 0 && (
+
+ No properties added. Click "Add Property" to start.
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/app/components/chat/template-mesage.tsx b/app/components/chat/template-mesage.tsx
new file mode 100644
index 00000000..3f417dbf
--- /dev/null
+++ b/app/components/chat/template-mesage.tsx
@@ -0,0 +1,139 @@
+import { formatRelative } from "date-fns";
+import { startCase } from "lodash";
+import { ChevronDown, Workflow, RefreshCw } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+import { ReadOnlyJSON } from "../read-only-json";
+import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "../ui/collapsible";
+import { Button } from "../ui/button";
+import toast from "react-hot-toast";
+import { MessageContainerProps } from "./workflow-event";
+import { client } from "../../client/client";
+import { useAuth } from "@clerk/nextjs";
+
+export function TemplateMessage({
+ createdAt,
+ displayableContext,
+ data,
+ clusterId,
+ id: messageId,
+ runId,
+}: MessageContainerProps & { runId: string }) {
+ let templateId;
+ let templateName;
+ if (displayableContext && "templateId" in displayableContext) {
+ templateId = displayableContext.templateId;
+ }
+
+ if (displayableContext && "templateName" in displayableContext) {
+ templateName = displayableContext.templateName;
+ }
+
+ const [isRetrying, setIsRetrying] = useState(false);
+ const { getToken } = useAuth();
+
+ const handleRetry = async () => {
+ if (
+ window.confirm(
+ "Are you sure you want to retry? This will delete the current result.",
+ )
+ ) {
+ setIsRetrying(true);
+ try {
+ const token = await getToken();
+ const result = await client.createRunRetry({
+ params: { clusterId, runId },
+ body: { messageId },
+ headers: { authorization: `Bearer ${token}` },
+ });
+
+ if (result.status === 204) {
+ toast.success("Retry initiated");
+ } else {
+ throw new Error("Unexpected response status");
+ }
+ } catch (error) {
+ console.error("Error retrying run:", error);
+ toast.error("Retry failed");
+ } finally {
+ setIsRetrying(false);
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+ {templateId && templateName ? (
+
+
+
+ Triggered by
+
+
{templateName}
+
+
+ ) : (
+
Prompt Template
+ )}
+
+ {formatRelative(createdAt, new Date())}
+
+
+
+
+ {isRetrying ? "Retrying..." : "Retry"}
+
+
+
+ {Object.entries({ ...data, ...displayableContext }).map(
+ ([key, value]) => (
+
+ {key === "message" ? (
+
+
+
+ {startCase(key)}
+
+
+
+
+
+ {value as string}
+
+
+
+ ) : (
+ <>
+
+ {startCase(key)}
+
+ {typeof value === "object" ? (
+
+ ) : (
+
+ {value as string}
+
+ )}
+ >
+ )}
+
+ ),
+ )}
+
+ );
+}
diff --git a/app/components/chat/template-search.tsx b/app/components/chat/template-search.tsx
new file mode 100644
index 00000000..967742f1
--- /dev/null
+++ b/app/components/chat/template-search.tsx
@@ -0,0 +1,111 @@
+import { client } from "@/client/client";
+import { useDebounce } from "@uidotdev/usehooks";
+import { useCallback, useEffect, useState } from "react";
+import { useAuth } from "@clerk/nextjs";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { ChevronRight } from "lucide-react"; // Change to ChevronRight
+import { contract } from "@/client/contract";
+import { ClientInferResponseBody } from "@ts-rest/core";
+
+export function TemplateSearch({
+ prompt,
+ onSelect,
+ clusterId,
+}: {
+ prompt: string;
+ onSelect: (
+ template: ClientInferResponseBody<
+ typeof contract.searchRunConfigs,
+ 200
+ >[number],
+ ) => void;
+ clusterId: string;
+}) {
+ const [results, setResults] = useState<
+ ClientInferResponseBody
+ >([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const { getToken } = useAuth();
+ const [isOpen, setIsOpen] = useState(true);
+
+ const debouncedPrompt = useDebounce(prompt, 500);
+
+ const searchPromptTemplates = useCallback(
+ async (searchPrompt: string) => {
+ try {
+ const response = await client.searchRunConfigs({
+ params: {
+ clusterId: clusterId,
+ },
+ query: {
+ search: searchPrompt,
+ },
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ });
+
+ if (response.status === 200) {
+ return response.body;
+ } else {
+ console.error(`Failed to search run configs: ${response.status}`);
+ return [];
+ }
+ } catch (error) {
+ console.error("An error occurred while searching run configs:", error);
+ return [];
+ }
+ },
+ [clusterId, getToken],
+ );
+
+ useEffect(() => {
+ const fetchResults = async () => {
+ setIsSearching(true);
+ const data = await searchPromptTemplates(debouncedPrompt);
+ setResults(data);
+ setIsSearching(false);
+ };
+
+ fetchResults();
+ }, [debouncedPrompt, searchPromptTemplates]);
+
+ return (
+
+
+
+
+
+ {isSearching
+ ? "Searching for run configs..."
+ : debouncedPrompt.length > 0
+ ? `${results.length} related prompt${results.length !== 1 ? "s" : ""} found`
+ : "Available Run Configs"}
+
+
+
+
+ {results.map((template, index) => (
+ onSelect(template)}
+ className={`inline-block px-3 py-1 mr-2 mb-2 font-medium text-gray-700 border border-gray-200 rounded-md hover:bg-gray-50 transition-colors hover:opacity-100`}
+ style={{
+ opacity: Math.min(template.similarity + 0.5, 1),
+ }}
+ >
+ {template.name}
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/chat/tool-input.tsx b/app/components/chat/tool-input.tsx
new file mode 100644
index 00000000..85165b5a
--- /dev/null
+++ b/app/components/chat/tool-input.tsx
@@ -0,0 +1,149 @@
+import React, { useState, useEffect, useCallback } from "react";
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "../ui/command";
+import { X, Check } from "lucide-react";
+import { Button } from "../ui/button";
+import { client } from "../../client/client";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { useParams } from "next/navigation";
+
+interface ToolInputProps {
+ value: string[];
+ onChange: (value: string[]) => void;
+}
+
+const standardLibrary = [
+ {
+ name: "inferable_inputRequest",
+ description: "Allow the agent to pause the run to request input",
+ },
+ {
+ name: "inferable_accessKnowledgeArtifacts",
+ description: "Allow the agent to search for knowledge artifacts",
+ },
+];
+
+export const ToolInput: React.FC = ({ value, onChange }) => {
+ const [open, setOpen] = useState(false);
+ const [availableFunctions, setAvailableFunctions] = useState<
+ Array<{ name: string; description?: string }>
+ >([]);
+ const { getToken } = useAuth();
+ const params = useParams<{ clusterId: string }>();
+
+ const fetchAvailableFunctions = useCallback(async () => {
+ if (!params?.clusterId) {
+ return;
+ }
+
+ const token = await getToken();
+ const response = await client.listServices({
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ params: {
+ clusterId: params.clusterId,
+ },
+ });
+
+ if (response.status === 200) {
+ const functions = response.body.flatMap(
+ (service) =>
+ service.functions?.map((func) => ({
+ name: `${service.name}_${func.name}`,
+ description: func.description,
+ })) || [],
+ );
+ setAvailableFunctions([...functions, ...standardLibrary]);
+ } else {
+ createErrorToast(response, "Failed to fetch available functions");
+ }
+ }, [params?.clusterId, getToken]);
+
+ useEffect(() => {
+ fetchAvailableFunctions();
+ }, [fetchAvailableFunctions]);
+
+ const handleSelect = (selectedTool: string) => {
+ if (!value.includes(selectedTool)) {
+ onChange([...value, selectedTool]);
+ } else {
+ onChange(value.filter((tool) => tool !== selectedTool));
+ }
+ };
+
+ const handleRemove = (toolToRemove: string) => {
+ onChange(value.filter((tool) => tool !== toolToRemove));
+ };
+
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ if (e.key === "t" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ setOpen((open) => !open);
+ }
+ };
+
+ document.addEventListener("keydown", down);
+ return () => document.removeEventListener("keydown", down);
+ }, []);
+
+ return (
+
+
setOpen(true)} size="sm" variant="outline">
+ Select Tools
+
+
+ {value.map((tool) => (
+
+ {tool}
+ handleRemove(tool)} className="ml-1">
+
+
+
+ ))}
+
+
+
+
+ No tools found.
+ {availableFunctions.length > 0 && (
+
+ {availableFunctions.map((func) => (
+ handleSelect(func.name)}
+ className="cursor-pointer text-sm flex items-center justify-between p-2"
+ >
+
+
{func.name}
+ {func.description && (
+
+ {func.description}
+
+ )}
+
+
+ {value.includes(func.name) && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+};
diff --git a/app/components/chat/typable.tsx b/app/components/chat/typable.tsx
new file mode 100644
index 00000000..38b6c3a0
--- /dev/null
+++ b/app/components/chat/typable.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { TypeAnimation } from "react-type-animation";
+
+function TypableInner({ text, createdAt }: { text: string; createdAt: Date }) {
+ if (new Date(createdAt).getTime() < Date.now() - 2000) {
+ return text;
+ }
+
+ return (
+ str.split(/(?= )/)} // 'Lorem ipsum dolor' -> ['Lorem', ' ipsum', ' dolor']
+ sequence={[text, 3000]}
+ speed={{ type: "keyStrokeDelayInMs", value: 30 }}
+ omitDeletionAnimation={true}
+ cursor={false}
+ repeat={0}
+ />
+ );
+}
+
+export const Typable = React.memo(TypableInner, (prev, next) => {
+ return prev.text === next.text;
+});
diff --git a/app/components/chat/workflow-event.tsx b/app/components/chat/workflow-event.tsx
new file mode 100644
index 00000000..75b18d5b
--- /dev/null
+++ b/app/components/chat/workflow-event.tsx
@@ -0,0 +1,68 @@
+import { contract } from "@/client/contract";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import React from "react";
+import { AiMessage } from "./ai-message";
+import { HumanMessage } from "./human-message";
+import { TemplateMessage } from "./template-mesage";
+import { WorkflowJob } from "@/lib/types";
+
+export type MessageContainerProps = {
+ id: string;
+ createdAt: Date;
+ data: ClientInferResponseBody<
+ typeof contract.listMessages,
+ 200
+ >[number]["data"];
+ displayableContext?: Record;
+ isEditable: boolean;
+ type: ClientInferResponseBody<
+ typeof contract.listMessages,
+ 200
+ >[number]["type"];
+ showMeta: boolean;
+ clusterId: string;
+ jobs: WorkflowJob[];
+ pending?: boolean;
+ runId: string;
+ onPreMutation: (isHovering: boolean) => void;
+};
+
+const container: {
+ [key: string]: (props: MessageContainerProps) => React.ReactNode;
+} = {
+ human: HumanMessage,
+ agent: AiMessage,
+ template: TemplateMessage,
+ default: ({ data }) => {JSON.stringify(data)}
,
+};
+
+function RunEvent(
+ props: Omit & {
+ onPreMutation: (ulid: string) => void;
+ },
+) {
+ const Container = container[props.type] || container.default;
+
+ return (
+
+ inside
+ ? props.onPreMutation(props.id)
+ : props.onPreMutation("7ZZZZZZZZZZZZZZZZZZZZZZZZZ")
+ }
+ createdAt={props.createdAt}
+ pending={props.pending ?? false}
+ />
+ );
+}
+
+export default RunEvent;
diff --git a/app/components/circles.tsx b/app/components/circles.tsx
new file mode 100644
index 00000000..a0f7ce18
--- /dev/null
+++ b/app/components/circles.tsx
@@ -0,0 +1,59 @@
+export function LiveGreenCircle() {
+ return (
+
+ );
+}
+
+export function LiveAmberCircle() {
+ return (
+
+ );
+}
+
+export function DeadRedCircle() {
+ return
;
+}
+
+export function DeadGreenCircle() {
+ return
;
+}
+
+export function DeadGrayCircle() {
+ return
;
+}
+
+export function LiveGrayCircle() {
+ return
;
+}
+
+export function SmallLiveGreenCircle() {
+ return (
+
+ );
+}
+
+export function SmallLiveAmberCircle() {
+ return (
+
+ );
+}
+
+export function SmallDeadRedCircle() {
+ return
;
+}
+
+export function SmallDeadGreenCircle() {
+ return
;
+}
+
+export function SmallDeadGrayCircle() {
+ return
;
+}
+
+export function SmallLiveGrayCircle() {
+ return
;
+}
+
+export function SmallLiveBlueCircle() {
+ return
;
+}
diff --git a/app/components/cluster-card.tsx b/app/components/cluster-card.tsx
new file mode 100644
index 00000000..a2506417
--- /dev/null
+++ b/app/components/cluster-card.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Trash2Icon } from "lucide-react";
+import { useCallback, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@clerk/nextjs";
+import { client } from "@/client/client";
+import { cn, createErrorToast } from "@/lib/utils";
+import toast from "react-hot-toast";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Input } from "@/components/ui/input";
+
+interface Cluster {
+ id: string;
+ name: string;
+ description: string | null;
+}
+
+interface ClusterCardProps {
+ cluster: Cluster;
+}
+
+export function ClusterCard({ cluster }: ClusterCardProps) {
+ const router = useRouter();
+ const { getToken, orgId } = useAuth();
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [deleteConfirmation, setDeleteConfirmation] = useState("");
+
+ const handleClusterClick = useCallback(() => {
+ if (!orgId) return;
+
+ router.push(`/clusters/${cluster.id}`);
+ }, [router, orgId, cluster]);
+
+ const handleDeleteCluster = async () => {
+ try {
+ const result = await client.deleteCluster({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId: cluster.id,
+ },
+ });
+
+ if (result.status === 204) {
+ toast.success("Cluster deleted successfully");
+ setIsDeleteModalOpen(false);
+ setDeleteConfirmation("");
+ router.refresh();
+ } else {
+ createErrorToast(result, "Failed to delete cluster");
+ }
+ } catch (error) {
+ createErrorToast(error, "Error deleting cluster");
+ }
+ };
+
+ return (
+ <>
+
+
+ {cluster.name}
+ {cluster.id}
+
+
+ {cluster.description || " "}
+
+
+ View
+ setIsDeleteModalOpen(true)}
+ >
+
+
+
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete the
+ cluster "{cluster.name}".
+
+
+
+
+ Type "delete {cluster.name}" to confirm:
+
+
setDeleteConfirmation(e.target.value)}
+ placeholder="delete cluster"
+ />
+
+
+ setDeleteConfirmation("")}>
+ Cancel
+
+
+ Delete
+
+
+
+
+ >
+ );
+}
diff --git a/app/components/cluster-details.tsx b/app/components/cluster-details.tsx
new file mode 100644
index 00000000..4bce24d9
--- /dev/null
+++ b/app/components/cluster-details.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { useCallback, useEffect, useState } from "react";
+import {
+ SmallDeadRedCircle,
+ SmallLiveAmberCircle,
+ SmallLiveGreenCircle,
+} from "./circles";
+import { ClusterHealthPane } from "./cluster-health-plane";
+import { ServiceDetailsPane } from "./service-details-pane"; // Add this import
+import { Button } from "./ui/button";
+
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import { useAuth } from "@clerk/nextjs";
+import { ClientInferResponses } from "@ts-rest/core";
+import {
+ ServerConnectionPane,
+ ServerConnectionStatus,
+} from "./server-connection-pane";
+
+export function ClusterDetails({
+ clusterId,
+}: {
+ clusterId: string;
+}): JSX.Element {
+ const { getToken } = useAuth();
+
+ const [clusterDetails, setClusterDetails] = useState<
+ ClientInferResponses["body"] | null
+ >(null);
+
+ const fetchClusterDetails = useCallback(async () => {
+ if (!clusterId) {
+ return;
+ }
+
+ const result = await client.getCluster({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId,
+ },
+ });
+
+ if (result.status === 200) {
+ setClusterDetails(result.body);
+ } else {
+ ServerConnectionStatus.addEvent({
+ type: "getCluster",
+ success: false,
+ });
+ }
+ }, [clusterId, getToken]);
+
+ useEffect(() => {
+ fetchClusterDetails();
+ const interval = setInterval(fetchClusterDetails, 5000);
+ return () => clearInterval(interval);
+ }, [fetchClusterDetails]);
+
+ const isLive = clusterDetails?.lastPingAt
+ ? Date.now() - new Date(clusterDetails.lastPingAt).getTime() < 1000 * 60
+ : false;
+
+ const circle = !clusterDetails ? (
+
+ ) : isLive ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
+
+ Machines {circle}
+
+
+
+
+ Cluster Health
+
+
+
+
+
+
+
+
+ Services
+
+
+
+
+ Service Details
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/cluster-health-plane.tsx b/app/components/cluster-health-plane.tsx
new file mode 100644
index 00000000..e882cbed
--- /dev/null
+++ b/app/components/cluster-health-plane.tsx
@@ -0,0 +1,133 @@
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { useAuth } from "@clerk/nextjs";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { formatRelative } from "date-fns";
+import { useCallback, useEffect, useState } from "react";
+import { DeadGrayCircle, DeadRedCircle, LiveGreenCircle } from "./circles";
+import ErrorDisplay from "./error-display";
+import { EventsOverlayButton } from "./events-overlay";
+import { ClusterDetails } from "@/lib/types";
+
+function MachinesOverview({ clusterId }: { clusterId: string }) {
+ const [machines, setMachines] = useState<
+ ClientInferResponseBody
+ >([]);
+ const [liveMachineCount, setLiveMachineCount] = useState(0);
+ const { getToken } = useAuth();
+ const [error, setError] = useState(null);
+
+ const getClusterMachines = useCallback(async () => {
+ const machinesResponse = await client.listMachines({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId,
+ },
+ });
+
+ if (machinesResponse.status === 200) {
+ setMachines(machinesResponse.body);
+ setLiveMachineCount(
+ machinesResponse.body.filter(
+ (m) => Date.now() - new Date(m.lastPingAt!).getTime() < 1000 * 60,
+ ).length,
+ );
+ } else {
+ setError(machinesResponse);
+ }
+ }, [clusterId, getToken]);
+
+ useEffect(() => {
+ getClusterMachines();
+
+ const interval = setInterval(getClusterMachines, 1000 * 10);
+ return () => clearInterval(interval);
+ }, [getClusterMachines]);
+
+ if (error) {
+ return ;
+ }
+
+ return (
+
+
Machines
+
+ You have {liveMachineCount} machine
+ {liveMachineCount === 1 ? "" : "s"} connected.
+
+
+
+
+ Status
+ ID
+ IP
+ Last Heartbeat
+
+
+
+ {machines && machines.length > 0 ? (
+ machines
+ .sort(
+ (a, b) =>
+ new Date(b.lastPingAt!).getTime() -
+ new Date(a.lastPingAt!).getTime(),
+ )
+ .map((m) => (
+
+
+ {Date.now() - new Date(m.lastPingAt!).getTime() <
+ 1000 * 60 ? (
+
+ ) : (
+
+ )}
+
+
+ {m.id}
+
+
+ {m.ip}
+
+ {formatRelative(m.lastPingAt!, new Date())}
+
+
+ ))
+ ) : (
+
+
+
+ Your machines are offline.
+
+
+ )}
+
+
+
+ );
+}
+
+export function ClusterHealthPane({
+ clusterDetails,
+}: {
+ clusterDetails: ClusterDetails | null;
+}): JSX.Element {
+ return (
+
+
+ {clusterDetails?.id &&
}
+
+ );
+}
diff --git a/app/components/cluster-list.tsx b/app/components/cluster-list.tsx
new file mode 100644
index 00000000..c8de89ad
--- /dev/null
+++ b/app/components/cluster-list.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useLocalStorage } from "@uidotdev/usehooks";
+import { useMemo } from "react";
+import { ClusterCard } from "./cluster-card";
+
+interface Cluster {
+ id: string;
+ name: string;
+ description: string | null;
+}
+
+interface ClusterListProps {
+ clusters: Cluster[];
+}
+
+export function ClusterList({ clusters }: ClusterListProps) {
+ const [recentClusters] = useLocalStorage<
+ Array<{ id: string; name: string; orgId: string }>
+ >("recentClusters", []);
+
+ const sortedClusters = useMemo(() => {
+ const recentClusterIds = recentClusters.map((rc) => rc.id);
+ return clusters.sort((a, b) => {
+ const aIsRecent = recentClusterIds.includes(a.id);
+ const bIsRecent = recentClusterIds.includes(b.id);
+ if (aIsRecent && !bIsRecent) return -1;
+ if (!aIsRecent && bIsRecent) return 1;
+ return 0;
+ });
+ }, [clusters, recentClusters]);
+
+ return (
+
+ {sortedClusters.map((cluster) => (
+
+ ))}
+
+ );
+}
diff --git a/app/components/copy-button.tsx b/app/components/copy-button.tsx
new file mode 100644
index 00000000..634de6db
--- /dev/null
+++ b/app/components/copy-button.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Copy } from "lucide-react";
+import { useState } from "react";
+
+export function CopyButton({ text }: { text: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/app/components/create-api-key.tsx b/app/components/create-api-key.tsx
new file mode 100644
index 00000000..3a8dd658
--- /dev/null
+++ b/app/components/create-api-key.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useState } from "react";
+import { useAuth } from "@clerk/nextjs";
+import { client } from "@/client/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { createErrorToast } from "@/lib/utils";
+import toast from "react-hot-toast";
+import { PlusIcon } from "lucide-react";
+
+export function CreateApiKey({
+ clusterId,
+ onCreated,
+}: {
+ clusterId: string;
+ onCreated: () => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [key, setKey] = useState(null);
+ const { getToken } = useAuth();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ const loading = toast.loading("Creating API key...");
+
+ try {
+ const result = await client.createApiKey({
+ headers: { authorization: `Bearer ${await getToken()}` },
+ params: { clusterId },
+ body: { name },
+ });
+
+ toast.dismiss(loading);
+
+ if (result.status === 200) {
+ toast.success("API key created successfully");
+ setKey(result.body.key);
+ onCreated();
+ } else {
+ createErrorToast(result, "Failed to create API key");
+ }
+ } catch (err) {
+ toast.dismiss(loading);
+ createErrorToast(err, "Failed to create API key");
+ }
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ setName("");
+ setKey(null);
+ };
+
+ return (
+
+
+ Create API Key
+
+
+
+
+ {key ? "Save your API Key" : "Create new API Key"}
+
+
+
+ {key ? (
+
+
+ Make sure to copy your API key now. You won't be able to see
+ it again!
+
+
+ {key}
+
+
+ Close
+
+
+ ) : (
+
+
+ Name
+ setName(e.target.value)}
+ placeholder="Enter a name for your API key"
+ required
+ />
+
+
+
+ Create
+
+
+ )}
+
+
+ );
+}
diff --git a/app/components/create-cluster-button.tsx b/app/components/create-cluster-button.tsx
new file mode 100644
index 00000000..43f5f7e2
--- /dev/null
+++ b/app/components/create-cluster-button.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { client } from "@/client/client";
+import { Button } from "@/components/ui/button";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { PlusIcon } from "lucide-react";
+import { toast } from "react-hot-toast";
+import { useRouter } from "next/navigation";
+
+export const CreateClusterButton = () => {
+ const { getToken } = useAuth();
+ const router = useRouter();
+ return (
+ {
+ const toastId = toast.loading("Creating cluster...");
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ await client
+ .createCluster({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ body: {
+ description: "Cluster created from playground",
+ },
+ })
+ .then((result) => {
+ toast.dismiss(toastId);
+ if (result.status === 204) {
+ toast.success("Successfully created a cluster.");
+ router.refresh();
+ } else {
+ toast.error("Failed to create a cluster.");
+ }
+ })
+ .catch((error) => {
+ toast.dismiss(toastId);
+ createErrorToast(error, "Failed to create a cluster.");
+ });
+ }}
+ >
+
+ Create a Cluster
+
+ );
+};
diff --git a/app/components/crisp-chat.tsx b/app/components/crisp-chat.tsx
new file mode 100644
index 00000000..572d8ed4
--- /dev/null
+++ b/app/components/crisp-chat.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { useEffect } from "react";
+import { Crisp } from "crisp-sdk-web";
+
+const CrispChat = () => {
+ useEffect(() => {
+ Crisp.configure("9ea8a5a6-1032-49eb-aee0-81af37053f65");
+ }, []);
+
+ return null;
+};
+
+export default CrispChat;
diff --git a/app/components/debug-event.tsx b/app/components/debug-event.tsx
new file mode 100644
index 00000000..1ea512b1
--- /dev/null
+++ b/app/components/debug-event.tsx
@@ -0,0 +1,114 @@
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { useAuth } from "@clerk/nextjs";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { startCase } from "lodash";
+import { Info } from "lucide-react";
+import { useState } from "react";
+import { ReadOnlyJSON } from "./read-only-json";
+
+const sanitizedKey: { [key: string]: string } = {
+ targetFn: "function",
+};
+
+export function DebugEvent({
+ event,
+ clusterId,
+}: {
+ event: ClientInferResponseBody<
+ typeof contract.getRunTimeline,
+ 200
+ >["activity"][number];
+ clusterId: string;
+}) {
+ const [eventMeta, setEventMeta] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const { getToken } = useAuth();
+
+ const fetchEventMeta = async () => {
+ if (eventMeta) return; // Don't fetch if we already have the data
+ setIsLoading(true);
+ try {
+ const response = await client.getEventMeta({
+ params: { clusterId, eventId: event.id },
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ });
+ if (response.status === 200) {
+ setEventMeta(response.body.meta);
+ } else {
+ console.error("Failed to fetch event metadata");
+ }
+ } catch (error) {
+ console.error("Error fetching event metadata:", error);
+ } finally {
+ setIsLoading(false);
+ ``;
+ }
+ };
+
+ return (
+
+
+
+
+ {startCase(event.type)}
+
+
+
+
+
+
+
+ Event Metadata
+
+
+ {isLoading ? (
+
Loading metadata...
+ ) : eventMeta ? (
+
+ ) : (
+
No metadata available
+ )}
+
+
+
+
+
+ {Object.entries(event)
+ .filter(
+ ([key, value]) =>
+ key !== "id" && key !== "createdAt" && key !== "type" && !!value,
+ )
+ .map(([key, value]) => (
+
+
+ {sanitizedKey[key] ?? startCase(key)}:
+
+
+ {value instanceof Date ? value.toISOString() : String(value)}
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/error-display.tsx b/app/components/error-display.tsx
new file mode 100644
index 00000000..0b8c4a6b
--- /dev/null
+++ b/app/components/error-display.tsx
@@ -0,0 +1,90 @@
+import {
+ AlertTriangle,
+ Bot,
+ Rocket,
+ Key,
+ Pill,
+ Zap,
+ Binary,
+} from "lucide-react";
+
+interface ErrorDisplayProps {
+ status?: number;
+ error?: any;
+}
+
+const getHumorousMessage = (
+ status?: number,
+): { message: string; icon: JSX.Element } => {
+ switch (status) {
+ case 400:
+ return {
+ message:
+ "Bad request - even WALL-E could organize garbage better than this!",
+ icon: ,
+ };
+ case 401:
+ return {
+ message:
+ "Access denied - 'I'm sorry Dave, I'm afraid I can't do that.'",
+ icon: ,
+ };
+ case 403:
+ return {
+ message: "Forbidden - 'I'm sorry Dave, I'm afraid I can't do that.'",
+ icon: ,
+ };
+ case 404:
+ return {
+ message:
+ "Page not found - It's probably hiding in the Matrix. Take the red pill?",
+ icon: ,
+ };
+ case 500:
+ return {
+ message: "Server error - or Skynet is acting up again! Devs are on it.",
+ icon: ,
+ };
+ case 503:
+ return {
+ message:
+ "Service unavailable - Our servers are on strike! Devs are on it.",
+ icon: ,
+ };
+ default:
+ return {
+ message:
+ "Error detected - And we're not quite sure why. Devs are on it.",
+ icon: ,
+ };
+ }
+};
+
+export default function ErrorDisplay({ status, error }: ErrorDisplayProps) {
+ const errorContent = getHumorousMessage(status);
+
+ const message = error?.body?.error?.message || error?.error?.message;
+
+ return (
+
+
+
+
+
+
+ We hit an error {status && `(${status})`}
+
+
+
{message || errorContent.message}
+
+
+
+
+
+ );
+}
diff --git a/app/components/events-overlay.tsx b/app/components/events-overlay.tsx
new file mode 100644
index 00000000..7d65582c
--- /dev/null
+++ b/app/components/events-overlay.tsx
@@ -0,0 +1,541 @@
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { useAuth } from "@clerk/nextjs";
+import type { ClientInferResponseBody } from "@ts-rest/core";
+import { format } from "date-fns";
+import { snakeCase, startCase } from "lodash";
+import {
+ AlertCircle,
+ AlertTriangle,
+ BarChartIcon,
+ Clock,
+ Info,
+ Loader2,
+} from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
+import ErrorDisplay from "./error-display";
+
+type Event = ClientInferResponseBody[number];
+
+type FilterKey =
+ | "type"
+ | "service"
+ | "function"
+ | "jobId"
+ | "workflowId"
+ | "machineId";
+
+interface Filter {
+ key: FilterKey;
+ value: string;
+}
+
+interface EventsOverlayProps {
+ clusterId: string;
+ query?: Partial>;
+ refreshInterval?: number;
+}
+
+interface EventParam {
+ label: string;
+ value: string;
+}
+
+const formatEventParams = (event: Event): EventParam[] => {
+ const params: EventParam[] = [];
+
+ if (event.service) params.push({ label: "Service", value: event.service });
+ if (event.targetFn) params.push({ label: "Function", value: event.targetFn });
+ if (event.machineId)
+ params.push({ label: "Machine", value: event.machineId });
+ if (event.resultType)
+ params.push({ label: "Result", value: event.resultType });
+ if (event.status) params.push({ label: "Status", value: event.status });
+ if (event.workflowId)
+ params.push({ label: "Workflow", value: event.workflowId });
+
+ return params;
+};
+
+const typeToText: { [key: string]: string } = {
+ machinePing: `Machine pinged the control plane.`,
+ machineStalled: `Machine marked as stalled.`,
+ jobCreated: "Job was created.",
+ jobStatusRequest: `Caller asked for the status of the job.`,
+ jobReceived: `Function was received by the machine for execution.`,
+ jobResulted: `Function execution concluded.`,
+ jobStalled: `Function execution did not complete within the expected time frame. The function is marked as stalled.`,
+ jobRecovered: `Function execution was recovered after being marked as stalled.`,
+ jobStalledTooManyTimes: `Function execution did not complete within the expected time frame too many times. The execution has resulted in a failure.`,
+ agentMessage: `Agent message produced.`,
+ agentEnd: `Agent workflow concluded.`,
+ jobAcknowledged: `Job was acknowledged by the machine.`,
+ agentTool: `Agent is invoking a tool.`,
+ humanMessage: `Human sent a message.`,
+ machineRegistered: `Machine registered with the control plane.`,
+ agentToolError: `Invoked tool produced an error.`,
+ modelInvocation: `A call was made to the model.`,
+};
+
+type FilterableEventKeys = {
+ [K in FilterKey]: keyof Event;
+};
+
+const filterKeyToEventKey: FilterableEventKeys = {
+ type: "type",
+ service: "service",
+ function: "targetFn",
+ jobId: "jobId",
+ workflowId: "workflowId",
+ machineId: "machineId",
+};
+
+const chartConfig: ChartConfig = {
+ machinePing: {
+ label: "Machine Ping",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ machineStalled: {
+ label: "Machine Stalled",
+ color: "hsl(0, 100%, 70%)", // Red
+ },
+ jobCreated: {
+ label: "Job Created",
+ color: "hsl(45, 100%, 70%)", // Yellow
+ },
+ jobStatusRequest: {
+ label: "Job Status Request",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ jobReceived: {
+ label: "Job Received",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ jobResulted: {
+ label: "Job Resulted",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ jobStalled: {
+ label: "Job Stalled",
+ color: "hsl(0, 100%, 70%)", // Red
+ },
+ jobRecovered: {
+ label: "Job Recovered",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ jobStalledTooManyTimes: {
+ label: "Job Stalled Too Many Times",
+ color: "hsl(0, 100%, 70%)", // Red
+ },
+ agentMessage: {
+ label: "Agent Message",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ agentEnd: {
+ label: "Agent End",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ jobAcknowledged: {
+ label: "Job Acknowledged",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ agentTool: {
+ label: "Agent Tool",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ humanMessage: {
+ label: "Human Message",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ machineRegistered: {
+ label: "Machine Registered",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+ agentToolError: {
+ label: "Agent Tool Error",
+ color: "hsl(0, 100%, 70%)", // Red
+ },
+ modelInvocation: {
+ label: "Model Invocation",
+ color: "hsl(210, 100%, 70%)", // Blue
+ },
+} satisfies ChartConfig;
+
+const getEventCountsByTime = (events: Event[]) => {
+ if (events.length === 0) return [];
+
+ const earliestEventTime = new Date(
+ Math.min(...events.map((event) => new Date(event.createdAt).getTime())),
+ );
+
+ const timeNow = new Date();
+
+ const differenceInMs = timeNow.getTime() - earliestEventTime.getTime();
+
+ const bucketCount = 20;
+
+ const bucketStartTimes = Array.from({ length: bucketCount }, (_, i) => {
+ const time = new Date(earliestEventTime);
+ time.setMilliseconds(
+ time.getMilliseconds() + i * (differenceInMs / bucketCount),
+ );
+ return time;
+ });
+
+ // Format data for chart
+ return bucketStartTimes.map((t, i) => {
+ const eventsInBucket = events.filter((event) => {
+ const eventTime = new Date(event.createdAt);
+ return eventTime >= t && eventTime < bucketStartTimes[i + 1];
+ });
+ return {
+ date: t.toISOString(),
+ ...eventsInBucket.reduce(
+ (acc, event) => {
+ acc[event.type] = (acc[event.type] || 0) + 1;
+ return acc;
+ },
+ {} as Record,
+ ),
+ };
+ });
+};
+
+export function EventsOverlayButton({
+ clusterId,
+ query,
+ text = "",
+}: {
+ clusterId: string;
+ query?: Partial>;
+ text?: string;
+}) {
+ return (
+
+
+
+ {text}
+
+
+
+
+
+
+ );
+}
+
+function EventsOverlay({
+ clusterId,
+ query = {},
+ refreshInterval = 10000,
+}: EventsOverlayProps) {
+ const [events, setEvents] = useState([]);
+ const [filters, setFilters] = useState(
+ Object.entries(query).map(([key, value]) => ({
+ key: key as FilterKey,
+ value: value as string,
+ })),
+ );
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { getToken } = useAuth();
+ const [searchTerm, setSearchTerm] = useState("");
+
+ const fetchEvents = useCallback(
+ async (filters: Filter[] = []) => {
+ try {
+ const response = await client.listEvents({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId,
+ },
+ query: filters.reduce(
+ (acc, filter) => {
+ acc[filter.key] = filter.value;
+ return acc;
+ },
+ {} as Partial>,
+ ),
+ });
+
+ if (response.status === 200) {
+ setEvents(response.body);
+ } else {
+ setError(response);
+ }
+ } catch (err) {
+ setError(err);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [clusterId, getToken],
+ );
+
+ useEffect(() => {
+ console.log("fetching events", filters);
+
+ fetchEvents(filters);
+ const interval = setInterval(() => fetchEvents(filters), refreshInterval);
+ return () => clearInterval(interval);
+ }, [fetchEvents, refreshInterval, filters]);
+
+ const addFilter = (event: Event) => {
+ const newFilters: Filter[] = [];
+
+ if (event.type) newFilters.push({ key: "type", value: event.type });
+ if (event.service)
+ newFilters.push({ key: "service", value: event.service });
+ if (event.targetFn)
+ newFilters.push({ key: "function", value: event.targetFn });
+ if (event.machineId)
+ newFilters.push({ key: "machineId", value: event.machineId });
+ if (event.workflowId)
+ newFilters.push({ key: "workflowId", value: event.workflowId });
+
+ setFilters((prev) => {
+ const updated = [...prev];
+ newFilters.forEach((newFilter) => {
+ const existingIndex = updated.findIndex(
+ (f) => f.key === newFilter.key && f.value === newFilter.value,
+ );
+ if (existingIndex === -1) {
+ updated.push(newFilter);
+ }
+ });
+ return updated;
+ });
+ };
+
+ const removeFilter = (filterToRemove: Filter) => {
+ setFilters((prev) =>
+ prev.filter(
+ (f) =>
+ !(f.key === filterToRemove.key && f.value === filterToRemove.value),
+ ),
+ );
+ };
+
+ const getFilteredEvents = useCallback(() => {
+ return events.filter((event) => {
+ const matchesFilters = filters.every((filter) => {
+ const eventKey = filterKeyToEventKey[filter.key];
+ const eventValue = event[eventKey];
+ return eventValue === filter.value;
+ });
+
+ if (!matchesFilters) return false;
+
+ if (!searchTerm) return true;
+
+ const searchLower = searchTerm.toLowerCase();
+
+ return (
+ Object.values(filterKeyToEventKey).some((key) => {
+ const value = event[key];
+ return value?.toString().toLowerCase().includes(searchLower);
+ }) || typeToText[event.type]?.toLowerCase().includes(searchLower)
+ );
+ });
+ }, [events, filters, searchTerm]);
+
+ if (error) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ Event Stream{" "}
+ {loading && }
+
+
+
+ {filters.map((filter, index) => (
+
+
+ {startCase(filter.key)}:
+
+ {filter.value}
+ removeFilter(filter)}
+ className="ml-1 hover:text-destructive"
+ >
+ ×
+
+
+ ))}
+ {filters.length === 0 && !searchTerm && (
+
+ Showing events for the whole cluster. Click on event parameters
+ to add filters or use the search bar.
+
+ )}
+
+
+
+
+
+
+ Event Frequency
+
+
+
+
+
+ {
+ const date = new Date(value);
+ return date.toLocaleDateString("en-US", {
+ day: "numeric",
+ month: "short",
+ minute: "2-digit",
+ hour: "2-digit",
+ });
+ }}
+ />
+
+ {
+ return new Date(value).toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ }}
+ />
+ }
+ />
+ {Object.entries(chartConfig).map(([key, config]) => (
+
+ ))}
+
+
+
+
+
+
+ {loading ? (
+
+ Loading events...
+
+ ) : (
+
+ {getFilteredEvents().map((event) => (
+
+
+
+
+ {format(new Date(event.createdAt), "MMM dd, yyyy")}
+
+ {format(new Date(event.createdAt), "HH:mm:ss")}
+
+
+
+
+
+
{
+ e.stopPropagation();
+ addFilter(event);
+ }}
+ >
+ {snakeCase(event.type).toUpperCase()}
+
+
+ {typeToText[event.type]}
+
+
+
+ {formatEventParams(event).map((param, index) => (
+ {
+ e.stopPropagation();
+ addFilter(event);
+ }}
+ >
+
+ {param.label}:
+
+ {param.value}
+
+ ))}
+
+
+
+
+
+ ))}
+
+ )}
+
+ >
+ );
+}
diff --git a/app/components/fetch-rerender-error.example.md b/app/components/fetch-rerender-error.example.md
new file mode 100644
index 00000000..bbda24c7
--- /dev/null
+++ b/app/components/fetch-rerender-error.example.md
@@ -0,0 +1,49 @@
+// the contract is availble in @/client/contract
+import { client } from "@/client/client";
+
+function MachinesOverview({ clusterId }: { clusterId: string }) {
+const [machines, setMachines] = useState<
+ClientInferResponseBody
+
+> ([]);
+> const [liveMachineCount, setLiveMachineCount] = useState(0);
+> const { getToken } = useAuth();
+> const [error, setError] = useState(null);
+
+const getClusterMachines = useCallback(async () => {
+const machinesResponse = await client.listMachines({
+headers: {
+authorization: `Bearer ${await getToken()}`,
+},
+params: {
+clusterId,
+},
+});
+
+ if (machinesResponse.status === 200) {
+ setMachines(machinesResponse.body);
+ setLiveMachineCount(
+ machinesResponse.body.filter(
+ (m) => Date.now() - new Date(m.lastPingAt!).getTime() < 1000 * 60,
+ ).length,
+ );
+ } else {
+ setError(machinesResponse);
+ }
+
+}, [clusterId, getToken]);
+
+useEffect(() => {
+getClusterMachines();
+
+ const interval = setInterval(getClusterMachines, 1000 * 10);
+ return () => clearInterval(interval);
+
+}, [getClusterMachines]);
+
+if (error) {
+return ;
+}
+
+// the rest
+}
diff --git a/app/components/header.tsx b/app/components/header.tsx
new file mode 100644
index 00000000..1a63a1c5
--- /dev/null
+++ b/app/components/header.tsx
@@ -0,0 +1,38 @@
+import { OrganizationSwitcher, UserButton } from "@clerk/nextjs";
+import Image from "next/image";
+import logo from "./logo.png";
+
+export function Header() {
+ return (
+
+ );
+}
diff --git a/app/components/instructions-editor.css b/app/components/instructions-editor.css
new file mode 100644
index 00000000..a42429a1
--- /dev/null
+++ b/app/components/instructions-editor.css
@@ -0,0 +1,111 @@
+:root {
+ --purple-light: #e6e6fa;
+ --purple: #800080;
+ --white: #ffffff;
+ --gray-1: #d3d3d3;
+ --gray-2: #e0e0e0;
+ --gray-3: #f0f0f0;
+ --shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.tiptap {
+ padding: 1rem;
+ margin: 0.25rem;
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+
+ :first-child {
+ margin-top: 0;
+ }
+
+ .mention {
+ background-color: var(--purple-light);
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ color: var(--purple);
+ padding: 0.1rem 0.3rem;
+ }
+
+ p {
+ padding: 0.5rem;
+ }
+
+ p:last-child {
+ padding-bottom: 0;
+ }
+
+ p:first-child {
+ padding-top: 0;
+ }
+
+ strong {
+ font-weight: bold;
+ }
+
+ ul {
+ padding-left: 2rem;
+ list-style-type: disc;
+ }
+
+ li {
+ margin-bottom: 0.25rem;
+ }
+}
+
+.dropdown-menu {
+ background: var(--white);
+ border: 1px solid var(--gray-1);
+ border-radius: 0.7rem;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ overflow: auto;
+ padding: 0.4rem;
+ position: relative;
+ font-size: small;
+
+ button {
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.4rem;
+ background-color: transparent;
+ display: flex;
+ gap: 0.25rem;
+ text-align: left;
+ width: 100%;
+
+ &:hover,
+ &:hover.is-selected {
+ background-color: var(--gray-3);
+ }
+
+ &.is-selected {
+ background-color: var(--gray-2);
+ }
+ }
+}
+
+.rich-text-editor {
+ display: flex;
+ flex-direction: column;
+}
+
+.menu-bar {
+ display: flex;
+ padding: 0.5rem;
+}
+
+.menu-bar button {
+ margin-right: 0.5rem;
+ padding: 0.25rem 0.5rem;
+ background-color: var(--gray-2);
+ border: none;
+ border-radius: 0.25rem;
+ cursor: pointer;
+}
+
+.menu-bar button.is-active {
+ background-color: var(--purple-light);
+ color: var(--purple);
+}
diff --git a/app/components/instructions-editor.tsx b/app/components/instructions-editor.tsx
new file mode 100644
index 00000000..eb4878ab
--- /dev/null
+++ b/app/components/instructions-editor.tsx
@@ -0,0 +1,347 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { client } from "@/client/client";
+import "./instructions-editor.css";
+
+import Document from "@tiptap/extension-document";
+import Mention, { MentionOptions } from "@tiptap/extension-mention";
+import Paragraph from "@tiptap/extension-paragraph";
+import Text from "@tiptap/extension-text";
+import { EditorContent, ReactRenderer, useEditor } from "@tiptap/react";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import React, {
+ KeyboardEvent,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useState,
+} from "react";
+import tippy from "tippy.js";
+import { contract } from "@/client/contract";
+import { useAuth } from "@clerk/nextjs";
+import { cn, createErrorToast } from "@/lib/utils";
+import Bold from "@tiptap/extension-bold";
+import BulletList from "@tiptap/extension-bullet-list";
+import ListItem from "@tiptap/extension-list-item";
+import { Button } from "./ui/button";
+import { BoldIcon, ListIcon } from "lucide-react";
+
+type ServiceFunction = {
+ service: string;
+ function: string;
+ description: string;
+};
+
+interface MentionListProps {
+ items: ServiceFunction[];
+ command: (props: { id: string }) => void;
+}
+
+interface MentionListRef {
+ onKeyDown: (props: { event: KeyboardEvent }) => boolean;
+}
+
+const MentionList = React.forwardRef(
+ function MentionList(props, ref) {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const selectItem = (index: number) => {
+ const item = props.items[index];
+
+ if (item) {
+ props.command({ id: `${item.service}.${item.function}` });
+ }
+ };
+
+ const upHandler = () => {
+ setSelectedIndex(
+ (selectedIndex + props.items.length - 1) % props.items.length,
+ );
+ };
+
+ const downHandler = () => {
+ setSelectedIndex((selectedIndex + 1) % props.items.length);
+ };
+
+ const enterHandler = () => {
+ selectItem(selectedIndex);
+ };
+
+ useEffect(() => setSelectedIndex(0), [props.items]);
+
+ useImperativeHandle(ref, () => ({
+ onKeyDown: ({ event }: { event: KeyboardEvent }) => {
+ if (event.key === "ArrowUp") {
+ upHandler();
+ return true;
+ }
+
+ if (event.key === "ArrowDown") {
+ downHandler();
+ return true;
+ }
+
+ if (event.key === "Enter") {
+ enterHandler();
+ return true;
+ }
+
+ return false;
+ },
+ }));
+
+ return (
+
+ {props.items.length ? (
+ props.items.map((item, index) => (
+
selectItem(index)}
+ >
+
+ {item.service}.{item.function}
+
+ {item.description}
+
+ ))
+ ) : (
+
No result
+ )}
+
+ );
+ },
+);
+
+const suggestion = (
+ services: Array<{ service: string; function: string; description: string }>,
+): MentionOptions["suggestion"] => ({
+ items: ({ query }: { query: string }) => {
+ return (
+ services
+ ?.filter(
+ (item) =>
+ `${item.service}.${item.function}`
+ .toLowerCase()
+ .includes(query.toLowerCase()) ||
+ item.description.toLowerCase().includes(query.toLowerCase()),
+ )
+ ?.slice(0, 10) || []
+ );
+ },
+
+ render: () => {
+ let component: any;
+ let popup: any;
+
+ return {
+ onStart: (props) => {
+ component = new ReactRenderer(MentionList, {
+ props,
+ editor: props.editor,
+ });
+
+ if (!props.clientRect) {
+ return;
+ }
+
+ popup = tippy("body", {
+ getReferenceClientRect: props.clientRect as any,
+ appendTo: () => document.body,
+ content: component.element,
+ showOnCreate: true,
+ interactive: true,
+ trigger: "manual",
+ placement: "bottom-start",
+ });
+ },
+
+ onUpdate(props: any) {
+ component.updateProps(props);
+
+ if (!props.clientRect) {
+ return;
+ }
+
+ popup[0].setProps({
+ getReferenceClientRect: props.clientRect,
+ });
+ },
+
+ onKeyDown(props: any) {
+ if (props.event.key === "Escape") {
+ popup[0].hide();
+
+ return true;
+ }
+
+ return component.ref?.onKeyDown(props);
+ },
+
+ onExit() {
+ popup[0].destroy();
+ component.destroy();
+ },
+ };
+ },
+});
+
+const TipTap = ({
+ value,
+ onChange,
+ services,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ services: ServiceFunction[];
+}) => {
+ const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
+ if (event.key === "Tab") {
+ event.preventDefault();
+ }
+ }, []);
+
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Bold, // Add Bold extension
+ BulletList, // Add BulletList extension
+ ListItem, // Add ListItem extension
+ Mention.configure({
+ HTMLAttributes: {
+ class: "mention",
+ },
+ suggestion: suggestion(services),
+ }),
+ ],
+ onUpdate: ({ editor }) => {
+ const text = editor.getHTML();
+ onChange(text);
+ },
+ editorProps: {
+ handleKeyDown: (view, event) => {
+ if (event.key === "Tab") {
+ event.preventDefault();
+ view.dispatch(view.state.tr.insertText("\t"));
+ return true;
+ }
+ return false;
+ },
+ },
+ });
+
+ const [isMounted, setIsMounted] = useState(false);
+
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ useEffect(() => {
+ if (isMounted && editor && !editor.getText()) {
+ editor.commands.setContent(value);
+ }
+ }, [editor, isMounted, value]);
+
+ if (!editor) return null;
+
+ return (
+ e.stopPropagation()}
+ onKeyDown={handleKeyDown}
+ >
+
+ {
+ editor?.chain().focus().toggleBold().run();
+ }}
+ className={cn(editor?.isActive("bold") ? "is-active" : "")}
+ type="button"
+ variant="ghost"
+ size="sm"
+ >
+
+
+ {
+ editor?.chain().focus().toggleBulletList().run();
+ }}
+ className={cn(editor?.isActive("bulletList") ? "is-active" : "")}
+ type="button"
+ variant="ghost"
+ size="sm"
+ >
+
+
+
+
+
+ );
+};
+
+export const InstructionsEditor = ({
+ value,
+ onChange,
+ clusterId,
+ keyProp,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ clusterId: string;
+ keyProp: string | null;
+}) => {
+ const [clusterServices, setClusterServices] =
+ useState | null>(
+ null,
+ );
+
+ const { getToken } = useAuth();
+
+ const getClusterServices = useCallback(async () => {
+ const services = await client.listServices({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId,
+ },
+ });
+
+ if (services.status === 200) {
+ setClusterServices(services.body);
+ } else {
+ createErrorToast(services, "Failed to get cluster services");
+ }
+ }, [clusterId, getToken]);
+
+ useEffect(() => {
+ getClusterServices();
+ }, [getClusterServices]);
+
+ const services: ServiceFunction[] =
+ clusterServices?.flatMap(
+ (d) =>
+ d.functions?.map((f) => ({
+ service: d.name,
+ function: f.name,
+ description: f.description ?? "",
+ })) ?? [],
+ ) ?? [];
+
+ if (!services.length) {
+ return No services found. Connect a few services to get started.
;
+ }
+
+ return (
+
+ );
+};
diff --git a/app/components/job-references.tsx b/app/components/job-references.tsx
new file mode 100644
index 00000000..a1e44f40
--- /dev/null
+++ b/app/components/job-references.tsx
@@ -0,0 +1,149 @@
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import { useAuth } from "@clerk/nextjs";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { formatRelative } from "date-fns";
+import { useParams } from "next/navigation";
+import React, { useCallback, useEffect, useState } from "react";
+import { unpack } from "../lib/utils";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "./ui/card";
+import { JsonForm } from "./json-form";
+
+class ErrorBoundary extends React.Component<
+ { children: React.ReactNode },
+ { hasError: boolean }
+> {
+ constructor(props: { children: React.ReactNode }) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(_: Error) {
+ return { hasError: true };
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+ Job references could not be displayed. Are you authenticated?
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+function JobReferencesContent({
+ displayable,
+ highlighted,
+}: {
+ displayable: string;
+ highlighted: string;
+}) {
+ const params = useParams<{ runId: string; clusterId: string }>();
+ const { getToken } = useAuth();
+
+ const [references, setReferences] = useState<
+ ClientInferResponseBody
+ >([]);
+
+ const [error, setError] = useState(null);
+
+ const fetchJobReferences = useCallback(async () => {
+ if (params?.clusterId && params?.runId) {
+ try {
+ const token = await getToken();
+ if (!token) {
+ throw new Error("No authentication token available");
+ }
+ const response = await client.listRunReferences({
+ params: {
+ clusterId: params.clusterId,
+ runId: params.runId,
+ },
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+ query: {
+ token: displayable,
+ before: new Date().toISOString(),
+ },
+ });
+
+ if (response.status === 200) {
+ setReferences(response.body);
+ } else {
+ throw new Error(`Failed to get job references: ${response.status}`);
+ }
+ } catch (error) {
+ console.error("Error fetching job references:", error);
+ setError("Failed to fetch job references. Please try again later.");
+ }
+ } else {
+ setError("Missing cluster ID or Run ID");
+ }
+ }, [params?.clusterId, params?.runId, displayable, getToken]);
+
+ useEffect(() => {
+ fetchJobReferences();
+ }, [fetchJobReferences]);
+
+ if (error) {
+ return {error}
;
+ }
+
+ return (
+ <>
+ References ({references?.length})
+ {references?.map((e, i) => (
+
+
+
+ Reference #{i + 1}
+
+
+ Agent received this data{" "}
+ {formatRelative(e.createdAt, new Date())} from{" "}
+
+ {e.service}.{e.targetFn}
+ {" "}
+ executing on machine {e.executingMachineId} .
+
+
+
+
+
+
+
+
+ ))}
+ {!references?.length && (
+ No references found
+ )}
+ >
+ );
+}
+
+export function JobReferences(props: {
+ displayable: string;
+ highlighted: string;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/app/components/json-form.tsx b/app/components/json-form.tsx
new file mode 100644
index 00000000..1aa56f5a
--- /dev/null
+++ b/app/components/json-form.tsx
@@ -0,0 +1,551 @@
+import { formatRelative } from "date-fns";
+import { AnimatePresence, motion } from "framer-motion";
+import { isEmpty, set, startCase } from "lodash";
+import { ChevronDownIcon, CodeIcon } from "lucide-react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { cn } from "../lib/utils";
+import { JobReferences } from "./job-references";
+import { Button } from "./ui/button";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "./ui/sheet";
+import { Textarea } from "./ui/textarea";
+import { Toggle } from "./ui/toggle";
+
+const HighlightContext = createContext("");
+
+function FormattedLabel({
+ label,
+ focused,
+}: {
+ label: string | React.ReactNode;
+ focused: boolean;
+}) {
+ if (typeof label !== "string") {
+ return (
+
+ {label}
+
+ );
+ }
+ const title = startCase(label);
+ return (
+
+ {title}
+
+ );
+}
+
+const identity = (v: string) => v;
+
+const safeFormatRelative = (date: Date, baseDate: Date) => {
+ try {
+ return formatRelative(date, baseDate);
+ } catch (e) {
+ return date.toString();
+ }
+};
+
+function Primitive({
+ label,
+ value,
+ path,
+ displayable,
+ onChange,
+ transform,
+ onFocus,
+}: {
+ label: string;
+ value: unknown;
+ path: string[];
+ displayable: string;
+ onChange?: (path: string[], value: unknown) => void;
+ transform?: (value: string) => unknown;
+ onFocus: (focused: boolean) => void;
+}) {
+ const [state, setState] = useState(displayable);
+ const [editing, setEditing] = useState(false);
+
+ const p = path.filter(Boolean);
+ const title = p[p.length - 1];
+
+ const [mouseOver, setMouseOver] = useState(false);
+
+ const highlightedContext = useContext(HighlightContext);
+
+ const [highlighted, setHighlighted] = useState("");
+
+ const displayNode = (
+ e.stopPropagation()}>
+
setEditing(o)}>
+ {
+ onFocus(true);
+ setMouseOver(true);
+ }}
+ onMouseLeave={() => {
+ onFocus(false);
+ setMouseOver(false);
+ }}
+ >
+
{
+ setHighlighted(displayable);
+ setEditing(true);
+ }}
+ >
+ {displayable}
+
+
+
+
+
+ {onChange ? "Editing" : ""} {startCase(title)}
+
+ {p.join(" > ")}
+
+
+
{
+ if (onChange) {
+ setState(e.target.value);
+ }
+ }}
+ className="mb-4"
+ />
+
+ {onChange && (
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ const t = transform ?? identity;
+ const v = t(state);
+ onChange(path, v);
+ setEditing(false);
+ }}
+ >
+ Save
+
+ )}
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ navigator.clipboard.writeText(state);
+ }}
+ >
+ Copy
+
+
+
+
+
+
+
+
+ );
+
+ return (
+
+
+ {label === "" ? (
+
{displayNode}
+ ) : (
+
+ {displayNode}
+
+ )}
+
+ );
+}
+
+const RenderWrapper = ({
+ focused,
+ children,
+}: {
+ focused: boolean;
+ children: React.ReactNode;
+}) => {
+ const emptyWrapper = (children as any).type?.name === undefined;
+
+ return (
+
+ {children}
+
+ );
+};
+
+RenderWrapper.displayName = "RenderWrapper";
+
+export function Render({
+ label,
+ value,
+ path,
+ onChange,
+ highlighted,
+ onFocus,
+}: {
+ label: string;
+ value: unknown;
+ path: string[];
+ onChange?: (path: string[], value: unknown) => void;
+ highlighted?: string[];
+ onFocus: (focused: boolean) => void;
+}) {
+ const [focused, setFocused] = useState(false);
+
+ useEffect(() => {
+ onFocus(focused);
+ }, [focused, onFocus]);
+
+ if (value === null) {
+ return (
+
+ s || null}
+ onFocus={setFocused}
+ />
+
+ );
+ }
+
+ if (value === undefined) {
+ return (
+
+ s || undefined}
+ onFocus={setFocused}
+ />
+
+ );
+ }
+
+ if (value instanceof Date) {
+ return (
+
+ new Date(s)}
+ onFocus={setFocused}
+ />
+
+ );
+ }
+
+ if (typeof value === "string") {
+ return (
+
+
+
+ );
+ }
+
+ if (typeof value === "number") {
+ return (
+
+ parseFloat(s)}
+ onFocus={setFocused}
+ />
+
+ );
+ }
+
+ if (typeof value === "boolean") {
+ return (
+
+ (s.toLowerCase().includes("true") ? true : false)}
+ onFocus={setFocused}
+ />
+
+ );
+ }
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return (
+
+ );
+ }
+
+ if (
+ value.every(
+ (v) =>
+ typeof v === "string" ||
+ typeof v === "number" ||
+ typeof v === "boolean" ||
+ v === null ||
+ v === undefined ||
+ v instanceof Date,
+ )
+ ) {
+ return (
+
+
+
+
+
+
+ {value.map((v, i) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {value.map((v, i) => (
+
+
+
+ #{(i + 1).toString()}
+
+ }
+ />
+
+
+
+ ))}
+
+
+ );
+ }
+
+ if (typeof value === "object") {
+ if (isEmpty(value)) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {label && (
+
+
+
+ )}
+
+ {Object.entries(value).map(([k, v]) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ return Unknown type: {typeof value}
;
+}
+
+export function JsonForm({
+ label,
+ value,
+ onUpdate,
+ highlighted,
+}: {
+ label: string;
+ value: object;
+ onUpdate?: (v: unknown) => void;
+ highlighted?: string;
+}) {
+ const [showJson, setShowJson] = useState(false);
+ const [expanded, setExpanded] = useState(true);
+
+ const onChange = useCallback(
+ (s: object, path: string[], changed: unknown) => {
+ if (onUpdate) {
+ const p = path.filter(Boolean).slice(1);
+ const newState = { ...s };
+ set(newState, p, changed);
+ onUpdate(newState);
+ } else {
+ alert("No update function provided");
+ }
+ },
+ [onUpdate],
+ );
+
+ return (
+
+
+
e.stopPropagation()}
+ >
+
setExpanded(!expanded)}
+ >
+
+
+
+
Data
+
+
+ setShowJson(e)}
+ pressed={showJson}
+ >
+
+
+
+
+
+ {expanded && (
+
+ {showJson ? (
+
+
{JSON.stringify(value, null, 2)}
+
+ ) : (
+
+ onChange(value, path, changed)
+ : undefined
+ }
+ onFocus={() => {}}
+ />
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/components/knowledge-quickstart.tsx b/app/components/knowledge-quickstart.tsx
new file mode 100644
index 00000000..c16e9ab6
--- /dev/null
+++ b/app/components/knowledge-quickstart.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+export function KnowledgeQuickstart() {
+ return (
+
+
+
+ Creating Knowledge Artifacts 🧠
+
+
+ Knowledge artifacts help your AI assistant understand your
+ organization's specific context. Follow these guidelines to
+ create effective artifacts:
+
+
+
+
+
Field Guidelines
+
+
+
ID
+
+ Use lowercase letters, numbers, and hyphens. Make it unique and
+ descriptive.
+
+
+ Example: customer-greeting-001
+
+
+
+
+
Title
+
+ Create a clear, descriptive title that summarizes the content.
+
+
+ Example: Customer Greeting Policy
+
+
+
+
+
Content
+
Use the formatting tools to structure your content:
+
+ Use headers for sections
+ Create lists for steps or points
+ Add code blocks for technical content
+ Include examples when helpful
+
+
+
+
+
Tags
+
+ Add relevant, comma-separated keywords to organize your artifacts.
+
+
+ Example: customer-service, policy, greetings
+
+
+
+
+
+ );
+}
diff --git a/app/components/loading.tsx b/app/components/loading.tsx
new file mode 100644
index 00000000..80b6daef
--- /dev/null
+++ b/app/components/loading.tsx
@@ -0,0 +1,39 @@
+export function LoadingDots() {
+ return (
+
+
+ ...
+
+
+
+ );
+}
+
+export function Loading({ text = "Loading" }: { text?: string }) {
+ return (
+
+ {text}
+
+
+ );
+}
diff --git a/app/components/logo-2.png b/app/components/logo-2.png
new file mode 100644
index 00000000..6405dc18
Binary files /dev/null and b/app/components/logo-2.png differ
diff --git a/app/components/logo.png b/app/components/logo.png
new file mode 100644
index 00000000..cfed5928
Binary files /dev/null and b/app/components/logo.png differ
diff --git a/app/components/markdown-editor.tsx b/app/components/markdown-editor.tsx
new file mode 100644
index 00000000..16906450
--- /dev/null
+++ b/app/components/markdown-editor.tsx
@@ -0,0 +1,52 @@
+import {
+ BlockTypeSelect,
+ BoldItalicUnderlineToggles,
+ CodeToggle,
+ CreateLink,
+ InsertTable,
+ ListsToggle,
+ MDXEditor,
+ headingsPlugin,
+ listsPlugin,
+ markdownShortcutPlugin,
+ quotePlugin,
+ thematicBreakPlugin,
+ toolbarPlugin,
+} from "@mdxeditor/editor";
+import "./mdxeditor.css";
+import "@mdxeditor/editor/style.css";
+
+export function MarkdownEditor({
+ markdown,
+ onChange,
+}: {
+ markdown: string;
+ onChange: (markdown: string) => void;
+}) {
+ return (
+ (
+ <>
+
+
+
+
+
+
+ >
+ ),
+ }),
+ ]}
+ className="border rounded-md p-2"
+ />
+ );
+}
diff --git a/app/components/mdxeditor.css b/app/components/mdxeditor.css
new file mode 100644
index 00000000..db72c4cd
--- /dev/null
+++ b/app/components/mdxeditor.css
@@ -0,0 +1,93 @@
+.mdxeditor {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
+ Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
+ line-height: 1.6;
+ color: #333;
+}
+
+.mdxeditor h1,
+.mdxeditor h2,
+.mdxeditor h3 {
+ margin-top: 0.5em;
+ margin-bottom: 0.5em;
+ font-weight: 600;
+}
+
+.mdxeditor h1 {
+ font-size: 2em;
+ border-bottom: 1px solid #eaecef;
+ padding-bottom: 0.3em;
+}
+
+.mdxeditor h2 {
+ font-size: 1.5em;
+}
+
+.mdxeditor h3 {
+ font-size: 1.25em;
+}
+
+.mdxeditor blockquote {
+ border-left: 4px solid #dfe2e5;
+ color: #6a737d;
+ padding: 0 1em;
+ margin: 0 0 16px;
+}
+
+.mdxeditor p {
+ margin-bottom: 16px;
+}
+
+.mdxeditor ul,
+.mdxeditor ol {
+ padding-left: 2em;
+ margin-bottom: 16px;
+}
+
+.mdxeditor li {
+ margin-bottom: 4px;
+}
+
+.mdxeditor ul {
+ list-style-type: disc;
+}
+
+.mdxeditor ol {
+ list-style-type: decimal;
+}
+
+.mdxeditor li::marker {
+ color: #6a737d;
+}
+
+.mdxeditor code {
+ background-color: rgba(27, 31, 35, 0.05);
+ border-radius: 3px;
+ font-size: 85%;
+ margin: 0;
+ padding: 0.2em 0.4em;
+}
+
+.mdxeditor pre {
+ background-color: #f6f8fa;
+ border-radius: 3px;
+ font-size: 85%;
+ line-height: 1.45;
+ overflow: auto;
+ padding: 16px;
+ margin-bottom: 16px;
+}
+
+.mdxeditor a {
+ color: #0366d6;
+ text-decoration: none;
+}
+
+.mdxeditor a:hover {
+ text-decoration: underline;
+}
+
+.mdxeditor img {
+ max-width: 100%;
+ box-sizing: content-box;
+}
diff --git a/app/components/posthog-pageview.tsx b/app/components/posthog-pageview.tsx
new file mode 100644
index 00000000..3bd65730
--- /dev/null
+++ b/app/components/posthog-pageview.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { usePathname, useSearchParams } from "next/navigation";
+import { useEffect } from "react";
+import { usePostHog } from "posthog-js/react";
+
+export default function PostHogPageView() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const posthog = usePostHog();
+ useEffect(() => {
+ // Track pageviews
+ if (pathname && posthog) {
+ let url = window.origin + pathname;
+ if (searchParams?.toString()) {
+ url = url + `?${searchParams.toString()}`;
+ }
+ posthog.capture("$pageview", {
+ $current_url: url,
+ });
+ }
+ }, [pathname, searchParams, posthog]);
+
+ return null;
+}
diff --git a/app/components/posthog-user.tsx b/app/components/posthog-user.tsx
new file mode 100644
index 00000000..33a58b7a
--- /dev/null
+++ b/app/components/posthog-user.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useAuth, useUser, useOrganization } from "@clerk/nextjs";
+import { usePostHog } from "posthog-js/react";
+import { useEffect } from "react";
+
+export function PostHogUser() {
+ const { user } = useUser();
+ const { organization } = useOrganization();
+ const { isSignedIn } = useAuth();
+ const { orgRole } = useAuth();
+
+ const posthog = usePostHog();
+
+ useEffect(() => {
+ if (!posthog) return;
+
+ if (
+ isSignedIn &&
+ organization &&
+ user &&
+ !posthog._isIdentified() &&
+ user?.primaryEmailAddress?.emailAddress
+ ) {
+ posthog.identify(user.id);
+
+ posthog.people.set({
+ email: user.primaryEmailAddress?.emailAddress,
+ auth_type: "clerk",
+ username: user.username,
+ role: orgRole,
+ });
+
+ posthog.group("organization", organization.id, {
+ name: organization?.name,
+ slug: organization?.slug,
+ });
+ }
+
+ if (!isSignedIn && posthog._isIdentified()) {
+ posthog.reset();
+ }
+ }, [posthog, user, organization, orgRole, isSignedIn]);
+
+ return null;
+}
diff --git a/app/components/read-only-json.tsx b/app/components/read-only-json.tsx
new file mode 100644
index 00000000..235b7358
--- /dev/null
+++ b/app/components/read-only-json.tsx
@@ -0,0 +1,31 @@
+import { colorizeJSON } from "@/lib/colorize-json";
+
+export const ReadOnlyJSON = ({ json }: { json: string | object }) => {
+ let formattedJson: string;
+
+ if (typeof json === "object") {
+ formattedJson = JSON.stringify(json, null, 2);
+ } else if (typeof json === "string") {
+ try {
+ formattedJson = JSON.stringify(JSON.parse(json), null, 2);
+ } catch (e) {
+ formattedJson = "{}";
+ }
+ } else {
+ formattedJson = "{}";
+ }
+
+ // Replace \n with actual line breaks
+ formattedJson = formattedJson.replace(/\\n/g, "\n");
+
+ const colorizedJson = colorizeJSON(formattedJson);
+
+ return (
+
+ );
+};
diff --git a/app/components/rollbar-user.tsx b/app/components/rollbar-user.tsx
new file mode 100644
index 00000000..e7aefffc
--- /dev/null
+++ b/app/components/rollbar-user.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { useAuth, useUser } from "@clerk/nextjs";
+import { useRollbarPerson } from "@rollbar/react";
+
+export function RollbarUser() {
+ const { user } = useUser();
+ const { orgRole, orgId, orgSlug } = useAuth();
+
+ useRollbarPerson({
+ firstName: user?.firstName,
+ lastName: user?.lastName,
+ email:
+ user?.emailAddresses.find((email) => email.emailAddress)?.emailAddress ??
+ "",
+ id: user?.id,
+ organizationId: orgId,
+ organizationName: orgSlug,
+ organizationRole: orgRole,
+ });
+
+ return null;
+}
diff --git a/app/components/server-connection-pane.tsx b/app/components/server-connection-pane.tsx
new file mode 100644
index 00000000..efe11cab
--- /dev/null
+++ b/app/components/server-connection-pane.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { useEffect, useState } from "react";
+import {
+ DeadGrayCircle,
+ DeadGreenCircle,
+ DeadRedCircle,
+ LiveGreenCircle,
+ SmallDeadGrayCircle,
+ SmallDeadRedCircle,
+ SmallLiveGreenCircle,
+} from "./circles";
+
+import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
+import { Button } from "./ui/button";
+
+export class ServerConnectionStatus {
+ static events: {
+ type: string;
+ success: boolean;
+ timestamp: Date;
+ }[] = [];
+
+ static addEvent(event: { type: string; success: boolean }) {
+ this.events.push({
+ ...event,
+ timestamp: new Date(),
+ });
+
+ this.events = this.events.slice(-20);
+ }
+
+ static getLastEvent() {
+ return this.events[this.events.length - 1];
+ }
+
+ static getRecentEvents(count: number = 10) {
+ return this.events.slice(-count).reverse();
+ }
+}
+
+export function ServerConnectionPane() {
+ const [lastEvent, setLastEvent] = useState(
+ ServerConnectionStatus.getLastEvent(),
+ );
+ const [recentEvents, setRecentEvents] = useState(
+ ServerConnectionStatus.getRecentEvents(),
+ );
+
+ useEffect(() => {
+ // Update the status every second
+ const interval = setInterval(() => {
+ setLastEvent(ServerConnectionStatus.getLastEvent());
+ setRecentEvents(ServerConnectionStatus.getRecentEvents());
+ }, 2000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ const getStatusDisplay = () => {
+ if (!lastEvent) {
+ return (
+ <>
+
+ Waiting for connection...
+ >
+ );
+ }
+
+ if (lastEvent.success) {
+ return (
+ <>
+
+ Connected to server
+ >
+ );
+ }
+
+ return (
+ <>
+
+ Connection failed
+ >
+ );
+ };
+
+ const circle = lastEvent?.success ? (
+
+ ) : lastEvent ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
+ API
+ {circle}
+
+
+
+
+
+
Server Connection
+
{getStatusDisplay()}
+
+
+
+
Recent Events
+
+
+
+ Status
+ Event Type
+ Time
+
+
+
+ {recentEvents.length > 0 ? (
+ recentEvents.map((event, index) => (
+
+
+ {event.success ? (
+
+ ) : (
+
+ )}
+
+ {event.type}
+
+ {event.timestamp.toLocaleTimeString()}
+
+
+ ))
+ ) : (
+
+
+
+ No events recorded
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/service-details-pane.tsx b/app/components/service-details-pane.tsx
new file mode 100644
index 00000000..95f58105
--- /dev/null
+++ b/app/components/service-details-pane.tsx
@@ -0,0 +1,18 @@
+import ServicesOverview from "@/components/services-overview";
+import { type ClusterDetails } from "@/lib/types";
+
+export function ServiceDetailsPane({
+ clusterDetails,
+}: {
+ clusterDetails: ClusterDetails | null;
+}): JSX.Element {
+ if (!clusterDetails) {
+ return No services available
;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/app/components/services-overview.tsx b/app/components/services-overview.tsx
new file mode 100644
index 00000000..ccd6459a
--- /dev/null
+++ b/app/components/services-overview.tsx
@@ -0,0 +1,121 @@
+import { client } from "@/client/client";
+import { contract } from "@/client/contract";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { createErrorToast } from "@/lib/utils";
+import { useAuth } from "@clerk/nextjs";
+import { ClientInferResponseBody } from "@ts-rest/core";
+import { AppWindowIcon, Layers } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import ToolContextButton from "./chat/ToolContextButton";
+
+export type Service = {
+ name: string;
+ description?: string;
+ functions?: {
+ name: string;
+ description?: string;
+ schema?: string;
+ }[];
+};
+
+function toServiceName(name: string) {
+ return {name} ;
+}
+
+function toFunctionName(name: string, serviceName: string) {
+ if (serviceName === "InferableApplications") {
+ return Inferable App ;
+ }
+
+ return {name} ;
+}
+
+export default function ServicesOverview({ clusterId }: { clusterId: string }) {
+ const [services, setServices] = useState<
+ ClientInferResponseBody
+ >([]);
+ const { getToken } = useAuth();
+
+ const getClusterServices = useCallback(async () => {
+ const servicesResponse = await client.listServices({
+ headers: {
+ authorization: `Bearer ${await getToken()}`,
+ },
+ params: {
+ clusterId,
+ },
+ });
+
+ if (servicesResponse.status === 200) {
+ setServices(servicesResponse.body);
+ } else {
+ createErrorToast(servicesResponse, "Failed to get cluster services");
+ }
+ }, [clusterId, getToken]);
+
+ useEffect(() => {
+ getClusterServices();
+ }, [getClusterServices]);
+
+ return (
+
+
Services
+
+ You have {services.length} services with{" "}
+ {services.flatMap((s) => s.functions).length} functions.
+
+ {services
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((service) => (
+
+
+ {service.name === "InferableApplications" ? (
+
+ ) : (
+
+ )}
+ {toServiceName(service.name)}
+
+
+
+
+ Function
+ Description
+
+
+
+ {service.functions
+ ?.sort((a, b) => a.name.localeCompare(b.name))
+ .map((func) => (
+
+
+
+
+ {toFunctionName(func.name, service.name)}
+
+
+
+
+
+ {func.description || "No description"}
+
+
+ ))}
+
+
+
+ ))}
+
+ );
+}
diff --git a/app/components/services-quickstart.tsx b/app/components/services-quickstart.tsx
new file mode 100644
index 00000000..d8ebbe01
--- /dev/null
+++ b/app/components/services-quickstart.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { coldarkDark } from "react-syntax-highlighter/dist/esm/styles/prism";
+import { useState } from "react";
+
+type ProgrammingLanguage = "node" | "golang" | "dotnet";
+
+export function ServicesQuickstart({ clusterId }: { clusterId: string }) {
+ const [copied, setCopied] = useState(false);
+ const [selectedLanguage, setSelectedLanguage] =
+ useState("node");
+
+ const getCommands = (language: ProgrammingLanguage) => {
+ const baseCommands = `npm i @inferable/cli -g && \\
+inf auth login`;
+
+ const languageSpecificCommands = {
+ node: `inf bootstrap node --dir=inferable-app-${clusterId} --no-cluster=true`,
+ golang: `inf bootstrap go --dir=inferable-app-${clusterId} --no-cluster=true`,
+ dotnet: `inf bootstrap dotnet --dir=inferable-app-${clusterId} --no-cluster=true`,
+ };
+
+ return `${baseCommands} && \\
+${languageSpecificCommands[language]} && \\
+cd inferable-app-${clusterId} && \\
+inf auth keys create my_key --clusterId=${clusterId} --env=true && \\
+${language === "node" ? "npm run dev" : language === "golang" ? "go run ." : "dotnet run"}`;
+ };
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(getCommands(selectedLanguage));
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+ Welcome to your new cluster! 🎉
+
+
+ Your cluster is ready to go, but it doesn't have any services
+ yet. Let's fix that!
+
+
+
+
+
+
Quick Start Guide
+
+ Select language:
+
+ setSelectedLanguage(e.target.value as ProgrammingLanguage)
+ }
+ className="px-3 py-1.5 text-sm rounded border border-gray-300 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ Node.js
+ Go
+ .NET
+
+
+
+
+ Run these commands in your terminal to create your first service:
+
+
+
+ {copied ? "Copied!" : "Copy"}
+
+
+ {getCommands(selectedLanguage)}
+
+
+
+ This will:
+
+ Install the Inferable CLI
+
+ Create a new{" "}
+ {selectedLanguage === "node"
+ ? "Node.js"
+ : selectedLanguage === "golang"
+ ? "Go"
+ : ".NET"}{" "}
+ service
+
+ Set up your development environment
+ Start the development server
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/ui/accordion.tsx b/app/components/ui/accordion.tsx
new file mode 100644
index 00000000..55685f6c
--- /dev/null
+++ b/app/components/ui/accordion.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDown } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/app/components/ui/alert-dialog.tsx b/app/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..3add93c7
--- /dev/null
+++ b/app/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import * as React from "react";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/app/components/ui/alert.tsx b/app/components/ui/alert.tsx
new file mode 100644
index 00000000..13219e77
--- /dev/null
+++ b/app/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+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 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = "Alert";
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertTitle.displayName = "AlertTitle";
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertDescription.displayName = "AlertDescription";
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/app/components/ui/badge.tsx b/app/components/ui/badge.tsx
new file mode 100644
index 00000000..d3d5d604
--- /dev/null
+++ b/app/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/app/components/ui/breadcrumb.tsx b/app/components/ui/breadcrumb.tsx
new file mode 100644
index 00000000..6934f83b
--- /dev/null
+++ b/app/components/ui/breadcrumb.tsx
@@ -0,0 +1,115 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Breadcrumb = React.forwardRef<
+ HTMLElement,
+ React.ComponentPropsWithoutRef<"nav"> & {
+ separator?: React.ReactNode;
+ }
+>(({ ...props }, ref) => );
+Breadcrumb.displayName = "Breadcrumb";
+
+const BreadcrumbList = React.forwardRef<
+ HTMLOListElement,
+ React.ComponentPropsWithoutRef<"ol">
+>(({ className, ...props }, ref) => (
+
+));
+BreadcrumbList.displayName = "BreadcrumbList";
+
+const BreadcrumbItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+
+));
+BreadcrumbItem.displayName = "BreadcrumbItem";
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean;
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+});
+BreadcrumbLink.displayName = "BreadcrumbLink";
+
+const BreadcrumbPage = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentPropsWithoutRef<"span">
+>(({ className, ...props }, ref) => (
+
+));
+BreadcrumbPage.displayName = "BreadcrumbPage";
+
+const BreadcrumbSeparator = ({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) => (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+);
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
+
+const BreadcrumbEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More
+
+);
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx
new file mode 100644
index 00000000..57c9fe47
--- /dev/null
+++ b/app/components/ui/button.tsx
@@ -0,0 +1,56 @@
+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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/app/components/ui/card.tsx b/app/components/ui/card.tsx
new file mode 100644
index 00000000..dc3b01de
--- /dev/null
+++ b/app/components/ui/card.tsx
@@ -0,0 +1,86 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
+};
diff --git a/app/components/ui/chart.tsx b/app/components/ui/chart.tsx
new file mode 100644
index 00000000..74551d78
--- /dev/null
+++ b/app/components/ui/chart.tsx
@@ -0,0 +1,365 @@
+"use client";
+
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+
+import { cn } from "@/lib/utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ );
+};
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"];
+ }
+>(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+});
+ChartContainer.displayName = "Chart";
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([_, config]) => config.theme || config.color,
+ );
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+