diff --git a/ui/admin/app/components/header/HeaderNav.tsx b/ui/admin/app/components/header/HeaderNav.tsx index ee34cd98a..60320e057 100644 --- a/ui/admin/app/components/header/HeaderNav.tsx +++ b/ui/admin/app/components/header/HeaderNav.tsx @@ -1,4 +1,4 @@ -import { Link, Params, useLocation, useParams } from "@remix-run/react"; +import { Link } from "@remix-run/react"; import { $path } from "remix-routes"; import useSWR from "swr"; @@ -18,10 +18,9 @@ import { } from "~/components/ui/breadcrumb"; import { SidebarTrigger } from "~/components/ui/sidebar"; import { UserMenu } from "~/components/user/UserMenu"; +import { useUnknownPathParams } from "~/hooks/useRouteInfo"; export function HeaderNav() { - const { pathname } = useLocation(); - const params = useParams(); const headerHeight = "h-[60px]"; return ( @@ -36,7 +35,7 @@ export function HeaderNav() {
- {getBreadcrumbs(pathname, params)} +
@@ -49,7 +48,9 @@ export function HeaderNav() { ); } -function getBreadcrumbs(route: string, params: Readonly>) { +function RouteBreadcrumbs() { + const routeInfo = useUnknownPathParams(); + return ( @@ -59,9 +60,7 @@ function getBreadcrumbs(route: string, params: Readonly>) { - {new RegExp($path("/agents/:agent", { agent: "(.*)" })).test( - route - ) ? ( + {routeInfo?.path === "/agents/:agent" ? ( <> @@ -71,25 +70,25 @@ function getBreadcrumbs(route: string, params: Readonly>) { - + ) : ( - new RegExp($path("/agents")).test(route) && ( + routeInfo?.path === "/agents" && ( Agents ) )} - {new RegExp($path("/threads")).test(route) && ( + {routeInfo?.path === "/threads" && ( Threads )} - {new RegExp($path("/thread/:id", { id: "(.*)" })).test( - route - ) && ( + {routeInfo?.path === "/thread/:id" && ( <> @@ -99,14 +98,14 @@ function getBreadcrumbs(route: string, params: Readonly>) { - + )} - {new RegExp( - $path("/workflows/:workflow", { workflow: "(.*)" }) - ).test(route) && ( + {routeInfo?.path === "/workflows/:workflow" && ( <> @@ -117,23 +116,25 @@ function getBreadcrumbs(route: string, params: Readonly>) { )} - {new RegExp($path("/tools")).test(route) && ( + {routeInfo?.path === "/tools" && ( Tools )} - {new RegExp($path("/users")).test(route) && ( + {routeInfo?.path === "/users" && ( Users )} - {new RegExp($path("/oauth-apps")).test(route) && ( + {routeInfo?.path === "/oauth-apps" && ( OAuth Apps diff --git a/ui/admin/app/hooks/useRouteInfo.ts b/ui/admin/app/hooks/useRouteInfo.ts new file mode 100644 index 000000000..4c1957763 --- /dev/null +++ b/ui/admin/app/hooks/useRouteInfo.ts @@ -0,0 +1,25 @@ +import { Location, useLocation, useParams } from "@remix-run/react"; +import { useMemo } from "react"; + +import { RouteService } from "~/lib/service/routeService"; + +const urlFromLocation = (location: Location) => { + const { pathname, search, hash } = location; + return new URL(window.location.origin + pathname + search + hash); +}; + +export function useUrl() { + const location = useLocation(); + + return useMemo(() => urlFromLocation(location), [location]); +} + +export function useUnknownPathParams() { + const url = useUrl(); + const params = useParams(); + + return useMemo( + () => RouteService.getUnknownRouteInfo(url, params), + [url, params] + ); +} diff --git a/ui/admin/app/lib/service/routeService.ts b/ui/admin/app/lib/service/routeService.ts index 9c2e74802..62a18bee1 100644 --- a/ui/admin/app/lib/service/routeService.ts +++ b/ui/admin/app/lib/service/routeService.ts @@ -1,110 +1,209 @@ import queryString from "query-string"; import { $params, $path, Routes, RoutesWithParams } from "remix-routes"; -import { ZodSchema, z } from "zod"; +import { ZodNull, ZodSchema, ZodType, z } from "zod"; -const QueryParamSchemaMap = { - "": z.undefined(), - "/": z.undefined(), - "/agents": z.undefined(), - "/agents/:agent": z.object({ +const QuerySchemas = { + agentSchema: z.object({ threadId: z.string().optional(), from: z.string().optional(), }), - "/debug": z.undefined(), - "/home": z.undefined(), - "/models": z.undefined(), - "/oauth-apps": z.undefined(), - "/thread/:id": z.undefined(), - "/threads": z.object({ + threadsListSchema: z.object({ agentId: z.string().optional(), userId: z.string().optional(), workflowId: z.string().optional(), }), - "/workflows": z.undefined(), - "/workflows/:workflow": z.undefined(), - "/tools": z.undefined(), - "/users": z.undefined(), -} satisfies Record; +} as const; -function parseSearchParams(route: T, search: string) { - if (!QueryParamSchemaMap[route]) - throw new Error(`No schema found for route: ${route}`); +function parseQuery(search: string, schema: T) { + if (schema instanceof ZodNull) return null; const obj = queryString.parse(search); - const { data, success } = QueryParamSchemaMap[route].safeParse(obj); + const { data, success } = schema.safeParse(obj); if (!success) { - console.error("Failed to parse query params", route, search); - return undefined; + console.error("Failed to parse query params", search); + return null; } - return data as z.infer<(typeof QueryParamSchemaMap)[T]>; + return data; } -type QueryParamInfo = { - path: T; - query?: z.infer<(typeof QueryParamSchemaMap)[T]>; +const exactRegex = (path: string) => new RegExp(`^${path}$`); + +type RouteHelper = { + regex: RegExp; + path: keyof Routes; + schema: ZodSchema; }; -function getUnknownQueryParams(pathname: string, search: string) { - if (new RegExp($path("/agents/:agent", { agent: "(.*)" })).test(pathname)) { - return { - path: "/agents/:agent", - query: parseSearchParams("/agents/:agent", search), - } satisfies QueryParamInfo<"/agents/:agent">; - } +export const RouteHelperMap = { + "": { + regex: exactRegex($path("")), + path: "/", + schema: z.null(), + }, + "/": { + regex: exactRegex($path("/")), + path: "/", + schema: z.null(), + }, + "/agents": { + regex: exactRegex($path("/agents")), + path: "/agents", + schema: z.null(), + }, + "/agents/:agent": { + regex: exactRegex($path("/agents/:agent", { agent: "(.+)" })), + path: "/agents/:agent", + schema: QuerySchemas.agentSchema, + }, + "/debug": { + regex: exactRegex($path("/debug")), + path: "/debug", + schema: z.null(), + }, + "/home": { + regex: exactRegex($path("/home")), + path: "/home", + schema: z.null(), + }, + "/models": { + regex: exactRegex($path("/models")), + path: "/models", + schema: z.null(), + }, + "/oauth-apps": { + regex: exactRegex($path("/oauth-apps")), + path: "/oauth-apps", + schema: z.null(), + }, + "/thread/:id": { + regex: exactRegex($path("/thread/:id", { id: "(.+)" })), + path: "/thread/:id", + schema: z.null(), + }, + "/threads": { + regex: exactRegex($path("/threads")), + path: "/threads", + schema: QuerySchemas.threadsListSchema, + }, + "/tools": { + regex: exactRegex($path("/tools")), + path: "/tools", + schema: z.null(), + }, + "/users": { + regex: exactRegex($path("/users")), + path: "/users", + schema: z.null(), + }, + "/workflows": { + regex: exactRegex($path("/workflows")), + path: "/workflows", + schema: z.null(), + }, + "/workflows/:workflow": { + regex: exactRegex($path("/workflows/:workflow", { workflow: "(.+)" })), + path: "/workflows/:workflow", + schema: z.null(), + }, +} satisfies Record; - if (new RegExp($path("/threads")).test(pathname)) { - return { - path: "/threads", - query: parseSearchParams("/threads", search), - } satisfies QueryParamInfo<"/threads">; - } +type QueryInfo = z.infer< + (typeof RouteHelperMap)[T]["schema"] +>; - return {}; -} +type PathInfo = ReturnType< + typeof $params +>; -type PathParamInfo = { +type RouteInfo = { path: T; - pathParams: ReturnType>; + query: QueryInfo | null; + pathParams: T extends keyof RoutesWithParams ? PathInfo : unknown; }; -function getUnknownPathParams( - pathname: string, +function getRouteHelper( + url: URL, params: Record -) { - if (new RegExp($path("/agents/:agent", { agent: "(.*)" })).test(pathname)) { - return { - path: "/agents/:agent", - pathParams: $params("/agents/:agent", params), - } satisfies PathParamInfo<"/agents/:agent">; +): RouteInfo | null { + for (const route of Object.values(RouteHelperMap)) { + if (route.regex.test(url.pathname)) + return { + path: route.path, + query: parseQuery(url.search, route.schema as ZodSchema), + pathParams: $params( + route.path as keyof RoutesWithParams, + params + ), + }; } - if (new RegExp($path("/thread/:id", { id: "(.*)" })).test(pathname)) { - return { - path: "/thread/:id", - pathParams: $params("/thread/:id", params), - } satisfies PathParamInfo<"/thread/:id">; - } + return null; +} - if ( - new RegExp($path("/workflows/:workflow", { workflow: "(.*)" })).test( - pathname - ) - ) { - return { - path: "/workflows/:workflow", - pathParams: $params("/workflows/:workflow", params), - } satisfies PathParamInfo<"/workflows/:workflow">; - } +function getRouteInfo( + path: T, + url: URL, + params: Record +): RouteInfo { + const helper = RouteHelperMap[path]; - return {}; + return { + path, + query: parseQuery(url.search, helper.schema), + pathParams: $params(path as keyof RoutesWithParams, params) as Todo, + }; } +function getUnknownRouteInfo( + url: URL, + params: Record +) { + const routeInfo = getRouteHelper(url, params); + + switch (routeInfo?.path) { + case "/": + return routeInfo as RouteInfo<"/">; + case "/agents": + return routeInfo as RouteInfo<"/agents">; + case "/agents/:agent": + return routeInfo as RouteInfo<"/agents/:agent">; + case "/debug": + return routeInfo as RouteInfo<"/debug">; + case "/home": + return routeInfo as RouteInfo<"/home">; + case "/models": + return routeInfo as RouteInfo<"/models">; + case "/oauth-apps": + return routeInfo as RouteInfo<"/oauth-apps">; + case "/thread/:id": + return routeInfo as RouteInfo<"/thread/:id">; + case "/threads": + return routeInfo as RouteInfo<"/threads">; + case "/tools": + return routeInfo as RouteInfo<"/tools">; + case "/users": + return routeInfo as RouteInfo<"/users">; + case "/workflows": + return routeInfo as RouteInfo<"/workflows">; + case "/workflows/:workflow": + return routeInfo as RouteInfo<"/workflows/:workflow">; + default: + return null; + } +} + +export type RouteQueryParams = z.infer< + (typeof QuerySchemas)[T] +>; + +const getQueryParams = (path: T, search: string) => + parseQuery(search, RouteHelperMap[path].schema) as RouteInfo["query"]; + export const RouteService = { - getPathParams: $params, - getUnknownPathParams, - getUnknownQueryParams, - getQueryParams: parseSearchParams, - schemas: QueryParamSchemaMap, + schemas: QuerySchemas, + getUnknownRouteInfo, + getRouteInfo, + getQueryParams, }; diff --git a/ui/admin/app/routes/_auth.agents.$agent.tsx b/ui/admin/app/routes/_auth.agents.$agent.tsx index d738c14f0..24adf4d74 100644 --- a/ui/admin/app/routes/_auth.agents.$agent.tsx +++ b/ui/admin/app/routes/_auth.agents.$agent.tsx @@ -6,10 +6,9 @@ import { } from "@remix-run/react"; import { useCallback } from "react"; import { $path } from "remix-routes"; -import { z } from "zod"; import { AgentService } from "~/lib/service/api/agentService"; -import { RouteService } from "~/lib/service/routeService"; +import { RouteQueryParams, RouteService } from "~/lib/service/routeService"; import { noop } from "~/lib/utils"; import { Agent } from "~/components/agent"; @@ -21,9 +20,7 @@ import { ResizablePanelGroup, } from "~/components/ui/resizable"; -export type SearchParams = z.infer< - (typeof RouteService.schemas)["/agents/:agent"] ->; +export type SearchParams = RouteQueryParams<"agentSchema">; export const clientLoader = async ({ params, @@ -31,13 +28,10 @@ export const clientLoader = async ({ }: ClientLoaderFunctionArgs) => { const url = new URL(request.url); - const { agent: agentId } = RouteService.getPathParams( - "/agents/:agent", - params - ); + const routeInfo = RouteService.getRouteInfo("/agents/:agent", url, params); - const { threadId, from } = - RouteService.getQueryParams("/agents/:agent", url.search) ?? {}; + const { agent: agentId } = routeInfo.pathParams; + const { threadId, from } = routeInfo.query ?? {}; if (!agentId) { throw redirect("/agents"); diff --git a/ui/admin/app/routes/_auth.thread.$id.tsx b/ui/admin/app/routes/_auth.thread.$id.tsx index 3769214b0..bad085e15 100644 --- a/ui/admin/app/routes/_auth.thread.$id.tsx +++ b/ui/admin/app/routes/_auth.thread.$id.tsx @@ -28,8 +28,17 @@ import { TooltipTrigger, } from "~/components/ui/tooltip"; -export const clientLoader = async ({ params }: ClientLoaderFunctionArgs) => { - const { id } = RouteService.getPathParams("/thread/:id", params); +export const clientLoader = async ({ + params, + request, +}: ClientLoaderFunctionArgs) => { + const routeInfo = RouteService.getRouteInfo( + "/thread/:id", + new URL(request.url), + params + ); + + const { id } = routeInfo.pathParams; if (!id) { throw redirect("/threads"); diff --git a/ui/admin/app/routes/_auth.threads.tsx b/ui/admin/app/routes/_auth.threads.tsx index ac57e7f83..ee5271a9e 100644 --- a/ui/admin/app/routes/_auth.threads.tsx +++ b/ui/admin/app/routes/_auth.threads.tsx @@ -11,7 +11,6 @@ import { PuzzleIcon, Trash, XIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { $path } from "remix-routes"; import useSWR, { preload } from "swr"; -import { z } from "zod"; import { Agent } from "~/lib/model/agents"; import { Thread } from "~/lib/model/threads"; @@ -21,7 +20,7 @@ import { AgentService } from "~/lib/service/api/agentService"; import { ThreadsService } from "~/lib/service/api/threadsService"; import { UserService } from "~/lib/service/api/userService"; import { WorkflowService } from "~/lib/service/api/workflowService"; -import { RouteService } from "~/lib/service/routeService"; +import { RouteQueryParams, RouteService } from "~/lib/service/routeService"; import { timeSince } from "~/lib/utils"; import { TypographyH2, TypographyP } from "~/components/Typography"; @@ -34,11 +33,12 @@ import { } from "~/components/ui/tooltip"; import { useAsync } from "~/hooks/useAsync"; -export type SearchParams = z.infer<(typeof RouteService.schemas)["/threads"]>; - -export async function clientLoader({ request }: ClientLoaderFunctionArgs) { - const search = new URL(request.url).search; +export type SearchParams = RouteQueryParams<"threadsListSchema">; +export async function clientLoader({ + params, + request, +}: ClientLoaderFunctionArgs) { await Promise.all([ preload(AgentService.getAgents.key(), AgentService.getAgents), preload( @@ -48,7 +48,13 @@ export async function clientLoader({ request }: ClientLoaderFunctionArgs) { preload(ThreadsService.getThreads.key(), ThreadsService.getThreads), ]); - return RouteService.getQueryParams("/threads", search) ?? {}; + const { query } = RouteService.getRouteInfo( + "/threads", + new URL(request.url), + params + ); + + return query ?? {}; } export default function Threads() {