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 + + + + + + + + + + ); +} 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. +

+
+ + + + + +
+ + {promptTemplate.versions + .sort((a, b) => b.version - a.version) + .map((version) => ( + + ))} +
+
+
+ +
+ +
+
+ +
+ {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) => ( + + ))} +
+
+ )} +

+ Current Version (v{clusterContext?.current.version}) +

+ { + setActivePrompt(markdown); + }} + /> +
+
+ {wordCount} {wordCount === 1 ? "word" : "words"} + {wordCount > 300 && ( + + Warning: Exceeds 300 words + + )} +
+
+ +
+
+
+ ); +} 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 ( + + ); + }, + 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 ( +
+ + + +
+ ); + }, + }, +]; + +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. +

+
+ + +
+ +
+ ); +} 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. + +
+ +
+ + ( + + Secret API Key + + + + + Your Langfuse secret API key + + + + )} + /> + ( + + Public Key + + + + Your Langfuse public key + + + )} + /> + ( + + Base URL + + + + + Langfuse API base URL. For US region, use{" "} + https://us.cloud.langfuse.com + + + + )} + /> + ( + + Send Message Payloads + + + + + {field.value + ? "Inferable will send all metadata and payloads for every LLM call" + : "Inferable will only send LLM metadata like token count and latency"} + + + + )} + /> + + + +
+
+
+ ); +} 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 && ( +
+ + +
+ )} +
+
+
+ ))} + + + +
+ Zapier +
+ + Integrate your Inferable Runs with Zapier + +
+ + + + + +
+
+
+
+ ); +} 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 + + . + +
+ +
+ + ( + + API Key + + + + Your Toolhouse API key + + + )} + /> + + + +
+
+
+ ); +} 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 ( +
+
+
+
+ + + +
+ + + + + + + + Bulk Upload Knowledge Artifacts + +
+

+ Paste your JSON array of knowledge artifacts below. Note + that items with the same ID will be overwritten. +

+