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() {