From 2e79fcc6dfa2c728d0e3b053a752e2063914908a Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 12 Feb 2025 11:48:13 +0100 Subject: [PATCH 01/32] chore: add dnd-kit, uuid and upgrade ui-kit --- package-lock.json | 73 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 ++ 2 files changed, 76 insertions(+) diff --git a/package-lock.json b/package-lock.json index 1553f963..0b2883a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "vite-project", "version": "0.14.1", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@hey-api/client-fetch": "^0.7.1", "@jsonforms/core": "^3.5.1", "@jsonforms/react": "^3.5.1", @@ -16,7 +18,11 @@ "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", +<<<<<<< HEAD "@stacklok/ui-kit": "^1.0.1-4", +======= + "@stacklok/ui-kit": "^1.0.1-2", +>>>>>>> 79c794a (chore: add dnd-kit, uuid and upgrade ui-kit) "@tanstack/react-query": "^5.64.1", "@tanstack/react-query-devtools": "^5.66.0", "@types/lodash": "^4.17.15", @@ -39,6 +45,7 @@ "tailwind-variants": "^0.3.1", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.6.2", + "uuid": "^11.0.5", "zod": "^3.24.1" }, "devDependencies": { @@ -416,6 +423,59 @@ "tough-cookie": "^4.1.4" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", @@ -14300,6 +14360,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 41b7c15d..ce63d81e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "generate-icons": "npx @svgr/cli --typescript --no-dimensions --replace-attr-values '#2E323A=currentColor' --jsx-runtime automatic --out-dir ./src/components/icons/ -- icons" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@hey-api/client-fetch": "^0.7.1", "@jsonforms/core": "^3.5.1", "@jsonforms/react": "^3.5.1", @@ -52,6 +54,7 @@ "tailwind-variants": "^0.3.1", "tailwindcss-animate": "^1.0.7", "ts-pattern": "^5.6.2", + "uuid": "^11.0.5", "zod": "^3.24.1" }, "devDependencies": { From c7ff57ded2fa5dec4de7141351ff05de9008be16 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 12 Feb 2025 13:08:21 +0100 Subject: [PATCH 02/32] feat: create SortableArea generic component --- src/components/SortableArea.tsx | 92 +++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/components/SortableArea.tsx diff --git a/src/components/SortableArea.tsx b/src/components/SortableArea.tsx new file mode 100644 index 00000000..25da9618 --- /dev/null +++ b/src/components/SortableArea.tsx @@ -0,0 +1,92 @@ +import { + closestCenter, + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + UniqueIdentifier, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useSortable } from "@dnd-kit/sortable"; +import { DotsGrid } from "@untitled-ui/icons-react"; + +type Props = { + children: (item: T, index: number) => React.ReactNode; + setItems: (items: T[]) => void; + items: T[]; +}; + +function ItemWrapper({ + children, + id, +}: { + children: React.ReactNode; + id: UniqueIdentifier; +}) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + return ( +
+
+ +
+
{children}
+
+ ); +} + +export function SortableArea({ + children, + setItems, + items, +}: Props) { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + + if (over == null) { + return; + } + + if (active.id !== over.id) { + const oldIndex = items.findIndex(({ id }) => id === active.id); + const newIndex = items.findIndex(({ id }) => id === over.id); + + setItems(arrayMove(items, oldIndex, newIndex)); + } + } + + return ( + + + {items.map((item, index) => ( + + {children(item, index)} + + ))} + + + ); +} From b439c9764b05bdabb455f82fe2b51e2b816eb8ec Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 12 Feb 2025 13:09:25 +0100 Subject: [PATCH 03/32] feat: replacing zustand store with useFormState, rename filename --- .../hooks/use-preferred-model-workspace.ts | 94 +++++++++++++++++++ .../hooks/use-preferred-preferred-model.ts | 39 -------- 2 files changed, 94 insertions(+), 39 deletions(-) create mode 100644 src/features/workspace/hooks/use-preferred-model-workspace.ts delete mode 100644 src/features/workspace/hooks/use-preferred-preferred-model.ts diff --git a/src/features/workspace/hooks/use-preferred-model-workspace.ts b/src/features/workspace/hooks/use-preferred-model-workspace.ts new file mode 100644 index 00000000..e0f8ecec --- /dev/null +++ b/src/features/workspace/hooks/use-preferred-model-workspace.ts @@ -0,0 +1,94 @@ +import { + MuxMatcherType, + MuxRule, + V1GetWorkspaceMuxesData, +} from "@/api/generated"; +import { v1GetWorkspaceMuxesOptions } from "@/api/generated/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { useFormState } from "@/hooks/useFormState"; + +export type PreferredMuxRule = MuxRule & { id: string }; + +const DEFAULT_STATE: PreferredMuxRule = { + id: "", + provider_id: "", + model: "", + matcher: "", + matcher_type: MuxMatcherType.CATCH_ALL, +}; + +const usePreferredModel = (options: { + path: { + workspace_name: string; + }; +}) => { + return useQuery({ + ...v1GetWorkspaceMuxesOptions(options), + }); +}; + +export const usePreferredModelWorkspace = (workspaceName: string) => { + const { values, updateFormValues } = useFormState<{ + rules: PreferredMuxRule[]; + }>({ + rules: [{ ...DEFAULT_STATE, id: uuidv4() }], + }); + const options: V1GetWorkspaceMuxesData & + Omit = useMemo( + () => ({ + path: { workspace_name: workspaceName }, + }), + [workspaceName], + ); + const { data, isPending } = usePreferredModel(options); + + useEffect(() => { + updateFormValues({ + rules: data?.map((item) => ({ ...item, id: uuidv4() })) ?? [ + { ...DEFAULT_STATE, id: uuidv4() }, + ], + }); + }, [data, updateFormValues]); + + const addRule = useCallback(() => { + updateFormValues({ + rules: [...values.rules, { ...DEFAULT_STATE, id: uuidv4() }], + }); + }, [values.rules, updateFormValues]); + + const setRules = useCallback( + (rules: PreferredMuxRule[]) => { + updateFormValues({ rules }); + }, + [values.rules, updateFormValues], + ); + + const setRuleItem = useCallback( + (rule: PreferredMuxRule) => { + updateFormValues({ + rules: values.rules.map((item) => (item.id === rule.id ? rule : item)), + }); + }, + [values.rules, updateFormValues], + ); + + const removeRule = useCallback( + (ruleIndex: number) => { + updateFormValues({ + rules: values.rules.filter((_, index) => index !== ruleIndex), + }); + }, + [values.rules, updateFormValues], + ); + + return { + setRuleItem, + values, + addRule, + setRules, + removeRule, + isPending, + }; +}; diff --git a/src/features/workspace/hooks/use-preferred-preferred-model.ts b/src/features/workspace/hooks/use-preferred-preferred-model.ts deleted file mode 100644 index 78ad4eab..00000000 --- a/src/features/workspace/hooks/use-preferred-preferred-model.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { MuxRule, V1GetWorkspaceMuxesData } from '@/api/generated' -import { v1GetWorkspaceMuxesOptions } from '@/api/generated/@tanstack/react-query.gen' -import { useFormState } from '@/hooks/useFormState' -import { useQuery } from '@tanstack/react-query' -import { useMemo } from 'react' - -type ModelRule = Omit & {} - -const DEFAULT_STATE = { - provider_id: '', - model: '', -} as const satisfies ModelRule - -const usePreferredModel = (options: { - path: { - workspace_name: string - } -}) => { - return useQuery({ - ...v1GetWorkspaceMuxesOptions(options), - }) -} - -export const usePreferredModelWorkspace = (workspaceName: string) => { - const options: V1GetWorkspaceMuxesData & - Omit = useMemo( - () => ({ - path: { workspace_name: workspaceName }, - }), - [workspaceName] - ) - const { data, isPending } = usePreferredModel(options) - const providerModel = data?.[0] - const formState = useFormState<{ preferredModel: ModelRule }>({ - preferredModel: providerModel ?? DEFAULT_STATE, - }) - - return { isPending, formState } -} From 883e76afc7bc84b4ee0c2031b6ed4b22e4a0387f Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 12 Feb 2025 13:12:56 +0100 Subject: [PATCH 04/32] feat: replace drag icon --- icons/drag.svg | 8 ++++++++ src/components/SortableArea.tsx | 6 +++--- src/components/icons/Drag.tsx | 15 +++++++++++++++ src/components/icons/index.ts | 1 + 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 icons/drag.svg create mode 100644 src/components/icons/Drag.tsx diff --git a/icons/drag.svg b/icons/drag.svg new file mode 100644 index 00000000..d9db1f3e --- /dev/null +++ b/icons/drag.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/SortableArea.tsx b/src/components/SortableArea.tsx index 25da9618..c4bd7646 100644 --- a/src/components/SortableArea.tsx +++ b/src/components/SortableArea.tsx @@ -16,7 +16,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useSortable } from "@dnd-kit/sortable"; -import { DotsGrid } from "@untitled-ui/icons-react"; +import { Drag } from "./icons"; type Props = { children: (item: T, index: number) => React.ReactNode; @@ -38,9 +38,9 @@ function ItemWrapper({ transition, }; return ( -
+
- +
{children}
diff --git a/src/components/icons/Drag.tsx b/src/components/icons/Drag.tsx new file mode 100644 index 00000000..2363a7f0 --- /dev/null +++ b/src/components/icons/Drag.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from "react"; +const SvgDrag = (props: SVGProps) => ( + + + +); +export default SvgDrag; diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index 6c0e0fb2..0448acf4 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -3,3 +3,4 @@ export { default as Copilot } from './Copilot' export { default as Discord } from './Discord' export { default as Github } from './Github' export { default as Youtube } from './Youtube' +export { default as Drag } from "./Drag"; From 6f313bdd7bc31403a6ddba85906ac92e349bd05d Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 12 Feb 2025 15:59:24 +0100 Subject: [PATCH 05/32] feat: add models dropdown --- .../components/workspace-models-dropdown.tsx | 162 ++++++++++++++ .../components/workspace-preferred-model.tsx | 211 ++++++++++++------ 2 files changed, 302 insertions(+), 71 deletions(-) create mode 100644 src/features/workspace/components/workspace-models-dropdown.tsx diff --git a/src/features/workspace/components/workspace-models-dropdown.tsx b/src/features/workspace/components/workspace-models-dropdown.tsx new file mode 100644 index 00000000..da129b10 --- /dev/null +++ b/src/features/workspace/components/workspace-models-dropdown.tsx @@ -0,0 +1,162 @@ +import { + ModelByProvider, + V1ListAllModelsForAllProvidersResponse, +} from "@/api/generated"; +import { PreferredMuxRule } from "@/features/workspace/hooks/use-preferred-model-workspace"; +import { + DialogTrigger, + Button, + Popover, + SearchField, + ListBox, + Input, + OptionRenderer, + OptionsSchema, +} from "@stacklok/ui-kit"; +import { ChevronDown, SearchMd } from "@untitled-ui/icons-react"; +import { useState } from "react"; + +type Props = { + rule: PreferredMuxRule; + models: V1ListAllModelsForAllProvidersResponse; + onChange: ({ + model, + provider_id, + }: { + model: string; + provider_id: string; + }) => void; +}; + +function groupModelsByProviderName( + models: ModelByProvider[], +): OptionsSchema<"listbox", string>[] { + return models.reduce[]>( + (groupedProviders, item) => { + const providerData = groupedProviders.find( + (group) => group.id === item.provider_name, + ); + if (!providerData) { + groupedProviders.push({ + id: item.provider_name, + items: [], + textValue: item.provider_name, + }); + } + + (providerData?.items ?? []).push({ + id: item.name, + textValue: item.name, + }); + + return groupedProviders; + }, + [], + ); +} + +function filterModels({ + groupedModels, + searchItem, +}: { + searchItem: string; + groupedModels: OptionsSchema<"listbox", string>[]; +}) { + return groupedModels + .map((modelData) => { + if (!searchItem) return modelData; + const filteredModels = modelData.items?.filter((item) => { + return item.textValue.includes(searchItem); + }); + + const data = { + ...modelData, + items: filteredModels, + }; + return data; + }) + .filter((item) => (item.items ? item.items.length > 0 : false)); +} + +export function WorkspaceModelsDropdown({ + rule, + models = [], + onChange, +}: Props) { + const [isOpen, setIsOpen] = useState(false); + const [searchItem, setSearchItem] = useState(""); + const groupedModels = groupModelsByProviderName(models); + const currentProvider = models.find( + (p) => p.provider_id === rule.provider_id, + ); + const currentModel = + currentProvider && rule.model + ? `${currentProvider?.provider_name}/${rule.model}` + : ""; + + return ( +
+ setIsOpen(test)}> + + + +
+
+ + } /> + +
+ + { + if (v === "all") { + return; + } + const selectedValue = v.values().next().value; + const providerId = models.find( + (item) => item.name === selectedValue, + )?.provider_id; + if (typeof selectedValue === "string" && providerId) { + onChange({ + model: selectedValue, + provider_id: providerId, + }); + + setIsOpen(false); + } + }} + className="-mx-1 my-2 max-h-80 overflow-auto" + renderEmptyState={() => ( +

No models found

+ )} + > + {({ items, id, textValue }) => ( + + )} +
+
+
+
+
+ ); +} diff --git a/src/features/workspace/components/workspace-preferred-model.tsx b/src/features/workspace/components/workspace-preferred-model.tsx index b5fd73ae..1b22d2a4 100644 --- a/src/features/workspace/components/workspace-preferred-model.tsx +++ b/src/features/workspace/components/workspace-preferred-model.tsx @@ -1,38 +1,105 @@ import { Alert, + Button, Card, CardBody, CardFooter, Form, + Input, + Label, Link, LinkButton, Text, -} from '@stacklok/ui-kit' -import { twMerge } from 'tailwind-merge' -import { useMutationPreferredModelWorkspace } from '../hooks/use-mutation-preferred-model-workspace' -import { MuxMatcherType } from '@/api/generated' -import { FormEvent } from 'react' -import { usePreferredModelWorkspace } from '../hooks/use-preferred-preferred-model' -import { Select, SelectButton } from '@stacklok/ui-kit' -import { useQueryListAllModelsForAllProviders } from '@/hooks/use-query-list-all-models-for-all-providers' -import { FormButtons } from '@/components/FormButtons' -import { invalidateQueries } from '@/lib/react-query-utils' -import { v1GetWorkspaceMuxesQueryKey } from '@/api/generated/@tanstack/react-query.gen' -import { useQueryClient } from '@tanstack/react-query' + TextField, +} from "@stacklok/ui-kit"; +import { twMerge } from "tailwind-merge"; +import { useMutationPreferredModelWorkspace } from "../hooks/use-mutation-preferred-model-workspace"; +import { V1ListAllModelsForAllProvidersResponse } from "@/api/generated"; +import { FormEvent } from "react"; +import { + PreferredMuxRule, + usePreferredModelWorkspace, +} from "../hooks/use-preferred-model-workspace"; +import { Plus, Trash01 } from "@untitled-ui/icons-react"; +import { SortableArea } from "@/components/SortableArea"; +import { WorkspaceModelsDropdown } from "./workspace-models-dropdown"; +import { useQueryListAllModelsForAllProviders } from "@/hooks/use-query-list-all-models-for-all-providers"; +import { FormButtons } from "@/components/FormButtons"; +import { invalidateQueries } from "@/lib/react-query-utils"; +import { v1GetWorkspaceMuxesQueryKey } from "@/api/generated/@tanstack/react-query.gen"; +import { useQueryClient } from "@tanstack/react-query"; function MissingProviderBanner() { return ( + // TODO needs to update the related ui-kit component that diverges from the design - - Add Provider + + Configure a provider ) } +type SortableItemProps = { + index: number; + rule: PreferredMuxRule; + models: V1ListAllModelsForAllProvidersResponse; + setRuleItem: (rule: PreferredMuxRule) => void; + removeRule: (index: number) => void; +}; + +function SortableItem({ + rule, + index, + setRuleItem, + removeRule, + models, +}: SortableItemProps) { + return ( +
+
+ event.preventDefault()} + value={rule?.matcher ?? ""} + name="matcher" + onChange={(matcher) => { + setRuleItem({ ...rule, matcher }); + }} + > + + +
+
+ + setRuleItem({ ...rule, provider_id, model }) + } + /> + {index !== 0 && ( + + )} +
+
+ ); +} + export function WorkspacePreferredModel({ className, workspaceName, @@ -42,30 +109,35 @@ export function WorkspacePreferredModel({ workspaceName: string isArchived: boolean | undefined }) { - const queryClient = useQueryClient() - const { formState, isPending } = usePreferredModelWorkspace(workspaceName) - const { mutateAsync } = useMutationPreferredModelWorkspace() - const { data: providerModels = [] } = useQueryListAllModelsForAllProviders() - const isModelsEmpty = !isPending && providerModels.length === 0 + const { + values: { rules }, + setRules, + setRuleItem, + removeRule, + addRule, + isPending, + } = usePreferredModelWorkspace(workspaceName); + const { mutateAsync } = useMutationPreferredModelWorkspace(); + const { data: providerModels = [] } = useQueryListAllModelsForAllProviders(); + const isModelsEmpty = !isPending && providerModels.length === 0; const handleSubmit = (event: FormEvent) => { - event.preventDefault() - mutateAsync( - { - path: { workspace_name: workspaceName }, - body: [ - { - matcher: '', - matcher_type: MuxMatcherType.CATCH_ALL, - ...formState.values.preferredModel, - }, - ], - }, - { - onSuccess: () => - invalidateQueries(queryClient, [v1GetWorkspaceMuxesQueryKey]), - } - ) + event.preventDefault(); + mutateAsync({ + path: { workspace_name: workspaceName }, + body: rules, + }); + }; + + if (isModelsEmpty) { + return ( + + + Preferred Model + + + + ); } return ( @@ -86,47 +158,44 @@ export function WorkspacePreferredModel({
- {isModelsEmpty && } -
-
- + +
+
+
 
+
+ +
+
+ +
+ + {(rule, index) => ( + + )} +
- - */} + {/* + /> */} + + + From 980d59085e2b76fd1521a584b5afc2842c1265fc Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 11:08:49 +0100 Subject: [PATCH 06/32] feat: create a remove queries utility fn --- src/lib/react-query-utils.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/lib/react-query-utils.ts b/src/lib/react-query-utils.ts index f920ac4a..b7a33fbb 100644 --- a/src/lib/react-query-utils.ts +++ b/src/lib/react-query-utils.ts @@ -72,3 +72,25 @@ export function getQueryCacheConfig( return lifetime satisfies never } } + +export function removeQueriesByIds({ + queryClient, + queryKeyFns, +}: { + queryClient: QueryClient; + queryKeyFns: QueryKeyFn[]; +}) { + const allQueries = queryClient.getQueriesData({}); + + const queriesToRemove = allQueries.filter(([key]) => + key.some((item) => + queryKeyFns.some( + (fn) => getQueryKeyFnId(fn) === (item as { _id: string })._id, + ), + ), + ); + + return queriesToRemove.forEach(([queryKey]) => + queryClient.removeQueries({ queryKey }), + ); +} From 0ae1d16d3903575a41d7b9d23de27d253956021d Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 11:09:54 +0100 Subject: [PATCH 07/32] refactor: simplify logic for handling dirty and reset form state --- src/hooks/useFormState.ts | 44 +++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/src/hooks/useFormState.ts b/src/hooks/useFormState.ts index c668feda..0e09569e 100644 --- a/src/hooks/useFormState.ts +++ b/src/hooks/useFormState.ts @@ -1,5 +1,5 @@ -import { isEqual } from 'lodash' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { isEqual } from "lodash"; +import { useCallback, useMemo, useRef, useState } from "react"; export type FormState = { values: T @@ -19,34 +19,28 @@ function useDeepMemo(value: T): T { export function useFormState>( initialValues: Values ): FormState { - const memoizedInitialValues = useDeepMemo(initialValues) - + const memoizedInitialValues = useDeepMemo(initialValues); // this could be replaced with some form library later - const [values, setValues] = useState(memoizedInitialValues) - const [originalValues, setOriginalValues] = useState(values) - - useEffect(() => { - // this logic supports the use case when the initialValues change - // due to an async request for instance - setOriginalValues(memoizedInitialValues) - setValues(memoizedInitialValues) - }, [memoizedInitialValues]) - - const updateFormValues = useCallback((newState: Partial) => { - setValues((prevState: Values) => ({ - ...prevState, - ...newState, - })) - }, []) + const [values, setValues] = useState(memoizedInitialValues); + + const updateFormValues = useCallback( + (newState: Partial) => { + setValues((prevState: Values) => ({ + ...prevState, + ...newState, + })); + }, + [setValues], + ); const resetForm = useCallback(() => { - setValues(originalValues) - }, [originalValues]) + setValues(memoizedInitialValues); + }, [memoizedInitialValues]); const isDirty = useMemo( - () => !isEqual(values, originalValues), - [values, originalValues] - ) + () => !isEqual(values, initialValues), + [values, initialValues], + ); const formState = useMemo( () => ({ values, updateFormValues, resetForm, isDirty }), From 71203ab3cfe3920d76faa18966a6069a8e4cfaf2 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 11:37:51 +0100 Subject: [PATCH 08/32] feat: update muxing rules --- .../workspace-preferred-model.test.tsx | 4 +- .../components/workspace-models-dropdown.tsx | 4 +- .../components/workspace-preferred-model.tsx | 37 ++++---- .../hooks/use-mutation-create-workspace.ts | 24 ++++- .../hooks/use-muxing-rules-form-workspace.ts | 63 +++++++++++++ .../hooks/use-preferred-model-workspace.ts | 94 ------------------- .../hooks/use-query-muxing-rules-workspace.ts | 20 ++++ 7 files changed, 123 insertions(+), 123 deletions(-) create mode 100644 src/features/workspace/hooks/use-muxing-rules-form-workspace.ts delete mode 100644 src/features/workspace/hooks/use-preferred-model-workspace.ts create mode 100644 src/features/workspace/hooks/use-query-muxing-rules-workspace.ts diff --git a/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx b/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx index cd87b3c2..8766b2ed 100644 --- a/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx @@ -3,7 +3,7 @@ import { screen, waitFor } from '@testing-library/react' import { WorkspacePreferredModel } from '../workspace-preferred-model' import userEvent from '@testing-library/user-event' -test('render model overrides', async () => { +test('renders muxing model', async () => { render( { }) }) -test('submit preferred model', async () => { +test('submit muxing model', async () => { render( { + void id; + return { ...rest }; + }), }); }; - + console.log(rules); if (isModelsEmpty) { return ( @@ -183,12 +186,6 @@ export function WorkspacePreferredModel({
- {/* */} - {/* */}
- +
+ + + +
From 5d769492ef69958b1f751fee75614d3a157e395f Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 12:15:25 +0100 Subject: [PATCH 11/32] refactor: remove unneeded div wrapper --- .../components/workspace-models-dropdown.tsx | 86 +++++++++---------- 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/src/features/workspace/components/workspace-models-dropdown.tsx b/src/features/workspace/components/workspace-models-dropdown.tsx index c3f4ee84..e1cf5348 100644 --- a/src/features/workspace/components/workspace-models-dropdown.tsx +++ b/src/features/workspace/components/workspace-models-dropdown.tsx @@ -106,55 +106,47 @@ export function WorkspaceModelsDropdown({ -
-
- - } /> - -
+ + } /> + - { - if (v === "all") { - return; - } - const selectedValue = v.values().next().value; - const providerId = models.find( - (item) => item.name === selectedValue, - )?.provider_id; - if (typeof selectedValue === "string" && providerId) { - onChange({ - model: selectedValue, - provider_id: providerId, - }); + { + if (v === "all") { + return; + } + const selectedValue = v.values().next().value; + const providerId = models.find( + (item) => item.name === selectedValue, + )?.provider_id; + if (typeof selectedValue === "string" && providerId) { + onChange({ + model: selectedValue, + provider_id: providerId, + }); - setIsOpen(false); - } - }} - className="-mx-1 my-2 max-h-80 overflow-auto" - renderEmptyState={() => ( -

No models found

- )} - > - {({ items, id, textValue }) => ( - - )} -
-
+ setIsOpen(false); + } + }} + className="-mx-1 mt-2 max-h-72 overflow-auto" + renderEmptyState={() => ( +

No models found

+ )} + > + {({ items, id, textValue }) => ( + + )} +
From e3c64fe7113a95ba9c8ce2aba25a8f0e9b9e2d8c Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 12:24:32 +0100 Subject: [PATCH 12/32] feat: use form buttons --- .../components/workspace-preferred-model.tsx | 25 +++++++++++-------- .../hooks/use-muxing-rules-form-workspace.ts | 5 ++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/features/workspace/components/workspace-preferred-model.tsx b/src/features/workspace/components/workspace-preferred-model.tsx index f4f5ba21..6ca270eb 100644 --- a/src/features/workspace/components/workspace-preferred-model.tsx +++ b/src/features/workspace/components/workspace-preferred-model.tsx @@ -30,6 +30,7 @@ import { PreferredMuxRule, useMuxingRulesFormState, } from "../hooks/use-muxing-rules-form-workspace"; +import { FormButtons } from "@/components/FormButtons"; function MissingProviderBanner() { return ( @@ -115,14 +116,11 @@ export function WorkspacePreferredModel({ }) { const { data: muxingRules, isPending } = useQueryMuxingRulesWorkspace(workspaceName); - + const { addRule, setRules, setRuleItem, removeRule, formState } = + useMuxingRulesFormState(muxingRules); const { - addRule, - setRules, - setRuleItem, - removeRule, values: { rules }, - } = useMuxingRulesFormState(muxingRules); + } = formState; const { mutateAsync } = useMutationPreferredModelWorkspace(); const { data: providerModels = [] } = useQueryListAllModelsForAllProviders(); @@ -202,7 +200,12 @@ export function WorkspacePreferredModel({
- @@ -210,9 +213,11 @@ export function WorkspacePreferredModel({ Manage providers
- +
diff --git a/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts index 409ecba6..71ab2afc 100644 --- a/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts +++ b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts @@ -14,13 +14,14 @@ const DEFAULT_STATE: PreferredMuxRule = { }; export const useMuxingRulesFormState = (initialValues: MuxRule[]) => { - const { values, updateFormValues } = useFormState<{ + const formState = useFormState<{ rules: PreferredMuxRule[]; }>({ rules: initialValues.map((item) => ({ ...item, id: uuidv4() })) ?? [ { ...DEFAULT_STATE, id: uuidv4() }, ], }); + const { values, updateFormValues } = formState; useEffect(() => { updateFormValues({ @@ -59,5 +60,5 @@ export const useMuxingRulesFormState = (initialValues: MuxRule[]) => { [updateFormValues, values.rules], ); - return { addRule, setRules, setRuleItem, removeRule, values }; + return { addRule, setRules, setRuleItem, removeRule, values, formState }; }; From 90c399ce583cfb3d8210ac6fcc6aa085d9442736 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 12:30:57 +0100 Subject: [PATCH 13/32] leftover --- src/features/workspace/components/workspace-preferred-model.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/workspace/components/workspace-preferred-model.tsx b/src/features/workspace/components/workspace-preferred-model.tsx index 6ca270eb..a983af07 100644 --- a/src/features/workspace/components/workspace-preferred-model.tsx +++ b/src/features/workspace/components/workspace-preferred-model.tsx @@ -137,7 +137,7 @@ export function WorkspacePreferredModel({ }), }); }; - console.log(rules); + if (isModelsEmpty) { return ( From 9f4e2a0e930889df15ade9130ca60b66234e69a2 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 16:06:12 +0100 Subject: [PATCH 14/32] feat: change notfication message --- .../workspace/hooks/use-mutation-preferred-model-workspace.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/workspace/hooks/use-mutation-preferred-model-workspace.ts b/src/features/workspace/hooks/use-mutation-preferred-model-workspace.ts index 3ae1a267..51710e87 100644 --- a/src/features/workspace/hooks/use-mutation-preferred-model-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-preferred-model-workspace.ts @@ -10,6 +10,6 @@ export function useMutationPreferredModelWorkspace() { await invalidate() }, successMsg: (variables) => - `Preferred model for ${variables.path.workspace_name} updated`, - }) + `Muxing rules for ${variables.path.workspace_name} updated`, + }); } From 9c53ff9c4bea05a1bd51978e44dbb21a195c35dd Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 16:06:34 +0100 Subject: [PATCH 15/32] chore: update msw handlers --- src/mocks/msw/handlers.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts index 8a931761..fbf0e0f8 100644 --- a/src/mocks/msw/handlers.ts +++ b/src/mocks/msw/handlers.ts @@ -105,20 +105,8 @@ export const handlers = [ mswEndpoint('/api/v1/workspaces/:workspace_name/custom-instructions'), () => new HttpResponse(null, { status: 204 }) ), - http.get(mswEndpoint('/api/v1/workspaces/:workspace_name/muxes'), () => - HttpResponse.json([ - { - provider_id: 'openai', - model: 'gpt-3.5-turbo', - matcher_type: 'file_regex', - matcher: '.*\\.txt', - }, - { - provider_id: 'anthropic', - model: 'davinci', - matcher_type: 'catch_all', - }, - ]) + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/muxes"), () => + HttpResponse.json([]), ), http.put( mswEndpoint('/api/v1/workspaces/:workspace_name/muxes'), From 3580f9456f07567c340a6bf5be4689f64ad93e0a Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 16:07:14 +0100 Subject: [PATCH 16/32] fix: update rules and add tests case --- .../workspace-preferred-model.test.tsx | 110 ++++++++++++++---- .../components/workspace-models-dropdown.tsx | 8 +- .../components/workspace-preferred-model.tsx | 7 +- .../hooks/use-muxing-rules-form-workspace.ts | 6 +- src/hooks/useFormState.ts | 15 +-- 5 files changed, 109 insertions(+), 37 deletions(-) diff --git a/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx b/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx index 8766b2ed..f605f9e3 100644 --- a/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx @@ -8,24 +8,58 @@ test('renders muxing model', async () => { - ) - expect(screen.getByText(/preferred model/i)).toBeVisible() + />, + ); + expect(screen.getByText(/model muxing/i)).toBeVisible(); expect( screen.getByText( - /select the model you would like to use in this workspace./i - ) - ).toBeVisible() + /filters will be applied in order \(top to bottom\), empty filters will apply to all\./i, + ), + ).toBeVisible(); expect( - screen.getByRole('button', { name: /select the model/i }) - ).toBeVisible() + screen.getByRole("link", { + name: /learn more/i, + }), + ).toBeVisible(); - await waitFor(() => { - expect(screen.getByRole('button', { name: /save/i })).toBeVisible() - }) -}) + await userEvent.type( + screen.getByRole("textbox", { + name: /filter by/i, + }), + ".tsx", + ); + + await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)); + await userEvent.click( + screen.getByRole("option", { + name: /claude-3.6/i, + }), + ); -test('submit muxing model', async () => { + expect(screen.getByRole("button", { name: /add filter/i })).toBeVisible(); + expect( + screen.getByRole("button", { name: /manage providers/i }), + ).toBeVisible(); + expect(screen.getByRole("button", { name: /revert changes/i })).toBeVisible(); + expect(screen.getByRole("button", { name: /save/i })).toBeVisible(); +}); + +test("disabled muxing fields and buttons for archived workspace", async () => { + render( + , + ); + + expect(await screen.findByRole("button", { name: /save/i })).toBeDisabled(); + expect(screen.getByTestId(/workspace-models-dropdown/i)).toBeDisabled(); + expect( + await screen.findByRole("button", { name: /add filter/i }), + ).toBeDisabled(); +}); + +test("submit additional model overrides", async () => { render( { /> ) + expect(screen.getAllByRole("textbox", { name: /filter by/i }).length).toEqual( + 1, + ); + await userEvent.type( + screen.getByRole("textbox", { + name: /filter by/i, + }), + ".tsx", + ); + await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)); await userEvent.click( - screen.getByRole('button', { name: /select the model/i }) - ) + screen.getByRole("option", { + name: /claude-3.6/i, + }), + ); + + await userEvent.click(screen.getByRole("button", { name: /add filter/i })); + const textFields = await screen.findAllByRole("textbox", { + name: /filter by/i, + }); + expect(textFields.length).toEqual(2); + const modelsButton = await screen.findAllByTestId( + /workspace-models-dropdown/i, + ); + expect(modelsButton.length).toEqual(2); + + await userEvent.type(textFields[1] as HTMLFormElement, ".ts"); await userEvent.click( - screen.getByRole('option', { - name: 'anthropic/claude-3.5', - }) - ) + (await screen.findByRole("button", { + name: /select a model/i, + })) as HTMLFormElement, + ); + + await userEvent.click( + screen.getByRole("option", { + name: /chatgpt-4p/i, + }), + ); await userEvent.click(screen.getByRole('button', { name: /save/i })) await waitFor(() => { - expect(screen.getByText(/preferred model for fake-workspace updated/i)) - }) -}) + expect( + screen.getByText(/muxing rules for fake-workspace updated/i), + ).toBeVisible(); + }); +}); diff --git a/src/features/workspace/components/workspace-models-dropdown.tsx b/src/features/workspace/components/workspace-models-dropdown.tsx index e1cf5348..529d0002 100644 --- a/src/features/workspace/components/workspace-models-dropdown.tsx +++ b/src/features/workspace/components/workspace-models-dropdown.tsx @@ -18,6 +18,7 @@ import { useState } from "react"; type Props = { rule: MuxRule & { id: string }; + isArchived: boolean; models: V1ListAllModelsForAllProvidersResponse; onChange: ({ model, @@ -80,6 +81,7 @@ function filterModels({ export function WorkspaceModelsDropdown({ rule, + isArchived, models = [], onChange, }: Props) { @@ -99,10 +101,12 @@ export function WorkspaceModelsDropdown({ setIsOpen(test)}> diff --git a/src/features/workspace/components/workspace-preferred-model.tsx b/src/features/workspace/components/workspace-preferred-model.tsx index a983af07..8a25d3f4 100644 --- a/src/features/workspace/components/workspace-preferred-model.tsx +++ b/src/features/workspace/components/workspace-preferred-model.tsx @@ -54,6 +54,7 @@ type SortableItemProps = { index: number; rule: PreferredMuxRule; models: V1ListAllModelsForAllProvidersResponse; + isArchived: boolean; showRemoveButton: boolean; setRuleItem: (rule: PreferredMuxRule) => void; removeRule: (index: number) => void; @@ -66,6 +67,7 @@ function SortableItem({ removeRule, models, showRemoveButton, + isArchived, }: SortableItemProps) { return (
@@ -74,6 +76,7 @@ function SortableItem({ aria-labelledby="filter-by-label-id" onFocus={(event) => event.preventDefault()} value={rule?.matcher ?? ""} + isDisabled={isArchived} name="matcher" onChange={(matcher) => { setRuleItem({ ...rule, matcher }); @@ -85,6 +88,7 @@ function SortableItem({
setRuleItem({ ...rule, provider_id, model }) @@ -142,7 +146,7 @@ export function WorkspacePreferredModel({ return ( - Preferred Model + Model Muxing @@ -193,6 +197,7 @@ export function WorkspacePreferredModel({ removeRule={removeRule} models={providerModels} showRemoveButton={showRemoveButton} + isArchived={isArchived} /> )} diff --git a/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts index 71ab2afc..57002de1 100644 --- a/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts +++ b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts @@ -17,13 +17,13 @@ export const useMuxingRulesFormState = (initialValues: MuxRule[]) => { const formState = useFormState<{ rules: PreferredMuxRule[]; }>({ - rules: initialValues.map((item) => ({ ...item, id: uuidv4() })) ?? [ - { ...DEFAULT_STATE, id: uuidv4() }, - ], + rules: [{ ...DEFAULT_STATE, id: uuidv4() }], }); const { values, updateFormValues } = formState; useEffect(() => { + if (initialValues.length === 0) return; + updateFormValues({ rules: initialValues.map((item) => ({ ...item, id: uuidv4() })), }); diff --git a/src/hooks/useFormState.ts b/src/hooks/useFormState.ts index 0e09569e..f896cdfc 100644 --- a/src/hooks/useFormState.ts +++ b/src/hooks/useFormState.ts @@ -23,15 +23,12 @@ export function useFormState>( // this could be replaced with some form library later const [values, setValues] = useState(memoizedInitialValues); - const updateFormValues = useCallback( - (newState: Partial) => { - setValues((prevState: Values) => ({ - ...prevState, - ...newState, - })); - }, - [setValues], - ); + const updateFormValues = useCallback((newState: Partial) => { + setValues((prevState: Values) => { + if (isEqual(newState, prevState)) return prevState; + return { ...prevState, ...newState }; + }); + }, []); const resetForm = useCallback(() => { setValues(memoizedInitialValues); From 56e0e248fdccf2e6af3b11840fbd49bf1ed0e8d6 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 17:08:39 +0100 Subject: [PATCH 17/32] refactor: rename filename --- ...st.tsx => workspace-muxing-model.test.tsx} | 29 ++++++------------- src/routes/route-workspace.tsx | 21 +++++++------- 2 files changed, 19 insertions(+), 31 deletions(-) rename src/features/workspace/components/__tests__/{workspace-preferred-model.test.tsx => workspace-muxing-model.test.tsx} (80%) diff --git a/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx b/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx similarity index 80% rename from src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx rename to src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx index f605f9e3..53118116 100644 --- a/src/features/workspace/components/__tests__/workspace-preferred-model.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx @@ -1,14 +1,11 @@ -import { render } from '@/lib/test-utils' -import { screen, waitFor } from '@testing-library/react' -import { WorkspacePreferredModel } from '../workspace-preferred-model' -import userEvent from '@testing-library/user-event' +import { render } from "@/lib/test-utils"; +import { screen, waitFor } from "@testing-library/react"; +import { WorkspaceMuxingModel } from "../workspace-muxing-model"; +import userEvent from "@testing-library/user-event"; test('renders muxing model', async () => { render( - , + , ); expect(screen.getByText(/model muxing/i)).toBeVisible(); expect( @@ -37,19 +34,14 @@ test('renders muxing model', async () => { ); expect(screen.getByRole("button", { name: /add filter/i })).toBeVisible(); - expect( - screen.getByRole("button", { name: /manage providers/i }), - ).toBeVisible(); + expect(screen.getByRole("link", { name: /manage providers/i })).toBeVisible(); expect(screen.getByRole("button", { name: /revert changes/i })).toBeVisible(); expect(screen.getByRole("button", { name: /save/i })).toBeVisible(); }); test("disabled muxing fields and buttons for archived workspace", async () => { render( - , + , ); expect(await screen.findByRole("button", { name: /save/i })).toBeDisabled(); @@ -61,11 +53,8 @@ test("disabled muxing fields and buttons for archived workspace", async () => { test("submit additional model overrides", async () => { render( - - ) + , + ); expect(screen.getAllByRole("textbox", { name: /filter by/i }).length).toEqual( 1, diff --git a/src/routes/route-workspace.tsx b/src/routes/route-workspace.tsx index 9cea7aa1..3de4e14f 100644 --- a/src/routes/route-workspace.tsx +++ b/src/routes/route-workspace.tsx @@ -1,15 +1,14 @@ import { BreadcrumbHome } from '@/components/BreadcrumbHome' import { ArchiveWorkspace } from '@/features/workspace/components/archive-workspace' - -import { PageHeading } from '@/components/heading' -import { WorkspaceName } from '@/features/workspace/components/workspace-name' -import { Alert, Breadcrumb, Breadcrumbs } from '@stacklok/ui-kit' -import { useParams } from 'react-router-dom' -import { useArchivedWorkspaces } from '@/features/workspace/hooks/use-archived-workspaces' -import { useRestoreWorkspaceButton } from '@/features/workspace/hooks/use-restore-workspace-button' -import { WorkspaceCustomInstructions } from '@/features/workspace/components/workspace-custom-instructions' -import { WorkspacePreferredModel } from '@/features/workspace/components/workspace-preferred-model' -import { PageContainer } from '@/components/page-container' +import { PageHeading } from "@/components/heading"; +import { WorkspaceName } from "@/features/workspace/components/workspace-name"; +import { Alert, Breadcrumb, Breadcrumbs } from "@stacklok/ui-kit"; +import { useParams } from "react-router-dom"; +import { useArchivedWorkspaces } from "@/features/workspace/hooks/use-archived-workspaces"; +import { useRestoreWorkspaceButton } from "@/features/workspace/hooks/use-restore-workspace-button"; +import { WorkspaceCustomInstructions } from "@/features/workspace/components/workspace-custom-instructions"; +import { WorkspaceMuxingModel } from "@/features/workspace/components/workspace-muxing-model"; +import { PageContainer } from "@/components/page-container"; function WorkspaceArchivedBanner({ name }: { name: string }) { const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName: name }) @@ -54,7 +53,7 @@ export function RouteWorkspace() { className="mb-4" workspaceName={name} /> - Date: Thu, 13 Feb 2025 17:09:36 +0100 Subject: [PATCH 18/32] refactor: revert changes --- .../workspace-custom-instructions.tsx | 30 +-- .../components/workspace-muxing-model.tsx | 237 ++++++++++++++++++ .../workspace/components/workspace-name.tsx | 11 +- .../hooks/use-muxing-rules-form-workspace.ts | 7 +- src/hooks/useFormState.ts | 40 ++- 5 files changed, 282 insertions(+), 43 deletions(-) create mode 100644 src/features/workspace/components/workspace-muxing-model.tsx diff --git a/src/features/workspace/components/workspace-custom-instructions.tsx b/src/features/workspace/components/workspace-custom-instructions.tsx index 6d303caa..7ebb8997 100644 --- a/src/features/workspace/components/workspace-custom-instructions.tsx +++ b/src/features/workspace/components/workspace-custom-instructions.tsx @@ -125,10 +125,10 @@ function useCustomInstructionsValue({ }) { const initialFormValues = useMemo( () => ({ prompt: initialValue }), - [initialValue] - ) - const formState = useFormState(initialFormValues) - const { values, updateFormValues } = formState + [initialValue], + ); + const formState = useFormState(initialFormValues); + const { values, setInitialValues } = formState; // Subscribe to changes in the workspace system prompt value in the query cache useEffect(() => { @@ -145,14 +145,14 @@ function useCustomInstructionsValue({ const prompt: string | null = getCustomInstructionsFromEvent(event) if (prompt === values.prompt || prompt === null) return - updateFormValues({ prompt }) + setInitialValues({ prompt }); } }) return () => { - return unsubscribe() - } - }, [options, queryClient, updateFormValues, values.prompt]) + return unsubscribe(); + }; + }, [options, queryClient, setInitialValues, values.prompt]); return { ...formState } } @@ -309,15 +309,17 @@ export function WorkspaceCustomInstructions({ mutateAsync( { ...options, body: { prompt: value } }, { - onSuccess: () => + onSuccess: () => { + formState.setInitialValues({ prompt: values.prompt }); invalidateQueries(queryClient, [ v1GetWorkspaceCustomInstructionsQueryKey, - ]), - } - ) + ]); + }, + }, + ); }, - [mutateAsync, options, queryClient] - ) + [formState, mutateAsync, options, queryClient, values.prompt], + ); return (
+ + Configure a provider + + + ); +} + +type SortableItemProps = { + index: number; + rule: PreferredMuxRule; + models: V1ListAllModelsForAllProvidersResponse; + isArchived: boolean; + showRemoveButton: boolean; + setRuleItem: (rule: PreferredMuxRule) => void; + removeRule: (index: number) => void; +}; + +function SortableItem({ + rule, + index, + setRuleItem, + removeRule, + models, + showRemoveButton, + isArchived, +}: SortableItemProps) { + return ( +
+
+ event.preventDefault()} + value={rule?.matcher ?? ""} + isDisabled={isArchived} + name="matcher" + onChange={(matcher) => { + setRuleItem({ ...rule, matcher }); + }} + > + + +
+
+ + setRuleItem({ ...rule, provider_id, model }) + } + /> + {showRemoveButton && ( + + )} +
+
+ ); +} + +export function WorkspaceMuxingModel({ + className, + workspaceName, + isArchived, +}: { + className?: string; + workspaceName: string; + isArchived: boolean | undefined; +}) { + const { data: muxingRules, isPending } = + useQueryMuxingRulesWorkspace(workspaceName); + const { addRule, setRules, setRuleItem, removeRule, formState } = + useMuxingRulesFormState(muxingRules); + const { + values: { rules }, + } = formState; + + const { mutateAsync } = useMutationPreferredModelWorkspace(); + const { data: providerModels = [] } = useQueryListAllModelsForAllProviders(); + const isModelsEmpty = !isPending && providerModels.length === 0; + const showRemoveButton = rules.length > 1; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + mutateAsync( + { + path: { workspace_name: workspaceName }, + body: rules.map(({ id, ...rest }) => { + void id; + return { ...rest }; + }), + }, + { + onSuccess: () => { + formState.setInitialValues({ rules }); + }, + }, + ); + }; + + if (isModelsEmpty) { + return ( + + + Model Muxing + + + + ); + } + + return ( + + + +
+ Model Muxing + + Filters will be applied in order (top to bottom), empty filters + will apply to all. + + Learn more + + +
+ +
+
+
 
+
+ +
+
+ +
+
+ + {(rule, index) => ( + + )} + +
+
+ +
+ + + + Manage providers + +
+ +
+
+
+ ); +} diff --git a/src/features/workspace/components/workspace-name.tsx b/src/features/workspace/components/workspace-name.tsx index 8b9ac5a2..a86b4803 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -39,10 +39,13 @@ export function WorkspaceName({ mutateAsync( { body: { name: workspaceName, rename_to: values.workspaceName } }, { - onSuccess: () => navigate(`/workspace/${values.workspaceName}`), - } - ) - } + onSuccess: () => { + formState.setInitialValues({ workspaceName: values.workspaceName }); + navigate(`/workspace/${values.workspaceName}`); + }, + }, + ); + }; return (
{ }>({ rules: [{ ...DEFAULT_STATE, id: uuidv4() }], }); - const { values, updateFormValues } = formState; + const { values, updateFormValues, setInitialValues } = formState; useEffect(() => { if (initialValues.length === 0) return; - - updateFormValues({ + setInitialValues({ rules: initialValues.map((item) => ({ ...item, id: uuidv4() })), }); - }, [initialValues, updateFormValues]); + }, [initialValues, setInitialValues]); const addRule = useCallback(() => { updateFormValues({ diff --git a/src/hooks/useFormState.ts b/src/hooks/useFormState.ts index f896cdfc..14c0d0a2 100644 --- a/src/hooks/useFormState.ts +++ b/src/hooks/useFormState.ts @@ -2,26 +2,24 @@ import { isEqual } from "lodash"; import { useCallback, useMemo, useRef, useState } from "react"; export type FormState = { - values: T - updateFormValues: (newState: Partial) => void - resetForm: () => void - isDirty: boolean -} - -function useDeepMemo(value: T): T { - const ref = useRef(value) - if (!isEqual(ref.current, value)) { - ref.current = value - } - return ref.current -} + values: T; + updateFormValues: (newState: Partial) => void; + setInitialValues: (newState: T) => void; + resetForm: () => void; + isDirty: boolean; +}; export function useFormState>( initialValues: Values ): FormState { - const memoizedInitialValues = useDeepMemo(initialValues); + const memoizedInitialValues = useRef(initialValues); // this could be replaced with some form library later - const [values, setValues] = useState(memoizedInitialValues); + const [values, setValues] = useState(memoizedInitialValues.current); + + const setInitialValues = useCallback((newInitialValues: Values) => { + memoizedInitialValues.current = newInitialValues; + setValues(newInitialValues); + }, []); const updateFormValues = useCallback((newState: Partial) => { setValues((prevState: Values) => { @@ -31,18 +29,18 @@ export function useFormState>( }, []); const resetForm = useCallback(() => { - setValues(memoizedInitialValues); + setValues(memoizedInitialValues.current); }, [memoizedInitialValues]); const isDirty = useMemo( - () => !isEqual(values, initialValues), - [values, initialValues], + () => !isEqual(values, memoizedInitialValues.current), + [values, memoizedInitialValues], ); const formState = useMemo( - () => ({ values, updateFormValues, resetForm, isDirty }), - [values, updateFormValues, resetForm, isDirty] - ) + () => ({ values, updateFormValues, resetForm, isDirty, setInitialValues }), + [values, updateFormValues, resetForm, isDirty, setInitialValues], + ); return formState } From 33a93f697b30a8012406ae0577ccb61f9f6cf80f Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 18:17:10 +0100 Subject: [PATCH 19/32] chore: update openapi --- src/api/generated/types.gen.ts | 19 ++- src/api/openapi.json | 263 ++++++++++++++++++++++++++------- 2 files changed, 219 insertions(+), 63 deletions(-) diff --git a/src/api/generated/types.gen.ts b/src/api/generated/types.gen.ts index 6334e21d..5f2dc9be 100644 --- a/src/api/generated/types.gen.ts +++ b/src/api/generated/types.gen.ts @@ -35,11 +35,11 @@ export type Alert = { | { [key: string]: unknown } - | null - trigger_type: string - trigger_category: string | null - timestamp: string -} + | null; + trigger_type: string; + trigger_category: AlertSeverity; + timestamp: string; +}; /** * Represents an alert with it's respective conversation. @@ -59,6 +59,11 @@ export type AlertConversation = { timestamp: string } +export enum AlertSeverity { + INFO = "info", + CRITICAL = "critical", +} + /** * Represents a chat message. */ @@ -140,7 +145,9 @@ export type ModelByProvider = { * Represents the different types of matchers we support. */ export enum MuxMatcherType { - CATCH_ALL = 'catch_all', + CATCH_ALL = "catch_all", + FILENAME_MATCH = "filename_match", + REQUEST_TYPE_MATCH = "request_type_match", } /** diff --git a/src/api/openapi.json b/src/api/openapi.json index e587d68e..cde65b55 100644 --- a/src/api/openapi.json +++ b/src/api/openapi.json @@ -8,7 +8,9 @@ "paths": { "/health": { "get": { - "tags": ["System"], + "tags": [ + "System" + ], "summary": "Health Check", "operationId": "health_check_health_get", "responses": { @@ -25,7 +27,10 @@ }, "/api/v1/provider-endpoints": { "get": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "List Provider Endpoints", "description": "List all provider endpoints.", "operationId": "v1_list_provider_endpoints", @@ -75,7 +80,10 @@ } }, "post": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Add Provider Endpoint", "description": "Add a provider endpoint.", "operationId": "v1_add_provider_endpoint", @@ -115,7 +123,10 @@ }, "/api/v1/provider-endpoints/models": { "get": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "List All Models For All Providers", "description": "List all models for all providers.", "operationId": "v1_list_all_models_for_all_providers", @@ -139,7 +150,10 @@ }, "/api/v1/provider-endpoints/{provider_id}/models": { "get": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "List Models By Provider", "description": "List models by provider.", "operationId": "v1_list_models_by_provider", @@ -185,7 +199,10 @@ }, "/api/v1/provider-endpoints/{provider_id}": { "get": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Get Provider Endpoint", "description": "Get a provider endpoint by ID.", "operationId": "v1_get_provider_endpoint", @@ -225,7 +242,10 @@ } }, "put": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Update Provider Endpoint", "description": "Update a provider endpoint by ID.", "operationId": "v1_update_provider_endpoint", @@ -275,7 +295,10 @@ } }, "delete": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Delete Provider Endpoint", "description": "Delete a provider endpoint by id.", "operationId": "v1_delete_provider_endpoint", @@ -315,7 +338,10 @@ }, "/api/v1/provider-endpoints/{provider_id}/auth-material": { "put": { - "tags": ["CodeGate API", "Providers"], + "tags": [ + "CodeGate API", + "Providers" + ], "summary": "Configure Auth Material", "description": "Configure auth material for a provider.", "operationId": "v1_configure_auth_material", @@ -360,7 +386,10 @@ }, "/api/v1/workspaces": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "List Workspaces", "description": "List all workspaces.", "operationId": "v1_list_workspaces", @@ -378,7 +407,10 @@ } }, "post": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Create Workspace", "description": "Create a new workspace.", "operationId": "v1_create_workspace", @@ -418,7 +450,10 @@ }, "/api/v1/workspaces/active": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "List Active Workspaces", "description": "List all active workspaces.\n\nIn it's current form, this function will only return one workspace. That is,\nthe globally active workspace.", "operationId": "v1_list_active_workspaces", @@ -436,7 +471,10 @@ } }, "post": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Activate Workspace", "description": "Activate a workspace by name.", "operationId": "v1_activate_workspace", @@ -485,7 +523,10 @@ }, "/api/v1/workspaces/{workspace_name}": { "delete": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Delete Workspace", "description": "Delete a workspace by name.", "operationId": "v1_delete_workspace", @@ -524,7 +565,10 @@ }, "/api/v1/workspaces/archive": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "List Archived Workspaces", "description": "List all archived workspaces.", "operationId": "v1_list_archived_workspaces", @@ -544,7 +588,10 @@ }, "/api/v1/workspaces/archive/{workspace_name}/recover": { "post": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Recover Workspace", "description": "Recover an archived workspace by name.", "operationId": "v1_recover_workspace", @@ -578,7 +625,10 @@ }, "/api/v1/workspaces/archive/{workspace_name}": { "delete": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Hard Delete Workspace", "description": "Hard delete an archived workspace by name.", "operationId": "v1_hard_delete_workspace", @@ -617,7 +667,10 @@ }, "/api/v1/workspaces/{workspace_name}/alerts": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Get Workspace Alerts", "description": "Get alerts for a workspace.", "operationId": "v1_get_workspace_alerts", @@ -669,7 +722,10 @@ }, "/api/v1/workspaces/{workspace_name}/messages": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Get Workspace Messages", "description": "Get messages for a workspace.", "operationId": "v1_get_workspace_messages", @@ -714,7 +770,10 @@ }, "/api/v1/workspaces/{workspace_name}/custom-instructions": { "get": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Get Workspace Custom Instructions", "description": "Get the custom instructions of a workspace.", "operationId": "v1_get_workspace_custom_instructions", @@ -753,7 +812,10 @@ } }, "put": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Set Workspace Custom Instructions", "operationId": "v1_set_workspace_custom_instructions", "parameters": [ @@ -794,7 +856,10 @@ } }, "delete": { - "tags": ["CodeGate API", "Workspaces"], + "tags": [ + "CodeGate API", + "Workspaces" + ], "summary": "Delete Workspace Custom Instructions", "operationId": "v1_delete_workspace_custom_instructions", "parameters": [ @@ -827,7 +892,11 @@ }, "/api/v1/workspaces/{workspace_name}/muxes": { "get": { - "tags": ["CodeGate API", "Workspaces", "Muxes"], + "tags": [ + "CodeGate API", + "Workspaces", + "Muxes" + ], "summary": "Get Workspace Muxes", "description": "Get the mux rules of a workspace.\n\nThe list is ordered in order of priority. That is, the first rule in the list\nhas the highest priority.", "operationId": "v1_get_workspace_muxes", @@ -870,7 +939,11 @@ } }, "put": { - "tags": ["CodeGate API", "Workspaces", "Muxes"], + "tags": [ + "CodeGate API", + "Workspaces", + "Muxes" + ], "summary": "Set Workspace Muxes", "description": "Set the mux rules of a workspace.", "operationId": "v1_set_workspace_muxes", @@ -918,7 +991,10 @@ }, "/api/v1/alerts_notification": { "get": { - "tags": ["CodeGate API", "Dashboard"], + "tags": [ + "CodeGate API", + "Dashboard" + ], "summary": "Stream Sse", "description": "Send alerts event", "operationId": "v1_stream_sse", @@ -936,7 +1012,10 @@ }, "/api/v1/version": { "get": { - "tags": ["CodeGate API", "Dashboard"], + "tags": [ + "CodeGate API", + "Dashboard" + ], "summary": "Version Check", "operationId": "v1_version_check", "responses": { @@ -953,7 +1032,11 @@ }, "/api/v1/workspaces/{workspace_name}/token-usage": { "get": { - "tags": ["CodeGate API", "Workspaces", "Token Usage"], + "tags": [ + "CodeGate API", + "Workspaces", + "Token Usage" + ], "summary": "Get Workspace Token Usage", "description": "Get the token usage of a workspace.", "operationId": "v1_get_workspace_token_usage", @@ -1003,7 +1086,9 @@ } }, "type": "object", - "required": ["name"], + "required": [ + "name" + ], "title": "ActivateWorkspaceRequest" }, "ActiveWorkspace": { @@ -1021,7 +1106,11 @@ } }, "type": "object", - "required": ["name", "is_active", "last_updated"], + "required": [ + "name", + "is_active", + "last_updated" + ], "title": "ActiveWorkspace" }, "AddProviderEndpointRequest": { @@ -1072,7 +1161,10 @@ } }, "type": "object", - "required": ["name", "provider_type"], + "required": [ + "name", + "provider_type" + ], "title": "AddProviderEndpointRequest", "description": "Represents a request to add a provider endpoint." }, @@ -1115,15 +1207,7 @@ "title": "Trigger Type" }, "trigger_category": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Trigger Category" + "$ref": "#/components/schemas/AlertSeverity" }, "timestamp": { "type": "string", @@ -1211,6 +1295,14 @@ "title": "AlertConversation", "description": "Represents an alert with it's respective conversation." }, + "AlertSeverity": { + "type": "string", + "enum": [ + "info", + "critical" + ], + "title": "AlertSeverity" + }, "ChatMessage": { "properties": { "message": { @@ -1228,7 +1320,11 @@ } }, "type": "object", - "required": ["message", "timestamp", "message_id"], + "required": [ + "message", + "timestamp", + "message_id" + ], "title": "ChatMessage", "description": "Represents a chat message." }, @@ -1281,7 +1377,11 @@ } }, "type": "object", - "required": ["code", "language", "filepath"], + "required": [ + "code", + "language", + "filepath" + ], "title": "CodeSnippet", "description": "Represents a code snippet with its programming language.\n\nArgs:\n language: The programming language identifier (e.g., 'python', 'javascript')\n code: The actual code content" }, @@ -1303,7 +1403,9 @@ } }, "type": "object", - "required": ["auth_type"], + "required": [ + "auth_type" + ], "title": "ConfigureAuthMaterial", "description": "Represents a request to configure auth material for a provider." }, @@ -1389,7 +1491,9 @@ } }, "type": "object", - "required": ["name"], + "required": [ + "name" + ], "title": "CreateOrRenameWorkspaceRequest" }, "CustomInstructions": { @@ -1400,7 +1504,9 @@ } }, "type": "object", - "required": ["prompt"], + "required": [ + "prompt" + ], "title": "CustomInstructions" }, "HTTPValidationError": { @@ -1427,7 +1533,9 @@ } }, "type": "object", - "required": ["workspaces"], + "required": [ + "workspaces" + ], "title": "ListActiveWorkspacesResponse" }, "ListWorkspacesResponse": { @@ -1441,7 +1549,9 @@ } }, "type": "object", - "required": ["workspaces"], + "required": [ + "workspaces" + ], "title": "ListWorkspacesResponse" }, "ModelByProvider": { @@ -1460,13 +1570,21 @@ } }, "type": "object", - "required": ["name", "provider_id", "provider_name"], + "required": [ + "name", + "provider_id", + "provider_name" + ], "title": "ModelByProvider", "description": "Represents a model supported by a provider.\n\nNote that these are auto-discovered by the provider." }, "MuxMatcherType": { "type": "string", - "enum": ["catch_all"], + "enum": [ + "catch_all", + "filename_match", + "request_type_match" + ], "title": "MuxMatcherType", "description": "Represents the different types of matchers we support." }, @@ -1496,13 +1614,21 @@ } }, "type": "object", - "required": ["provider_id", "model", "matcher_type"], + "required": [ + "provider_id", + "model", + "matcher_type" + ], "title": "MuxRule", "description": "Represents a mux rule for a provider." }, "ProviderAuthType": { "type": "string", - "enum": ["none", "passthrough", "api_key"], + "enum": [ + "none", + "passthrough", + "api_key" + ], "title": "ProviderAuthType", "description": "Represents the different types of auth we support for providers." }, @@ -1543,7 +1669,10 @@ } }, "type": "object", - "required": ["name", "provider_type"], + "required": [ + "name", + "provider_type" + ], "title": "ProviderEndpoint", "description": "Represents a provider's endpoint configuration. This\nallows us to persist the configuration for each provider,\nso we can use this for muxing messages." }, @@ -1578,13 +1707,19 @@ } }, "type": "object", - "required": ["question", "answer"], + "required": [ + "question", + "answer" + ], "title": "QuestionAnswer", "description": "Represents a question and answer pair." }, "QuestionType": { "type": "string", - "enum": ["chat", "fim"], + "enum": [ + "chat", + "fim" + ], "title": "QuestionType" }, "TokenUsage": { @@ -1628,7 +1763,10 @@ } }, "type": "object", - "required": ["tokens_by_model", "token_usage"], + "required": [ + "tokens_by_model", + "token_usage" + ], "title": "TokenUsageAggregate", "description": "Represents the tokens used. Includes the information of the tokens used by model.\n`used_tokens` are the total tokens used in the `tokens_by_model` list." }, @@ -1646,7 +1784,11 @@ } }, "type": "object", - "required": ["provider_type", "model", "token_usage"], + "required": [ + "provider_type", + "model", + "token_usage" + ], "title": "TokenUsageByModel", "description": "Represents the tokens used by a model." }, @@ -1676,7 +1818,11 @@ } }, "type": "object", - "required": ["loc", "msg", "type"], + "required": [ + "loc", + "msg", + "type" + ], "title": "ValidationError" }, "Workspace": { @@ -1691,7 +1837,10 @@ } }, "type": "object", - "required": ["name", "is_active"], + "required": [ + "name", + "is_active" + ], "title": "Workspace" } } From 6a3551ddcbd4ec19ab36ac0ba71102f2028e080f Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 18:40:16 +0100 Subject: [PATCH 20/32] fix: matcher_type for filename --- .../workspace/components/workspace-muxing-model.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/features/workspace/components/workspace-muxing-model.tsx b/src/features/workspace/components/workspace-muxing-model.tsx index 6c4ac43d..b73db943 100644 --- a/src/features/workspace/components/workspace-muxing-model.tsx +++ b/src/features/workspace/components/workspace-muxing-model.tsx @@ -14,7 +14,10 @@ import { } from "@stacklok/ui-kit"; import { twMerge } from "tailwind-merge"; import { useMutationPreferredModelWorkspace } from "../hooks/use-mutation-preferred-model-workspace"; -import { V1ListAllModelsForAllProvidersResponse } from "@/api/generated"; +import { + MuxMatcherType, + V1ListAllModelsForAllProvidersResponse, +} from "@/api/generated"; import { FormEvent } from "react"; import { LayersThree01, @@ -138,7 +141,10 @@ export function WorkspaceMuxingModel({ path: { workspace_name: workspaceName }, body: rules.map(({ id, ...rest }) => { void id; - return { ...rest }; + + return rest.matcher + ? { ...rest, matcher_type: MuxMatcherType.FILENAME_MATCH } + : { ...rest }; }), }, { From 02dbfadcc96d743726fa19f95ed63c929920c2e0 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 19:51:41 +0100 Subject: [PATCH 21/32] feat: set the default muxing rule as catch all --- src/components/SortableArea.tsx | 21 +++++-- .../components/workspace-muxing-model.tsx | 62 +++++++++++++------ 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/components/SortableArea.tsx b/src/components/SortableArea.tsx index c4bd7646..7b936ce7 100644 --- a/src/components/SortableArea.tsx +++ b/src/components/SortableArea.tsx @@ -22,14 +22,17 @@ type Props = { children: (item: T, index: number) => React.ReactNode; setItems: (items: T[]) => void; items: T[]; + disableDragByIndex?: number; }; function ItemWrapper({ children, id, + disabledDrag, }: { children: React.ReactNode; id: UniqueIdentifier; + disabledDrag: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id }); @@ -37,11 +40,16 @@ function ItemWrapper({ transform: CSS.Transform.toString(transform), transition, }; + return (
-
- -
+ {disabledDrag ? ( +
+ ) : ( +
+ +
+ )}
{children}
); @@ -51,6 +59,7 @@ export function SortableArea({ children, setItems, items, + disableDragByIndex, }: Props) { const sensors = useSensors( useSensor(PointerSensor), @@ -82,7 +91,11 @@ export function SortableArea({ > {items.map((item, index) => ( - + {children(item, index)} ))} diff --git a/src/features/workspace/components/workspace-muxing-model.tsx b/src/features/workspace/components/workspace-muxing-model.tsx index b73db943..4a503160 100644 --- a/src/features/workspace/components/workspace-muxing-model.tsx +++ b/src/features/workspace/components/workspace-muxing-model.tsx @@ -11,6 +11,9 @@ import { LinkButton, Text, TextField, + Tooltip, + TooltipInfoButton, + TooltipTrigger, } from "@stacklok/ui-kit"; import { twMerge } from "tailwind-merge"; import { useMutationPreferredModelWorkspace } from "../hooks/use-mutation-preferred-model-workspace"; @@ -59,6 +62,7 @@ type SortableItemProps = { models: V1ListAllModelsForAllProvidersResponse; isArchived: boolean; showRemoveButton: boolean; + isDefaultRule: boolean; setRuleItem: (rule: PreferredMuxRule) => void; removeRule: (index: number) => void; }; @@ -71,7 +75,9 @@ function SortableItem({ models, showRemoveButton, isArchived, + isDefaultRule, }: SortableItemProps) { + const placeholder = isDefaultRule ? "Catch All" : "eg file type, file name"; return (
@@ -79,13 +85,13 @@ function SortableItem({ aria-labelledby="filter-by-label-id" onFocus={(event) => event.preventDefault()} value={rule?.matcher ?? ""} - isDisabled={isArchived} + isDisabled={isArchived || isDefaultRule} name="matcher" onChange={(matcher) => { setRuleItem({ ...rule, matcher }); }} > - +
@@ -97,7 +103,7 @@ function SortableItem({ setRuleItem({ ...rule, provider_id, model }) } /> - {showRemoveButton && ( + {showRemoveButton && !isDefaultRule && (
- - {(rule, index) => ( - - )} + + {(rule, index) => { + const isDefaultRule = rules.length - 1 === index; + return ( + + ); + }}
From 6dd61e66d2879984da35a3a1628ba21258bf9c21 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 20:34:32 +0100 Subject: [PATCH 22/32] fix: update workspace name and models --- .../workspace/components/workspace-name.tsx | 26 +++++++++++-------- .../hooks/use-muxing-rules-form-workspace.ts | 23 +++++++++++----- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/features/workspace/components/workspace-name.tsx b/src/features/workspace/components/workspace-name.tsx index a86b4803..805e2171 100644 --- a/src/features/workspace/components/workspace-name.tsx +++ b/src/features/workspace/components/workspace-name.tsx @@ -6,13 +6,13 @@ import { Input, Label, TextField, -} from '@stacklok/ui-kit' -import { useMutationCreateWorkspace } from '../hooks/use-mutation-create-workspace' -import { useNavigate } from 'react-router-dom' -import { twMerge } from 'tailwind-merge' -import { useFormState } from '@/hooks/useFormState' -import { FormButtons } from '@/components/FormButtons' -import { FormEvent } from 'react' +} from "@stacklok/ui-kit"; +import { useMutationCreateWorkspace } from "../hooks/use-mutation-create-workspace"; +import { useNavigate } from "react-router-dom"; +import { twMerge } from "tailwind-merge"; +import { useFormState } from "@/hooks/useFormState"; +import { FormButtons } from "@/components/FormButtons"; +import { FormEvent, useEffect } from "react"; export function WorkspaceName({ className, @@ -28,10 +28,14 @@ export function WorkspaceName({ const errorMsg = error?.detail ? `${error?.detail}` : '' const formState = useFormState({ workspaceName, - }) - const { values, updateFormValues } = formState - const isDefault = workspaceName === 'default' - const isUneditable = isArchived || isPending || isDefault + }); + const { values, updateFormValues, setInitialValues } = formState; + const isDefault = workspaceName === "default"; + const isUneditable = isArchived || isPending || isDefault; + + useEffect(() => { + setInitialValues({ workspaceName }); + }, [setInitialValues, workspaceName]); const handleSubmit = (event: FormEvent) => { event.preventDefault() diff --git a/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts index 2089f32c..09c9cd89 100644 --- a/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts +++ b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts @@ -5,8 +5,12 @@ import { v4 as uuidv4 } from "uuid"; export type PreferredMuxRule = MuxRule & { id: string }; +type MuxingRulesFormState = { + rules: PreferredMuxRule[]; +}; + const DEFAULT_STATE: PreferredMuxRule = { - id: "", + id: uuidv4(), provider_id: "", model: "", matcher: "", @@ -14,23 +18,28 @@ const DEFAULT_STATE: PreferredMuxRule = { }; export const useMuxingRulesFormState = (initialValues: MuxRule[]) => { - const formState = useFormState<{ - rules: PreferredMuxRule[]; - }>({ + const formState = useFormState({ rules: [{ ...DEFAULT_STATE, id: uuidv4() }], }); const { values, updateFormValues, setInitialValues } = formState; useEffect(() => { - if (initialValues.length === 0) return; setInitialValues({ - rules: initialValues.map((item) => ({ ...item, id: uuidv4() })), + rules: + initialValues.length == 0 + ? [DEFAULT_STATE] + : initialValues.map((item) => ({ ...item, id: uuidv4() })), }); }, [initialValues, setInitialValues]); const addRule = useCallback(() => { + const newRules = [ + ...values.rules.slice(0, values.rules.length - 1), + { ...DEFAULT_STATE, id: uuidv4() }, + ...values.rules.slice(values.rules.length - 1), + ]; updateFormValues({ - rules: [...values.rules, { ...DEFAULT_STATE, id: uuidv4() }], + rules: newRules, }); }, [updateFormValues, values.rules]); From 1e3bf2ac939b4d13c709ff45d7bc4daa7d61bf46 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 21:03:59 +0100 Subject: [PATCH 23/32] test: update muxing model --- .../__tests__/workspace-muxing-model.test.tsx | 2 +- .../hooks/use-muxing-rules-form-workspace.ts | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx b/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx index 53118116..b34410e7 100644 --- a/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx @@ -10,7 +10,7 @@ test('renders muxing model', async () => { expect(screen.getByText(/model muxing/i)).toBeVisible(); expect( screen.getByText( - /filters will be applied in order \(top to bottom\), empty filters will apply to all\./i, + /select the model you would like to use in this workspace. This section applies only if you are using the MUX endpoint./i, ), ).toBeVisible(); expect( diff --git a/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts index 09c9cd89..59c2847c 100644 --- a/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts +++ b/src/features/workspace/hooks/use-muxing-rules-form-workspace.ts @@ -1,6 +1,7 @@ import { MuxMatcherType, MuxRule } from "@/api/generated"; import { useFormState } from "@/hooks/useFormState"; -import { useCallback, useEffect } from "react"; +import { isEqual } from "lodash"; +import { useCallback, useEffect, useRef } from "react"; import { v4 as uuidv4 } from "uuid"; export type PreferredMuxRule = MuxRule & { id: string }; @@ -22,14 +23,18 @@ export const useMuxingRulesFormState = (initialValues: MuxRule[]) => { rules: [{ ...DEFAULT_STATE, id: uuidv4() }], }); const { values, updateFormValues, setInitialValues } = formState; + const lastValuesRef = useRef(values.rules); useEffect(() => { - setInitialValues({ - rules: - initialValues.length == 0 - ? [DEFAULT_STATE] - : initialValues.map((item) => ({ ...item, id: uuidv4() })), - }); + const newValues = + initialValues.length === 0 + ? [DEFAULT_STATE] + : initialValues.map((item) => ({ ...item, id: uuidv4() })); + + if (!isEqual(lastValuesRef.current, newValues)) { + lastValuesRef.current = newValues; + setInitialValues({ rules: newValues }); + } }, [initialValues, setInitialValues]); const addRule = useCallback(() => { From 7d86f5abcd9862f22141365d6f579a27e77fe100 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 21:06:06 +0100 Subject: [PATCH 24/32] fix: type --- src/mocks/msw/mockers/alert.mock.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mocks/msw/mockers/alert.mock.ts b/src/mocks/msw/mockers/alert.mock.ts index 7d77e3cc..e8a4e262 100644 --- a/src/mocks/msw/mockers/alert.mock.ts +++ b/src/mocks/msw/mockers/alert.mock.ts @@ -1,5 +1,5 @@ -import { Alert } from '@/api/generated' -import { faker } from '@faker-js/faker' +import { Alert, AlertSeverity } from "@/api/generated"; +import { faker } from "@faker-js/faker"; const ALERT_SECRET_FIELDS = { trigger_string: 'foo', @@ -24,7 +24,7 @@ const getBaseAlert = ({ id: faker.string.uuid(), prompt_id: faker.string.uuid(), code_snippet: null, - trigger_category: 'critical', + trigger_category: AlertSeverity.CRITICAL, timestamp: timestamp, }) From 97b7c33d0ee3cb677928c4e1fd4ab04b59c29d35 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 21:07:48 +0100 Subject: [PATCH 25/32] fix: drag sortable area --- src/components/SortableArea.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/SortableArea.tsx b/src/components/SortableArea.tsx index 7b936ce7..18fc62ff 100644 --- a/src/components/SortableArea.tsx +++ b/src/components/SortableArea.tsx @@ -43,9 +43,7 @@ function ItemWrapper({ return (
- {disabledDrag ? ( -
- ) : ( + {!disabledDrag && (
From d9ba640d9ed8db0cc8b4aa462f831c0800a15e19 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Thu, 13 Feb 2025 21:31:58 +0100 Subject: [PATCH 26/32] fix: catch all rule spacing --- src/components/SortableArea.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/SortableArea.tsx b/src/components/SortableArea.tsx index 18fc62ff..7b936ce7 100644 --- a/src/components/SortableArea.tsx +++ b/src/components/SortableArea.tsx @@ -43,7 +43,9 @@ function ItemWrapper({ return (
- {!disabledDrag && ( + {disabledDrag ? ( +
+ ) : (
From c155d3d7a23d10cac4865ccfcd41585ccf754696 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 14 Feb 2025 11:18:35 +0100 Subject: [PATCH 27/32] fix: fields alignment --- src/features/workspace/components/workspace-muxing-model.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/workspace/components/workspace-muxing-model.tsx b/src/features/workspace/components/workspace-muxing-model.tsx index 4a503160..28979538 100644 --- a/src/features/workspace/components/workspace-muxing-model.tsx +++ b/src/features/workspace/components/workspace-muxing-model.tsx @@ -103,7 +103,7 @@ function SortableItem({ setRuleItem({ ...rule, provider_id, model }) } /> - {showRemoveButton && !isDefaultRule && ( + {showRemoveButton && !isDefaultRule ? ( + ) : ( +
)}
From 62ba893b754b6ad3332ac3ad933b91d5551d33d3 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 14 Feb 2025 11:52:51 +0100 Subject: [PATCH 28/32] fix: models unique identifier --- .../components/workspace-models-dropdown.tsx | 114 ++++----- .../components/workspace-preferred-model.tsx | 230 ------------------ 2 files changed, 58 insertions(+), 286 deletions(-) delete mode 100644 src/features/workspace/components/workspace-preferred-model.tsx diff --git a/src/features/workspace/components/workspace-models-dropdown.tsx b/src/features/workspace/components/workspace-models-dropdown.tsx index 529d0002..571a1f72 100644 --- a/src/features/workspace/components/workspace-models-dropdown.tsx +++ b/src/features/workspace/components/workspace-models-dropdown.tsx @@ -2,7 +2,7 @@ import { ModelByProvider, MuxRule, V1ListAllModelsForAllProvidersResponse, -} from "@/api/generated"; +} from '@/api/generated' import { DialogTrigger, Button, @@ -12,71 +12,73 @@ import { Input, OptionRenderer, OptionsSchema, -} from "@stacklok/ui-kit"; -import { ChevronDown, SearchMd } from "@untitled-ui/icons-react"; -import { useState } from "react"; +} from '@stacklok/ui-kit' +import { ChevronDown, SearchMd } from '@untitled-ui/icons-react' +import { useState } from 'react' type Props = { - rule: MuxRule & { id: string }; - isArchived: boolean; - models: V1ListAllModelsForAllProvidersResponse; + rule: MuxRule & { id: string } + isArchived: boolean + models: V1ListAllModelsForAllProvidersResponse onChange: ({ model, provider_id, }: { - model: string; - provider_id: string; - }) => void; -}; + model: string + provider_id: string + }) => void +} function groupModelsByProviderName( - models: ModelByProvider[], -): OptionsSchema<"listbox", string>[] { - return models.reduce[]>( + models: ModelByProvider[] +): OptionsSchema<'listbox', string>[] { + return models.reduce[]>( (groupedProviders, item) => { const providerData = groupedProviders.find( - (group) => group.id === item.provider_name, - ); + (group) => group.id === item.provider_name + ) if (!providerData) { groupedProviders.push({ id: item.provider_name, items: [], textValue: item.provider_name, - }); + }) } - (providerData?.items ?? []).push({ - id: item.name, + ;(providerData?.items ?? []).push({ + id: `${item.provider_id}_${item.name}`, textValue: item.name, - }); + }) - return groupedProviders; + return groupedProviders }, - [], - ); + [] + ) } function filterModels({ groupedModels, searchItem, }: { - searchItem: string; - groupedModels: OptionsSchema<"listbox", string>[]; + searchItem: string + groupedModels: OptionsSchema<'listbox', string>[] }) { - return groupedModels + const test = groupedModels .map((modelData) => { - if (!searchItem) return modelData; + if (!searchItem) return modelData const filteredModels = modelData.items?.filter((item) => { - return item.textValue.includes(searchItem); - }); + return item.textValue.includes(searchItem) + }) const data = { ...modelData, items: filteredModels, - }; - return data; + } + return data }) - .filter((item) => (item.items ? item.items.length > 0 : false)); + .filter((item) => (item.items ? item.items.length > 0 : false)) + + return test } export function WorkspaceModelsDropdown({ @@ -85,27 +87,28 @@ export function WorkspaceModelsDropdown({ models = [], onChange, }: Props) { - const [isOpen, setIsOpen] = useState(false); - const [searchItem, setSearchItem] = useState(""); - const groupedModels = groupModelsByProviderName(models); - const currentProvider = models.find( - (p) => p.provider_id === rule.provider_id, - ); + const [isOpen, setIsOpen] = useState(false) + const [searchItem, setSearchItem] = useState('') + const groupedModels = groupModelsByProviderName(models) + console.log({ groupedModels, rule: rule }) + const currentProvider = models.find((p) => p.provider_id === rule.provider_id) const currentModel = currentProvider && rule.model ? `${currentProvider?.provider_name}/${rule.model}` - : ""; + : '' + const selectedKey = `${rule.provider_id}_${rule.model}` return ( -
+
setIsOpen(test)}> @@ -119,22 +122,21 @@ export function WorkspaceModelsDropdown({ items={filterModels({ searchItem, groupedModels })} selectionMode="single" selectionBehavior="replace" - selectedKeys={rule.model ? [rule.model] : []} + selectedKeys={selectedKey ? [selectedKey] : []} onSelectionChange={(v) => { - if (v === "all") { - return; + if (v === 'all') { + return } - const selectedValue = v.values().next().value; - const providerId = models.find( - (item) => item.name === selectedValue, - )?.provider_id; - if (typeof selectedValue === "string" && providerId) { + const selectedValue = v.values().next().value + if (!selectedValue && typeof selectedValue !== 'string') return + if (typeof selectedValue === 'string') { + const [provider_id, modelName] = selectedValue.split('_') + if (!provider_id || !modelName) return onChange({ - model: selectedValue, - provider_id: providerId, - }); - - setIsOpen(false); + model: modelName, + provider_id, + }) + setIsOpen(false) } }} className="-mx-1 mt-2 max-h-72 overflow-auto" @@ -154,5 +156,5 @@ export function WorkspaceModelsDropdown({
- ); + ) } diff --git a/src/features/workspace/components/workspace-preferred-model.tsx b/src/features/workspace/components/workspace-preferred-model.tsx deleted file mode 100644 index 8a25d3f4..00000000 --- a/src/features/workspace/components/workspace-preferred-model.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { - Alert, - Button, - Card, - CardBody, - CardFooter, - Form, - Input, - Label, - Link, - LinkButton, - Text, - TextField, -} from "@stacklok/ui-kit"; -import { twMerge } from "tailwind-merge"; -import { useMutationPreferredModelWorkspace } from "../hooks/use-mutation-preferred-model-workspace"; -import { V1ListAllModelsForAllProvidersResponse } from "@/api/generated"; -import { FormEvent } from "react"; -import { - LayersThree01, - LinkExternal01, - Plus, - Trash01, -} from "@untitled-ui/icons-react"; -import { SortableArea } from "@/components/SortableArea"; -import { WorkspaceModelsDropdown } from "./workspace-models-dropdown"; -import { useQueryListAllModelsForAllProviders } from "@/hooks/use-query-list-all-models-for-all-providers"; -import { useQueryMuxingRulesWorkspace } from "../hooks/use-query-muxing-rules-workspace"; -import { - PreferredMuxRule, - useMuxingRulesFormState, -} from "../hooks/use-muxing-rules-form-workspace"; -import { FormButtons } from "@/components/FormButtons"; - -function MissingProviderBanner() { - return ( - // TODO needs to update the related ui-kit component that diverges from the design - - - Configure a provider - - - ) -} - -type SortableItemProps = { - index: number; - rule: PreferredMuxRule; - models: V1ListAllModelsForAllProvidersResponse; - isArchived: boolean; - showRemoveButton: boolean; - setRuleItem: (rule: PreferredMuxRule) => void; - removeRule: (index: number) => void; -}; - -function SortableItem({ - rule, - index, - setRuleItem, - removeRule, - models, - showRemoveButton, - isArchived, -}: SortableItemProps) { - return ( -
-
- event.preventDefault()} - value={rule?.matcher ?? ""} - isDisabled={isArchived} - name="matcher" - onChange={(matcher) => { - setRuleItem({ ...rule, matcher }); - }} - > - - -
-
- - setRuleItem({ ...rule, provider_id, model }) - } - /> - {showRemoveButton && ( - - )} -
-
- ); -} - -export function WorkspacePreferredModel({ - className, - workspaceName, - isArchived, -}: { - className?: string - workspaceName: string - isArchived: boolean | undefined -}) { - const { data: muxingRules, isPending } = - useQueryMuxingRulesWorkspace(workspaceName); - const { addRule, setRules, setRuleItem, removeRule, formState } = - useMuxingRulesFormState(muxingRules); - const { - values: { rules }, - } = formState; - - const { mutateAsync } = useMutationPreferredModelWorkspace(); - const { data: providerModels = [] } = useQueryListAllModelsForAllProviders(); - const isModelsEmpty = !isPending && providerModels.length === 0; - const showRemoveButton = rules.length > 1; - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - mutateAsync({ - path: { workspace_name: workspaceName }, - body: rules.map(({ id, ...rest }) => { - void id; - return { ...rest }; - }), - }); - }; - - if (isModelsEmpty) { - return ( - - - Model Muxing - - - - ); - } - - return ( - - - -
- Model Muxing - - Filters will be applied in order (top to bottom), empty filters - will apply to all. - - Learn more - - -
- -
-
-
 
-
- -
-
- -
-
- - {(rule, index) => ( - - )} - -
-
- -
- - - -
- -
-
- - ) -} From ff2c8578192d056f3181818923ad9f8a491668b8 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 14 Feb 2025 12:08:20 +0100 Subject: [PATCH 29/32] fix: invalidate models --- .../hooks/use-invalidate-providers-queries.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/features/providers/hooks/use-invalidate-providers-queries.ts b/src/features/providers/hooks/use-invalidate-providers-queries.ts index 2967ab4c..3c147681 100644 --- a/src/features/providers/hooks/use-invalidate-providers-queries.ts +++ b/src/features/providers/hooks/use-invalidate-providers-queries.ts @@ -1,4 +1,7 @@ -import { v1ListProviderEndpointsQueryKey } from '@/api/generated/@tanstack/react-query.gen' +import { + v1ListAllModelsForAllProvidersQueryKey, + v1ListProviderEndpointsQueryKey, +} from '@/api/generated/@tanstack/react-query.gen' import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { invalidateQueries } from '../../../lib/react-query-utils' @@ -7,7 +10,10 @@ export function useInvalidateProvidersQueries() { const queryClient = useQueryClient() const invalidate = useCallback(async () => { - invalidateQueries(queryClient, [v1ListProviderEndpointsQueryKey]) + invalidateQueries(queryClient, [ + v1ListProviderEndpointsQueryKey, + v1ListAllModelsForAllProvidersQueryKey, + ]) }, [queryClient]) return invalidate From ac1361aa5f4f538c43429a7c0f08286bb6853df1 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 14 Feb 2025 12:20:05 +0100 Subject: [PATCH 30/32] test: update muxing logic --- .../__tests__/workspace-muxing-model.test.tsx | 134 +++++++++--------- .../components/workspace-models-dropdown.tsx | 5 +- 2 files changed, 68 insertions(+), 71 deletions(-) diff --git a/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx b/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx index b34410e7..b564dd12 100644 --- a/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-muxing-model.test.tsx @@ -1,106 +1,104 @@ -import { render } from "@/lib/test-utils"; -import { screen, waitFor } from "@testing-library/react"; -import { WorkspaceMuxingModel } from "../workspace-muxing-model"; -import userEvent from "@testing-library/user-event"; +import { render } from '@/lib/test-utils' +import { screen, waitFor } from '@testing-library/react' +import { WorkspaceMuxingModel } from '../workspace-muxing-model' +import userEvent from '@testing-library/user-event' test('renders muxing model', async () => { render( - , - ); - expect(screen.getByText(/model muxing/i)).toBeVisible(); + + ) + expect(screen.getByText(/model muxing/i)).toBeVisible() expect( screen.getByText( - /select the model you would like to use in this workspace. This section applies only if you are using the MUX endpoint./i, - ), - ).toBeVisible(); + /select the model you would like to use in this workspace. This section applies only if you are using the MUX endpoint./i + ) + ).toBeVisible() expect( - screen.getByRole("link", { + screen.getByRole('link', { name: /learn more/i, - }), - ).toBeVisible(); + }) + ).toBeVisible() await userEvent.type( - screen.getByRole("textbox", { + screen.getByRole('textbox', { name: /filter by/i, }), - ".tsx", - ); + '.tsx' + ) - await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)); + await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)) await userEvent.click( - screen.getByRole("option", { + screen.getByRole('option', { name: /claude-3.6/i, - }), - ); + }) + ) - expect(screen.getByRole("button", { name: /add filter/i })).toBeVisible(); - expect(screen.getByRole("link", { name: /manage providers/i })).toBeVisible(); - expect(screen.getByRole("button", { name: /revert changes/i })).toBeVisible(); - expect(screen.getByRole("button", { name: /save/i })).toBeVisible(); -}); + expect(screen.getByRole('button', { name: /add filter/i })).toBeVisible() + expect(screen.getByRole('link', { name: /manage providers/i })).toBeVisible() + expect(screen.getByRole('button', { name: /revert changes/i })).toBeVisible() + expect(screen.getByRole('button', { name: /save/i })).toBeVisible() +}) -test("disabled muxing fields and buttons for archived workspace", async () => { +test('disabled muxing fields and buttons for archived workspace', async () => { render( - , - ); + + ) - expect(await screen.findByRole("button", { name: /save/i })).toBeDisabled(); - expect(screen.getByTestId(/workspace-models-dropdown/i)).toBeDisabled(); + expect(await screen.findByRole('button', { name: /save/i })).toBeDisabled() + expect(screen.getByTestId(/workspace-models-dropdown/i)).toBeDisabled() expect( - await screen.findByRole("button", { name: /add filter/i }), - ).toBeDisabled(); -}); + await screen.findByRole('button', { name: /add filter/i }) + ).toBeDisabled() +}) -test("submit additional model overrides", async () => { +test('submit additional model overrides', async () => { render( - , - ); + + ) - expect(screen.getAllByRole("textbox", { name: /filter by/i }).length).toEqual( - 1, - ); - await userEvent.type( - screen.getByRole("textbox", { - name: /filter by/i, - }), - ".tsx", - ); - await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)); + expect(screen.getAllByRole('textbox', { name: /filter by/i }).length).toEqual( + 1 + ) + + await userEvent.click(screen.getByTestId(/workspace-models-dropdown/i)) await userEvent.click( - screen.getByRole("option", { + screen.getByRole('option', { name: /claude-3.6/i, - }), - ); - - await userEvent.click(screen.getByRole("button", { name: /add filter/i })); + }) + ) + await waitFor(() => { + expect(screen.getByText(/claude-3.6/i)).toBeVisible() + }) - const textFields = await screen.findAllByRole("textbox", { + await userEvent.click(screen.getByRole('button', { name: /add filter/i })) + const textFields = await screen.findAllByRole('textbox', { name: /filter by/i, - }); - expect(textFields.length).toEqual(2); + }) + expect(textFields.length).toEqual(2) const modelsButton = await screen.findAllByTestId( - /workspace-models-dropdown/i, - ); - expect(modelsButton.length).toEqual(2); + /workspace-models-dropdown/i + ) + expect(modelsButton.length).toEqual(2) + + await userEvent.type(textFields[1] as HTMLFormElement, '.ts') - await userEvent.type(textFields[1] as HTMLFormElement, ".ts"); await userEvent.click( - (await screen.findByRole("button", { + (await screen.findByRole('button', { name: /select a model/i, - })) as HTMLFormElement, - ); + })) as HTMLFormElement + ) await userEvent.click( - screen.getByRole("option", { + screen.getByRole('option', { name: /chatgpt-4p/i, - }), - ); + }) + ) await userEvent.click(screen.getByRole('button', { name: /save/i })) await waitFor(() => { expect( - screen.getByText(/muxing rules for fake-workspace updated/i), - ).toBeVisible(); - }); -}); + screen.getByText(/muxing rules for fake-workspace updated/i) + ).toBeVisible() + }) +}) diff --git a/src/features/workspace/components/workspace-models-dropdown.tsx b/src/features/workspace/components/workspace-models-dropdown.tsx index 571a1f72..378d6fc6 100644 --- a/src/features/workspace/components/workspace-models-dropdown.tsx +++ b/src/features/workspace/components/workspace-models-dropdown.tsx @@ -46,7 +46,7 @@ function groupModelsByProviderName( } ;(providerData?.items ?? []).push({ - id: `${item.provider_id}_${item.name}`, + id: `${item.provider_id}/${item.name}`, textValue: item.name, }) @@ -90,7 +90,6 @@ export function WorkspaceModelsDropdown({ const [isOpen, setIsOpen] = useState(false) const [searchItem, setSearchItem] = useState('') const groupedModels = groupModelsByProviderName(models) - console.log({ groupedModels, rule: rule }) const currentProvider = models.find((p) => p.provider_id === rule.provider_id) const currentModel = currentProvider && rule.model @@ -130,7 +129,7 @@ export function WorkspaceModelsDropdown({ const selectedValue = v.values().next().value if (!selectedValue && typeof selectedValue !== 'string') return if (typeof selectedValue === 'string') { - const [provider_id, modelName] = selectedValue.split('_') + const [provider_id, modelName] = selectedValue.split('/') if (!provider_id || !modelName) return onChange({ model: modelName, From 1604e40afb574760e07224ab29030df04e413bd9 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 14 Feb 2025 12:27:47 +0100 Subject: [PATCH 31/32] prettier --- .../components/workspace-muxing-model.tsx | 105 +++++++++--------- 1 file changed, 52 insertions(+), 53 deletions(-) diff --git a/src/features/workspace/components/workspace-muxing-model.tsx b/src/features/workspace/components/workspace-muxing-model.tsx index 28979538..8af6a49c 100644 --- a/src/features/workspace/components/workspace-muxing-model.tsx +++ b/src/features/workspace/components/workspace-muxing-model.tsx @@ -14,36 +14,36 @@ import { Tooltip, TooltipInfoButton, TooltipTrigger, -} from "@stacklok/ui-kit"; -import { twMerge } from "tailwind-merge"; -import { useMutationPreferredModelWorkspace } from "../hooks/use-mutation-preferred-model-workspace"; +} from '@stacklok/ui-kit' +import { twMerge } from 'tailwind-merge' +import { useMutationPreferredModelWorkspace } from '../hooks/use-mutation-preferred-model-workspace' import { MuxMatcherType, V1ListAllModelsForAllProvidersResponse, -} from "@/api/generated"; -import { FormEvent } from "react"; +} from '@/api/generated' +import { FormEvent } from 'react' import { LayersThree01, LinkExternal01, Plus, Trash01, -} from "@untitled-ui/icons-react"; -import { SortableArea } from "@/components/SortableArea"; -import { WorkspaceModelsDropdown } from "./workspace-models-dropdown"; -import { useQueryListAllModelsForAllProviders } from "@/hooks/use-query-list-all-models-for-all-providers"; -import { useQueryMuxingRulesWorkspace } from "../hooks/use-query-muxing-rules-workspace"; +} from '@untitled-ui/icons-react' +import { SortableArea } from '@/components/SortableArea' +import { WorkspaceModelsDropdown } from './workspace-models-dropdown' +import { useQueryListAllModelsForAllProviders } from '@/hooks/use-query-list-all-models-for-all-providers' +import { useQueryMuxingRulesWorkspace } from '../hooks/use-query-muxing-rules-workspace' import { PreferredMuxRule, useMuxingRulesFormState, -} from "../hooks/use-muxing-rules-form-workspace"; -import { FormButtons } from "@/components/FormButtons"; +} from '../hooks/use-muxing-rules-form-workspace' +import { FormButtons } from '@/components/FormButtons' function MissingProviderBanner() { return ( // TODO needs to update the related ui-kit component that diverges from the design - ); + ) } type SortableItemProps = { - index: number; - rule: PreferredMuxRule; - models: V1ListAllModelsForAllProvidersResponse; - isArchived: boolean; - showRemoveButton: boolean; - isDefaultRule: boolean; - setRuleItem: (rule: PreferredMuxRule) => void; - removeRule: (index: number) => void; -}; + index: number + rule: PreferredMuxRule + models: V1ListAllModelsForAllProvidersResponse + isArchived: boolean + showRemoveButton: boolean + isDefaultRule: boolean + setRuleItem: (rule: PreferredMuxRule) => void + removeRule: (index: number) => void +} function SortableItem({ rule, @@ -77,18 +77,17 @@ function SortableItem({ isArchived, isDefaultRule, }: SortableItemProps) { - const placeholder = isDefaultRule ? "Catch All" : "eg file type, file name"; + const placeholder = isDefaultRule ? 'Catch All' : 'eg file type, file name' return (
event.preventDefault()} - value={rule?.matcher ?? ""} + value={rule?.matcher ?? ''} isDisabled={isArchived || isDefaultRule} name="matcher" onChange={(matcher) => { - setRuleItem({ ...rule, matcher }); + setRuleItem({ ...rule, matcher }) }} > @@ -117,7 +116,7 @@ function SortableItem({ )}
- ); + ) } export function WorkspaceMuxingModel({ @@ -125,53 +124,53 @@ export function WorkspaceMuxingModel({ workspaceName, isArchived, }: { - className?: string; - workspaceName: string; - isArchived: boolean | undefined; + className?: string + workspaceName: string + isArchived: boolean | undefined }) { const { data: muxingRules, isPending } = - useQueryMuxingRulesWorkspace(workspaceName); + useQueryMuxingRulesWorkspace(workspaceName) const { addRule, setRules, setRuleItem, removeRule, formState } = - useMuxingRulesFormState(muxingRules); + useMuxingRulesFormState(muxingRules) const { values: { rules }, - } = formState; + } = formState - const { mutateAsync } = useMutationPreferredModelWorkspace(); - const { data: providerModels = [] } = useQueryListAllModelsForAllProviders(); - const isModelsEmpty = !isPending && providerModels.length === 0; - const showRemoveButton = rules.length > 1; + const { mutateAsync } = useMutationPreferredModelWorkspace() + const { data: providerModels = [] } = useQueryListAllModelsForAllProviders() + const isModelsEmpty = !isPending && providerModels.length === 0 + const showRemoveButton = rules.length > 1 const handleSubmit = (event: FormEvent) => { - event.preventDefault(); + event.preventDefault() mutateAsync( { path: { workspace_name: workspaceName }, body: rules.map(({ id, ...rest }) => { - void id; + void id return rest.matcher ? { ...rest, matcher_type: MuxMatcherType.FILENAME_MATCH } - : { ...rest }; + : { ...rest } }), }, { onSuccess: () => { - formState.setInitialValues({ rules }); + formState.setInitialValues({ rules }) }, - }, - ); - }; + } + ) + } if (isModelsEmpty) { return ( - + Model Muxing - ); + ) } return ( @@ -180,11 +179,11 @@ export function WorkspaceMuxingModel({ validationBehavior="aria" data-testid="preferred-model" > - +
Model Muxing - + Select the model you would like to use in this workspace. This section applies only if you are using the MUX endpoint.
-
+
 
@@ -224,7 +223,7 @@ export function WorkspaceMuxingModel({ disableDragByIndex={rules.length - 1} > {(rule, index) => { - const isDefaultRule = rules.length - 1 === index; + const isDefaultRule = rules.length - 1 === index return ( - ); + ) }}
@@ -265,5 +264,5 @@ export function WorkspaceMuxingModel({ - ); + ) } From 7b0e5f61f0c8cae1b4ecdc4a8370dd73b4b5b6db Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Fri, 14 Feb 2025 12:46:26 +0100 Subject: [PATCH 32/32] refactor: review --- src/components/SortableArea.tsx | 57 ++++++++++--------- .../components/workspace-models-dropdown.tsx | 33 ++++------- .../components/workspace-muxing-model.tsx | 2 +- 3 files changed, 40 insertions(+), 52 deletions(-) diff --git a/src/components/SortableArea.tsx b/src/components/SortableArea.tsx index 7b936ce7..a9596183 100644 --- a/src/components/SortableArea.tsx +++ b/src/components/SortableArea.tsx @@ -7,43 +7,43 @@ import { UniqueIdentifier, useSensor, useSensors, -} from "@dnd-kit/core"; +} from '@dnd-kit/core' import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { useSortable } from "@dnd-kit/sortable"; -import { Drag } from "./icons"; +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { useSortable } from '@dnd-kit/sortable' +import { Drag } from './icons' type Props = { - children: (item: T, index: number) => React.ReactNode; - setItems: (items: T[]) => void; - items: T[]; - disableDragByIndex?: number; -}; + children: (item: T, index: number) => React.ReactNode + setItems: (items: T[]) => void + items: T[] + disableDragByIndex?: number +} function ItemWrapper({ children, id, - disabledDrag, + hasDragDisabled, }: { - children: React.ReactNode; - id: UniqueIdentifier; - disabledDrag: boolean; + children: React.ReactNode + id: UniqueIdentifier + hasDragDisabled: boolean }) { const { attributes, listeners, setNodeRef, transform, transition } = - useSortable({ id }); + useSortable({ id }) const style = { transform: CSS.Transform.toString(transform), transition, - }; + } return ( -
- {disabledDrag ? ( +
+ {hasDragDisabled ? (
) : (
@@ -52,7 +52,7 @@ function ItemWrapper({ )}
{children}
- ); + ) } export function SortableArea({ @@ -65,21 +65,22 @@ export function SortableArea({ useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, - }), - ); + }) + ) function handleDragEnd(event: DragEndEvent) { - const { active, over } = event; + const { active, over } = event if (over == null) { - return; + // The item was dropped in it's original place + return } if (active.id !== over.id) { - const oldIndex = items.findIndex(({ id }) => id === active.id); - const newIndex = items.findIndex(({ id }) => id === over.id); + const oldIndex = items.findIndex(({ id }) => id === active.id) + const newIndex = items.findIndex(({ id }) => id === over.id) - setItems(arrayMove(items, oldIndex, newIndex)); + setItems(arrayMove(items, oldIndex, newIndex)) } } @@ -94,12 +95,12 @@ export function SortableArea({ {children(item, index)} ))} - ); + ) } diff --git a/src/features/workspace/components/workspace-models-dropdown.tsx b/src/features/workspace/components/workspace-models-dropdown.tsx index 378d6fc6..059064c5 100644 --- a/src/features/workspace/components/workspace-models-dropdown.tsx +++ b/src/features/workspace/components/workspace-models-dropdown.tsx @@ -14,6 +14,7 @@ import { OptionsSchema, } from '@stacklok/ui-kit' import { ChevronDown, SearchMd } from '@untitled-ui/icons-react' +import { map, groupBy } from 'lodash' import { useState } from 'react' type Props = { @@ -32,28 +33,14 @@ type Props = { function groupModelsByProviderName( models: ModelByProvider[] ): OptionsSchema<'listbox', string>[] { - return models.reduce[]>( - (groupedProviders, item) => { - const providerData = groupedProviders.find( - (group) => group.id === item.provider_name - ) - if (!providerData) { - groupedProviders.push({ - id: item.provider_name, - items: [], - textValue: item.provider_name, - }) - } - - ;(providerData?.items ?? []).push({ - id: `${item.provider_id}/${item.name}`, - textValue: item.name, - }) - - return groupedProviders - }, - [] - ) + return map(groupBy(models, 'provider_name'), (items, providerName) => ({ + id: providerName, + textValue: providerName, + items: items.map((item) => ({ + id: `${item.provider_id}/${item.name}`, + textValue: item.name, + })), + })) } function filterModels({ @@ -95,7 +82,7 @@ export function WorkspaceModelsDropdown({ currentProvider && rule.model ? `${currentProvider?.provider_name}/${rule.model}` : '' - const selectedKey = `${rule.provider_id}_${rule.model}` + const selectedKey = `${rule.provider_id}/${rule.model}` return (
diff --git a/src/features/workspace/components/workspace-muxing-model.tsx b/src/features/workspace/components/workspace-muxing-model.tsx index 8af6a49c..44e8fe6b 100644 --- a/src/features/workspace/components/workspace-muxing-model.tsx +++ b/src/features/workspace/components/workspace-muxing-model.tsx @@ -77,7 +77,7 @@ function SortableItem({ isArchived, isDefaultRule, }: SortableItemProps) { - const placeholder = isDefaultRule ? 'Catch All' : 'eg file type, file name' + const placeholder = isDefaultRule ? 'Catch-all' : 'e.g. file type, file name' return (