diff --git a/.cursor/rules/task.mdc b/.cursor/rules/task.mdc index f03b36a7..e009d5c8 100644 --- a/.cursor/rules/task.mdc +++ b/.cursor/rules/task.mdc @@ -4,4 +4,4 @@ globs: alwaysApply: true --- # Task -Add your task for the agent here. \ No newline at end of file +Place your task for the agent here. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6a90be39..965e80a9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ provider.config.toml provider.test.config.toml test_logs.jsonl docker-compose.override.yml -node_modules/ \ No newline at end of file +node_modules/ +graphcap_pg.session.sql diff --git a/Taskfile.yml b/Taskfile.yml index ef95fa0b..cbd8b251 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,7 +3,7 @@ dotenv: ['.env', '{{.ENV}}/.env', '{{.HOME}}/.env'] includes: data: ./servers/data_service/Taskfile.data.yml - inference: ./servers/inference_server/server/Taskfile.inference.yml + inference: ./servers/inference_bridge/server/Taskfile.inference.yml studio: ./graphcap_studio/Taskfile.studio.yml tasks: diff --git a/docker-compose.override.example.yml b/docker-compose.override.example.yml index 5909d20f..91a7d635 100644 --- a/docker-compose.override.example.yml +++ b/docker-compose.override.example.yml @@ -14,8 +14,8 @@ # SPDX-License-Identifier: Apache-2.0 name: graphcap # services: -# graphcap_server: -# container_name: graphcap_server +# inference_bridge: +# container_name: inference_bridge # build: # context: ./src # dockerfile: ./server/Dockerfile.server @@ -45,7 +45,7 @@ name: graphcap # ports: # - "35433:5432" # volumes: -# - graphcap_server_db:/var/lib/postgresql/data +# - inference_bridge_db:/var/lib/postgresql/data # healthcheck: # test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] # interval: 5s @@ -95,7 +95,7 @@ name: graphcap # target: /app/pnpm-lock.yaml # environment: # - NODE_ENV=${NODE_ENV:-development} - # - VITE_API_URL=http://localhost:32100/api + # - VITE_API_URL=http://localhost:32100/ # - VITE_WORKSPACE_PATH=/workspace/.local # - VITE_MEDIA_SERVER_URL=http://localhost:32400 # networks: @@ -181,7 +181,7 @@ name: graphcap # volumes: -# graphcap_server_db: +# inference_bridge_db: # networks: # graphcap: diff --git a/docker-compose.yml b/docker-compose.yml index 97311fef..7373471f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,16 @@ # SPDX-License-Identifier: Apache-2.0 name: graphcap services: - graphcap_server: - container_name: graphcap_server + inference_bridge: + container_name: inference_bridge build: - context: ./servers/inference_server + context: ./servers/inference_bridge dockerfile: ./server/Dockerfile.server.dev ports: - 32100:32100 volumes: - - ./servers/inference_server/graphcap:/app/graphcap - - ./servers/inference_server/server/server:/app/server/server + - ./servers/inference_bridge/graphcap:/app/graphcap + - ./servers/inference_bridge/server/server:/app/server/server - ./workspace:/workspace environment: - HOST_PLATFORM=${HOST_PLATFORM:-linux} @@ -33,7 +33,7 @@ services: ports: - "35433:5432" volumes: - - graphcap_server_db:/var/lib/postgresql/data + - graphcap_db:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] interval: 5s @@ -83,7 +83,7 @@ services: target: /app/pnpm-lock.yaml environment: - NODE_ENV=${NODE_ENV:-development} - - VITE_API_URL=http://localhost:32100/api + - VITE_API_URL=http://localhost:32100 - VITE_WORKSPACE_PATH=/workspace/.local - VITE_MEDIA_SERVER_URL=http://localhost:32400 - VITE_DATASETS_PATH=/workspace/datasets @@ -128,12 +128,12 @@ services: graphcap_pipelines: build: - context: ./servers/inference_server + context: ./servers/inference_bridge dockerfile: pipelines/Dockerfile.pipelines.dev container_name: graphcap_pipelines volumes: - ./workspace:/workspace - - ./servers/inference_server:/app + - ./servers/inference_bridge:/app environment: - DAGSTER_HOME=/workspace/.local/.dagster - DAGSTER_PORT=32300 @@ -182,7 +182,7 @@ services: - ./workspace/config/.env volumes: - graphcap_server_db: + graphcap_db: networks: graphcap: diff --git a/graphcap_studio/package.json b/graphcap_studio/package.json index d1db07f9..e54b5d1c 100644 --- a/graphcap_studio/package.json +++ b/graphcap_studio/package.json @@ -42,7 +42,6 @@ "react-icons": "^5.5.0", "react-window": "^1.8.11", "react-window-infinite-loader": "^1.0.10", - "sonner": "^1.7.4", "styled-components": "^6.1.15", "tailwindcss": "^4.0.12", "zod": "^3.24.2" diff --git a/graphcap_studio/pnpm-lock.yaml b/graphcap_studio/pnpm-lock.yaml index b7056229..d6bd07e5 100644 --- a/graphcap_studio/pnpm-lock.yaml +++ b/graphcap_studio/pnpm-lock.yaml @@ -86,9 +86,6 @@ importers: react-window-infinite-loader: specifier: ^1.0.10 version: 1.0.10(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - sonner: - specifier: ^1.7.4 - version: 1.7.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) styled-components: specifier: ^6.1.15 version: 6.1.15(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -2666,12 +2663,6 @@ packages: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} - sonner@1.7.4: - resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5790,11 +5781,6 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - sonner@1.7.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0): - dependencies: - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - source-map-js@1.2.1: {} source-map@0.5.7: {} diff --git a/graphcap_studio/src/app/layout/ActionPanel.tsx b/graphcap_studio/src/app/layout/ActionPanel.tsx index f010361c..7384ca53 100644 --- a/graphcap_studio/src/app/layout/ActionPanel.tsx +++ b/graphcap_studio/src/app/layout/ActionPanel.tsx @@ -113,9 +113,28 @@ export function ActionPanel({ // Handle clicks outside the panel useEffect(() => { function handleClickOutside(event: MouseEvent) { + const target = event.target as HTMLElement; + + // Check if the click is inside a dialog or modal + const isInsideModal = + // Check for Chakra UI Dialog elements + target.closest("[role='dialog']") || + target.closest("[data-part='backdrop']") || + target.closest("[data-part='positioner']") || + target.closest("[data-part='content']") || + // Also check for other common modal classes/attributes + target.closest(".modal") || + target.closest("[aria-modal='true']"); + + // Don't collapse the panel if clicking inside a dialog/modal + if (isInsideModal) { + return; + } + + // Proceed with the normal check for panel containment if ( panelRef.current && - !panelRef.current.contains(event.target as Node) && + !panelRef.current.contains(target) && isExpanded ) { collapsePanel(); diff --git a/graphcap_studio/src/app/layout/RootLeftActionPanel.tsx b/graphcap_studio/src/app/layout/RootLeftActionPanel.tsx index bc6948c4..fb0787ff 100644 --- a/graphcap_studio/src/app/layout/RootLeftActionPanel.tsx +++ b/graphcap_studio/src/app/layout/RootLeftActionPanel.tsx @@ -7,14 +7,14 @@ import { DatasetIcon, - FlagIcon, + GenerationOptionsIcon, PerspectiveLayersIcon, ProviderIcon, - SettingsIcon, + SettingsIcon } from "@/components/icons"; import { SettingsPanel } from "@/features/app-settings"; -import { FeatureFlagsPanel } from "@/features/app-settings/feature-flags"; import { DatasetPanel } from "@/features/datasets"; +import { GenerationOptionsPanel } from "@/features/inference/generation-options/components/GenerationOptionsPanel"; import { ProvidersPanel } from "@/features/inference/providers"; import { PerspectiveManagementPanel } from "@/features/perspectives/components/PerspectiveManagement/PerspectiveManagementPanel"; import { ActionPanel } from "./ActionPanel"; @@ -27,13 +27,13 @@ export function RootLeftActionPanel() { , - content: , + id: "generation-options", + title: "Generation Options", + icon: , + content: , }, { id: "datasets", diff --git a/graphcap_studio/src/app/theme/global-css.ts b/graphcap_studio/src/app/theme/global-css.ts index e758079e..39aea236 100644 --- a/graphcap_studio/src/app/theme/global-css.ts +++ b/graphcap_studio/src/app/theme/global-css.ts @@ -40,6 +40,8 @@ export const globalCss = defineGlobalStyles({ color: "fg.muted/80", }, "*::selection": { - bg: "colorPalette.muted/80", + bg: "colorPalette.500/50", + color: "white", + }, }); diff --git a/graphcap_studio/src/components/common_inference/ModelSelector.tsx b/graphcap_studio/src/components/common_inference/ModelSelector.tsx new file mode 100644 index 00000000..abbd5584 --- /dev/null +++ b/graphcap_studio/src/components/common_inference/ModelSelector.tsx @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Model Selector Component + * + * A reusable component for selecting a model from a list of options. + */ + +import { Box, createListCollection } from "@chakra-ui/react"; +import { Field } from "../ui/field"; +import { + SelectContent, + SelectItem, + SelectRoot, + SelectTrigger, + SelectValueText, +} from "../ui/select"; + +export interface ModelOption { + label: string; + value: string; + id: string; + is_default?: boolean; +} + +export interface ModelSelectorProps { + readonly options: ModelOption[]; + readonly value: string | null | undefined; + readonly onChange: (value: string) => void; + readonly isDisabled?: boolean; + readonly maxWidth?: string | number; + readonly minWidth?: string | number; + readonly width?: string | number; + readonly size?: "xs" | "sm" | "md" | "lg"; + readonly placeholder?: string; + readonly className?: string; + readonly showLabel?: boolean; + readonly label?: string; + readonly helperText?: string; +} + +/** + * A reusable component for selecting a model from a list of options. + */ +export function ModelSelector({ + options, + value, + onChange, + isDisabled = false, + maxWidth = undefined, + minWidth = undefined, + width = undefined, + size = "sm", + placeholder = "Select model", + className, + showLabel = false, + label = "Model", + helperText, +}: ModelSelectorProps) { + // Create collection for SelectRoot + const modelCollection = createListCollection({ + items: options, + }); + + // Convert value to string array format required by SelectRoot + const selectValue = value ? [value] : []; + + const handleValueChange = (details: { value: string[] }) => { + if (details.value && details.value.length > 0) { + onChange(details.value[0]); + } else { + onChange(""); + } + }; + + const boxProps = { + ...(maxWidth ? { maxWidth } : {}), + ...(minWidth ? { minWidth } : {}), + ...(width ? { width } : {}), + className, + }; + + const selector = ( + + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + ); + + return ( + + {showLabel ? ( + + {selector} + + ) : ( + selector + )} + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/components/common_inference/ProviderSelector.tsx b/graphcap_studio/src/components/common_inference/ProviderSelector.tsx new file mode 100644 index 00000000..1135463e --- /dev/null +++ b/graphcap_studio/src/components/common_inference/ProviderSelector.tsx @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Provider Selector Component + * + * A reusable component for selecting a provider from a list of options. + */ + +import { Box, createListCollection } from "@chakra-ui/react"; +import { Field } from "../ui/field"; +import { + SelectContent, + SelectItem, + SelectRoot, + SelectTrigger, + SelectValueText, +} from "../ui/select"; + +export interface ProviderOption { + label: string; + value: string; + id: string; +} + +export interface ProviderSelectorProps { + readonly options: ProviderOption[]; + readonly value: string | null | undefined; + readonly onChange: (value: string) => void; + readonly isDisabled?: boolean; + readonly maxWidth?: string | number; + readonly minWidth?: string | number; + readonly width?: string | number; + readonly size?: "xs" | "sm" | "md" | "lg"; + readonly placeholder?: string; + readonly className?: string; + readonly showLabel?: boolean; + readonly label?: string; + readonly helperText?: string; +} + +/** + * A reusable component for selecting a provider from a list of options. + */ +export function ProviderSelector({ + options, + value, + onChange, + isDisabled = false, + maxWidth = undefined, + minWidth = undefined, + width = undefined, + size = "sm", + placeholder = "Select provider", + className, + showLabel = false, + label = "Provider", + helperText, +}: ProviderSelectorProps) { + // Create collection for SelectRoot + const providerCollection = createListCollection({ + items: options, + }); + + // Convert value to string array format required by SelectRoot + // Handle both string and number values + const stringValue = value !== null && value !== undefined ? String(value) : null; + const selectValue = stringValue ? [stringValue] : []; + + const handleValueChange = (details: { value: string[] }) => { + if (details.value && details.value.length > 0) { + onChange(details.value[0]); + } else { + onChange(""); + } + }; + + const boxProps = { + ...(maxWidth ? { maxWidth } : {}), + ...(minWidth ? { minWidth } : {}), + ...(width ? { width } : {}), + className, + }; + + const selector = ( + + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + ); + + return ( + + {showLabel ? ( + + {selector} + + ) : ( + selector + )} + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/components/icons/index.tsx b/graphcap_studio/src/components/icons/index.tsx index 9a37668f..ed9b1ac0 100644 --- a/graphcap_studio/src/components/icons/index.tsx +++ b/graphcap_studio/src/components/icons/index.tsx @@ -187,5 +187,29 @@ export function DatasetIcon({ className = "" }: Readonly) { ); } +/** + * Generation Options icon + */ +export function GenerationOptionsIcon({ className = "" }: Readonly) { + return ( + + Generation Options + + + ); +} + // Export for PerspectiveLayersIcon export { PerspectiveLayersIcon } from "./PerspectiveLayersIcon"; diff --git a/graphcap_studio/src/components/ui/index.ts b/graphcap_studio/src/components/ui/index.ts index 670c9ad6..6aa6afb8 100644 --- a/graphcap_studio/src/components/ui/index.ts +++ b/graphcap_studio/src/components/ui/index.ts @@ -3,3 +3,4 @@ export * from "./ImageCounter"; export * from "./buttons"; export * from "./status"; +export * from "../common_inference/ModelSelector"; diff --git a/graphcap_studio/src/components/ui/theme/ThemeProvider.tsx b/graphcap_studio/src/components/ui/theme/ThemeProvider.tsx index 85e553d4..f3790e4a 100644 --- a/graphcap_studio/src/components/ui/theme/ThemeProvider.tsx +++ b/graphcap_studio/src/components/ui/theme/ThemeProvider.tsx @@ -1,6 +1,7 @@ "use client"; import { graphcapTheme } from "@/app/theme"; +import { Toaster } from "@/components/ui/toaster"; import { ChakraProvider } from "@chakra-ui/react"; import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode"; @@ -8,6 +9,7 @@ export function Provider(props: Readonly) { return ( + ); } diff --git a/graphcap_studio/src/components/ui/toaster.tsx b/graphcap_studio/src/components/ui/toaster.tsx new file mode 100644 index 00000000..df6c2c38 --- /dev/null +++ b/graphcap_studio/src/components/ui/toaster.tsx @@ -0,0 +1,43 @@ +"use client" + +import { + Toaster as ChakraToaster, + Portal, + Spinner, + Stack, + Toast, + createToaster, +} from "@chakra-ui/react" + +export const toaster = createToaster({ + placement: "bottom-end", + pauseOnPageIdle: true, +}) + +export const Toaster = () => { + return ( + + + {(toast) => ( + + {toast.type === "loading" ? ( + + ) : ( + + )} + + {toast.title && {toast.title}} + {toast.description && ( + {toast.description} + )} + + {toast.action && ( + {toast.action.label} + )} + {toast.meta?.closable && } + + )} + + + ) +} diff --git a/graphcap_studio/src/context/AppContextProvider.tsx b/graphcap_studio/src/context/AppContextProvider.tsx index 771b790e..35f80f43 100644 --- a/graphcap_studio/src/context/AppContextProvider.tsx +++ b/graphcap_studio/src/context/AppContextProvider.tsx @@ -1,7 +1,9 @@ import { DatasetInitializer } from "@/features/datasets"; +import { GenerationOptionsProvider } from "@/features/inference/generation-options"; +import { InferenceProviderProvider } from "@/features/inference/providers/context"; import { PerspectivesProvider } from "@/features/perspectives/context"; // SPDX-License-Identifier: Apache-2.0 -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { ServerConnectionsProvider } from "."; import { FeatureFlagProvider } from "../features/app-settings/feature-flags/FeatureFlagProvider"; @@ -28,7 +30,11 @@ export function AppContextProvider({ children }: AppContextProviderProps) { - {children} + + + {children} + + diff --git a/graphcap_studio/src/context/ServerConnectionsContext.tsx b/graphcap_studio/src/context/ServerConnectionsContext.tsx index 047bbe75..02c0a1b9 100644 --- a/graphcap_studio/src/context/ServerConnectionsContext.tsx +++ b/graphcap_studio/src/context/ServerConnectionsContext.tsx @@ -1,7 +1,7 @@ import { useServerConnections } from "@/features/server-connections"; -import { ServerConnection } from "@/features/server-connections/types"; +import type { ServerConnection } from "@/types/server-connection-types"; // SPDX-License-Identifier: Apache-2.0 -import { ReactNode, createContext, useContext } from "react"; +import { type ReactNode, createContext, useContext } from "react"; /** * Interface for the ServerConnectionsContext value diff --git a/graphcap_studio/src/features/app-settings/components/SettingsPanel.tsx b/graphcap_studio/src/features/app-settings/components/SettingsPanel.tsx index 8487c647..0eb5ea87 100644 --- a/graphcap_studio/src/features/app-settings/components/SettingsPanel.tsx +++ b/graphcap_studio/src/features/app-settings/components/SettingsPanel.tsx @@ -1,4 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 +import { FeatureFlagsPanel } from "../feature-flags/FeatureFlagsPanel"; + export function SettingsPanel() { - return
Settings Stub
; + return ( +
+

Settings

+
+ +
+
+ ); } diff --git a/graphcap_studio/src/features/datasets/components/CreateDatasetModal.tsx b/graphcap_studio/src/features/datasets/components/CreateDatasetModal.tsx index c46628e9..38e5c8ed 100644 --- a/graphcap_studio/src/features/datasets/components/CreateDatasetModal.tsx +++ b/graphcap_studio/src/features/datasets/components/CreateDatasetModal.tsx @@ -1,6 +1,6 @@ +import { toast } from "@/utils/toast"; // SPDX-License-Identifier: Apache-2.0 import { useState } from "react"; -import { toast } from "sonner"; import { useDatasetContext } from "../context/DatasetContext"; type CreateDatasetModalProps = { @@ -50,7 +50,7 @@ export function CreateDatasetModal({ try { await createDataset(datasetName); - toast.success(`Dataset "${datasetName}" created successfully`); + toast.success({ title: `Dataset "${datasetName}" created successfully` }); onDatasetCreated(datasetName); onClose(); } catch (error) { @@ -60,7 +60,7 @@ export function CreateDatasetModal({ if (error instanceof Error && error.message.includes("409")) { // If the dataset already exists, we can still consider this a success // and notify the user that we're switching to the existing dataset - toast.info(`Dataset "${datasetName}" already exists. Switching to it.`); + toast.info({ title: `Dataset "${datasetName}" already exists. Switching to it.` }); onDatasetCreated(datasetName); onClose(); } else { diff --git a/graphcap_studio/src/features/datasets/components/DeleteDatasetModal.tsx b/graphcap_studio/src/features/datasets/components/DeleteDatasetModal.tsx index ce418dca..5d6b3fc1 100644 --- a/graphcap_studio/src/features/datasets/components/DeleteDatasetModal.tsx +++ b/graphcap_studio/src/features/datasets/components/DeleteDatasetModal.tsx @@ -1,7 +1,7 @@ import { useDeleteDataset } from "@/services/dataset"; +import { toast } from "@/utils/toast"; // SPDX-License-Identifier: Apache-2.0 import { useState } from "react"; -import { toast } from "sonner"; type DeleteDatasetModalProps = { readonly isOpen: boolean; @@ -32,7 +32,7 @@ export function DeleteDatasetModal({ try { await deleteDatasetMutation.mutateAsync(datasetName); - toast.success(`Dataset "${datasetName}" deleted successfully`); + toast.success({ title: `Dataset "${datasetName}" deleted successfully` }); onDatasetDeleted(); onClose(); } catch (error) { diff --git a/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useDatasetNavigation.ts b/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useDatasetNavigation.ts index 548a83f9..809b40a0 100644 --- a/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useDatasetNavigation.ts +++ b/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useDatasetNavigation.ts @@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; // SPDX-License-Identifier: Apache-2.0 import { useCallback } from "react"; -import { TreeItemData } from "../types"; +import type { TreeItemData } from "../types"; /** * Custom hook for dataset navigation. diff --git a/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useTree.ts b/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useTree.ts index abe47671..4ab5286a 100644 --- a/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useTree.ts +++ b/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useTree.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useCallback } from "react"; import { useTreeContext } from "../TreeContext"; -import { TreeItemData } from "../types"; +import type { TreeItemData } from "../types"; /** * Custom hook for tree-specific logic. diff --git a/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useTreeActions.ts b/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useTreeActions.ts index 7c4ac604..129ea5e7 100644 --- a/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useTreeActions.ts +++ b/graphcap_studio/src/features/datasets/components/dataset-tree/hooks/useTreeActions.ts @@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; // SPDX-License-Identifier: Apache-2.0 import { useCallback } from "react"; -import { TreeContextMenuAction, TreeItemData } from "../types"; +import type { TreeContextMenuAction, TreeItemData } from "../types"; /** * Custom hook for common tree actions like deletion, navigation, etc. diff --git a/graphcap_studio/src/features/datasets/context/DatasetContext.tsx b/graphcap_studio/src/features/datasets/context/DatasetContext.tsx index aee859e1..17e048fc 100644 --- a/graphcap_studio/src/features/datasets/context/DatasetContext.tsx +++ b/graphcap_studio/src/features/datasets/context/DatasetContext.tsx @@ -2,6 +2,7 @@ import type { Dataset } from "@/services/dataset"; import { useAddImageToDataset, useCreateDataset } from "@/services/dataset"; import type { Image } from "@/services/images"; +import { toast } from "@/utils/toast"; import { type ReactNode, createContext, @@ -11,7 +12,6 @@ import { useMemo, useState, } from "react"; -import { toast } from "sonner"; /** * Interface for the dataset context state @@ -121,17 +121,17 @@ export function DatasetProvider({ }); if (result.success) { - toast.success( - result.message ?? - `Image added to dataset ${targetDataset} successfully`, - ); + toast.success({ + title: result.message ?? + `Image added to dataset ${targetDataset} successfully` + }); } else { - toast.error(result.message ?? "Failed to add image to dataset"); + toast.error({ title: result.message ?? "Failed to add image to dataset" }); } } catch (error) { - toast.error( - `Failed to add image to dataset: ${(error as Error).message}`, - ); + toast.error({ + title: `Failed to add image to dataset: ${(error as Error).message}` + }); console.error("Error adding image to dataset:", error); } }, @@ -149,10 +149,10 @@ export function DatasetProvider({ // Otherwise use the mutation from dataset service await createDatasetMutation.mutateAsync(name); - toast.success(`Created dataset ${name}`); + toast.success({ title: `Created dataset ${name}` }); } catch (error) { console.error("Failed to create dataset:", error); - toast.error(`Failed to create dataset: ${(error as Error).message}`); + toast.error({ title: `Failed to create dataset: ${(error as Error).message}` }); throw error; } }, diff --git a/graphcap_studio/src/features/datasets/hooks/useDatasets.ts b/graphcap_studio/src/features/datasets/hooks/useDatasets.ts index 24557fa1..dd804d7d 100644 --- a/graphcap_studio/src/features/datasets/hooks/useDatasets.ts +++ b/graphcap_studio/src/features/datasets/hooks/useDatasets.ts @@ -5,9 +5,9 @@ import { useListDatasets, } from "@/services/dataset"; import { getQueryClient } from "@/utils/queryClient"; +import { toast } from "@/utils/toast"; // SPDX-License-Identifier: Apache-2.0 import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; /** * Custom hook for managing datasets @@ -96,10 +96,10 @@ export function useDatasets() { setSelectedDataset(name); setSelectedSubfolder(null); - toast.success(`Created dataset ${name}`); + toast.success({ title: `Created dataset ${name}` }); } catch (error) { console.error("Failed to create dataset:", error); - toast.error(`Failed to create dataset: ${(error as Error).message}`); + toast.error({ title: `Failed to create dataset: ${(error as Error).message}` }); throw error; } }, @@ -120,17 +120,17 @@ export function useDatasets() { }); if (result.success) { - toast.success( - result.message ?? - `Image added to dataset ${targetDataset} successfully`, - ); + toast.success({ + title: result.message ?? + `Image added to dataset ${targetDataset} successfully` + }); } else { - toast.error(result.message ?? "Failed to add image to dataset"); + toast.error({ title: result.message ?? "Failed to add image to dataset" }); } } catch (error) { - toast.error( - `Failed to add image to dataset: ${(error as Error).message}`, - ); + toast.error({ + title: `Failed to add image to dataset: ${(error as Error).message}` + }); console.error("Error adding image to dataset:", error); } }, @@ -151,11 +151,11 @@ export function useDatasets() { // After refresh, identify new images and mark them as recently uploaded if (currentDataset?.images) { const newRecentImages = new Set(recentlyUploadedImages); - currentDataset.images.forEach((image) => { + for (const image of currentDataset.images) { // Add all images from the current dataset to the recent set // In a real implementation, you might want to be more selective newRecentImages.add(image.path); - }); + } setRecentlyUploadedImages(newRecentImages); // Clear the recent uploads set after 5 minutes diff --git a/graphcap_studio/src/features/editor/components/ImageEditor.tsx b/graphcap_studio/src/features/editor/components/ImageEditor.tsx index 268f50f9..3a2bfdcf 100644 --- a/graphcap_studio/src/features/editor/components/ImageEditor.tsx +++ b/graphcap_studio/src/features/editor/components/ImageEditor.tsx @@ -1,11 +1,11 @@ import { - ImageProcessResponse, + type ImageProcessResponse, getImageUrl, useProcessImage, } from "@/services/images"; +import { toast } from "@/utils/toast"; import { useCallback, useState } from "react"; -import Cropper, { Area } from "react-easy-crop"; -import { toast } from "sonner"; +import Cropper, { type Area } from "react-easy-crop"; import { ImageViewer } from "../../gallery-viewer"; interface ImageEditorProps { @@ -36,7 +36,7 @@ export function ImageEditor({ imagePath, onSave, onCancel }: ImageEditorProps) { const handleSave = async () => { if (!croppedAreaPixels) { - toast.error("No crop area selected"); + toast.error({ title: "No crop area selected" }); return; } @@ -55,12 +55,12 @@ export function ImageEditor({ imagePath, onSave, onCancel }: ImageEditorProps) { }, }); - toast.success("Image saved successfully"); + toast.success({ title: "Image saved successfully" }); onSave?.(result); } catch (error) { - toast.error( - `Failed to save image: ${error instanceof Error ? error.message : String(error)}`, - ); + toast.error({ + title: `Failed to save image: ${error instanceof Error ? error.message : String(error)}`, + }); } finally { setIsSaving(false); } diff --git a/graphcap_studio/src/features/editor/hooks/useImageActions.ts b/graphcap_studio/src/features/editor/hooks/useImageActions.ts index 8dc99406..8d89dd4a 100644 --- a/graphcap_studio/src/features/editor/hooks/useImageActions.ts +++ b/graphcap_studio/src/features/editor/hooks/useImageActions.ts @@ -1,7 +1,7 @@ -import { Image } from "@/services/images"; +import type { Image } from "@/services/images"; +import { toast } from "@/utils/toast"; // SPDX-License-Identifier: Apache-2.0 import { useCallback } from "react"; -import { toast } from "sonner"; interface UseImageActionsProps { selectedImage: Image | null; @@ -37,7 +37,7 @@ export function useImageActions({ const handleDownload = useCallback(() => { if (selectedImage) { // Implementation for download - toast.success("Download started"); + toast.success({ title: "Download started" }); } }, [selectedImage]); @@ -45,7 +45,7 @@ export function useImageActions({ const handleDelete = useCallback(() => { if (selectedImage) { // Implementation for delete - toast.success("Image deleted"); + toast.success({ title: "Image deleted" }); } }, [selectedImage]); diff --git a/graphcap_studio/src/features/editor/hooks/useImageEditor.ts b/graphcap_studio/src/features/editor/hooks/useImageEditor.ts index d72aa487..946df122 100644 --- a/graphcap_studio/src/features/editor/hooks/useImageEditor.ts +++ b/graphcap_studio/src/features/editor/hooks/useImageEditor.ts @@ -1,9 +1,9 @@ import { queryKeys } from "@/services/dataset"; -import { Image } from "@/services/images"; +import type { Image } from "@/services/images"; +import { toast } from "@/utils/toast"; import { useQueryClient } from "@tanstack/react-query"; // SPDX-License-Identifier: Apache-2.0 import { useCallback, useState } from "react"; -import { toast } from "sonner"; interface UseImageEditorProps { selectedDataset: string | null; @@ -28,7 +28,7 @@ export function useImageEditor({ selectedDataset }: UseImageEditorProps) { if (selectedImage) { setIsEditing(true); } else { - toast.error("Please select an image to edit"); + toast.error({ title: "Please select an image to edit" }); } }, []); @@ -36,7 +36,7 @@ export function useImageEditor({ selectedDataset }: UseImageEditorProps) { * Save edited image */ const handleSave = useCallback(() => { - toast.success("Image saved successfully"); + toast.success({ title: "Image saved successfully" }); setIsEditing(false); // Invalidate cache for this dataset to refresh the images diff --git a/graphcap_studio/src/features/gallery-viewer/image-uploader/useImageUploader.ts b/graphcap_studio/src/features/gallery-viewer/image-uploader/useImageUploader.ts index 3f0fffc8..476bc672 100644 --- a/graphcap_studio/src/features/gallery-viewer/image-uploader/useImageUploader.ts +++ b/graphcap_studio/src/features/gallery-viewer/image-uploader/useImageUploader.ts @@ -1,9 +1,9 @@ import { useUploadImage } from "@/services/images"; +import { toast } from "@/utils/toast"; // SPDX-License-Identifier: Apache-2.0 // TODO: RESOLVE OLD DATASET NAME SYSTEM import { useCallback, useState } from "react"; import { useDropzone } from "react-dropzone"; -import { toast } from "sonner"; export interface UseImageUploaderProps { readonly datasetName: string; @@ -48,9 +48,9 @@ export function useImageUploader({ // Initialize progress for each file const initialProgress: Record = {}; - acceptedFiles.forEach((file) => { + for (const file of acceptedFiles) { initialProgress[file.name] = 0; - }); + } setUploadProgress(initialProgress); // Process files sequentially to avoid overwhelming the server @@ -73,7 +73,7 @@ export function useImageUploader({ })); // Show success toast for each file - toast.success(`Uploaded ${file.name}`); + toast.success({ title: `Uploaded ${file.name}` }); } catch (error) { console.error(`Error uploading ${file.name}:`, error); failedUploads.push(file.name); @@ -85,25 +85,25 @@ export function useImageUploader({ })); // Show error toast - toast.error(`Failed to upload ${file.name}`); + toast.error({ title: `Failed to upload ${file.name}` }); } } // Show summary toast if (failedUploads.length === 0) { if (totalFiles > 1) { - toast.success(`Successfully uploaded all ${totalFiles} images`); + toast.success({ title: `Successfully uploaded all ${totalFiles} images` }); } } else { - toast.error( - `Failed to upload ${failedUploads.length} of ${totalFiles} images`, - ); + toast.error({ + title: `Failed to upload ${failedUploads.length} of ${totalFiles} images`, + }); } setIsUploading(false); onUploadComplete(); }, - [datasetName, onUploadComplete], + [datasetName, onUploadComplete, uploadImageMutation.mutateAsync], ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ diff --git a/graphcap_studio/src/features/inference/constants.ts b/graphcap_studio/src/features/inference/constants.ts index 63343b2f..0d923b2c 100644 --- a/graphcap_studio/src/features/inference/constants.ts +++ b/graphcap_studio/src/features/inference/constants.ts @@ -5,19 +5,26 @@ */ export const DEFAULT_PROVIDER_FORM_DATA = { name: "", - kind: "", + kind: "openai" as const, environment: "cloud" as const, baseUrl: "", - envVar: "", - isEnabled: true, + apiKey: "", + isEnabled: false, + defaultModel: "", models: [], - rateLimits: { - requestsPerMinute: 0, - tokensPerMinute: 0, - }, }; /** * Environment options for providers */ export const PROVIDER_ENVIRONMENTS = ["cloud", "local"] as const; + +/** + * Provider kinds + */ +export const PROVIDER_KINDS = [ + "openai", + "gemini", + "ollama", + "vllm", +] as const; diff --git a/graphcap_studio/src/features/inference/generation-options/README.md b/graphcap_studio/src/features/inference/generation-options/README.md deleted file mode 100644 index f805d793..00000000 --- a/graphcap_studio/src/features/inference/generation-options/README.md +++ /dev/null @@ -1,163 +0,0 @@ -# Generation Options Module - -This module provides components and context for managing model generation options such as temperature, max tokens, top_p, and repetition penalty. - -## Features - -- React Context API for state management -- Zod schema for validation -- Chakra UI Popover for form display -- Individual field components for easy reuse - -## Usage - -### Basic Setup - -Wrap your component tree with the provider: - -```tsx -import { GenerationOptionsProvider } from '@/features/inference/generation-options'; - -function App() { - return ( - - - - ); -} -``` - -### Using the Button - -The simplest way to add generation options to your UI: - -```tsx -import { GenerationOptionsButton } from '@/features/inference/generation-options'; - -function YourComponent() { - return ( -
- {/* Other UI elements */} - -
- ); -} -``` - -### Custom Trigger - -You can use your own trigger element instead of the default button: - -```tsx -import { GenerationOptionsPopover, useGenerationOptions } from '@/features/inference/generation-options'; -import { IconButton } from '@/components/ui'; - -function YourComponent() { - const { togglePopover } = useGenerationOptions(); - - return ( -
- {/* Other UI elements */} - - - -
- ); -} -``` - -### Accessing Options State - -You can access the current options state anywhere in your component tree: - -```tsx -import { useGenerationOptions } from '@/features/inference/generation-options'; - -function YourComponent() { - const { options, updateOption, resetOptions } = useGenerationOptions(); - - // Example: Get the current temperature value - console.log('Current temperature:', options.temperature); - - // Example: Update an option - const handleTemperatureChange = (newValue) => { - updateOption('temperature', newValue); - }; - - return ( -
- {/* Your UI using options */} -
- ); -} -``` - -### Getting Notified of Changes - -You can pass an `onOptionsChange` callback to the provider: - -```tsx -import { GenerationOptionsProvider } from '@/features/inference/generation-options'; -import type { GenerationOptions } from '@/features/inference/generation-options'; - -function App() { - const handleOptionsChange = (options: GenerationOptions) => { - console.log('Options changed:', options); - // Do something with the updated options - }; - - return ( - - - - ); -} -``` - -## Components - -- `GenerationOptionsProvider`: Context provider -- `GenerationOptionsButton`: Button that opens the options popover -- `GenerationOptionsPopover`: Popover container for custom triggers -- Field Components: - - `TemperatureField`: Controls the temperature option - - `MaxTokensField`: Controls the max_tokens option - - `TopPField`: Controls the top_p option - - `RepetitionPenaltyField`: Controls the repetition_penalty option - -## API - -### GenerationOptionsProvider Props - -| Prop | Type | Description | -|------|------|-------------| -| `children` | `React.ReactNode` | Child components | -| `initialOptions` | `Partial` | Initial values for options | -| `onOptionsChange` | `(options: GenerationOptions) => void` | Callback when options change | - -### GenerationOptionsButton Props - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `label` | `string` | `'Options'` | Button text | -| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'sm'` | Button size | -| `variant` | `'solid' \| 'outline' \| 'ghost'` | `'outline'` | Button variant | - -### useGenerationOptions Hook - -The hook returns an object with the following properties: - -- `options`: Current options state -- `isPopoverOpen`: Whether the popover is open -- `isGenerating`: Whether generation is in progress -- `updateOption`: Function to update a single option -- `resetOptions`: Function to reset options to defaults -- `setOptions`: Function to update multiple options -- `openPopover`: Function to open the popover -- `closePopover`: Function to close the popover -- `togglePopover`: Function to toggle the popover -- `setIsGenerating`: Function to update the isGenerating state \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/generation-options/components/GenerationOptionsButton.tsx b/graphcap_studio/src/features/inference/generation-options/components/GenerationOptionsButton.tsx deleted file mode 100644 index 44e25613..00000000 --- a/graphcap_studio/src/features/inference/generation-options/components/GenerationOptionsButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -/** - * Generation Options Button - * - * This component provides a button that triggers the generation options popover. - */ - -import { Button } from "@/components/ui"; -import React from "react"; -import { useGenerationOptions } from "../context"; -import { GenerationOptionsPopover } from "./GenerationOptionsPopover"; - -interface GenerationOptionsButtonProps { - readonly label?: React.ReactNode; - readonly size?: "xs" | "sm" | "md" | "lg"; - readonly variant?: "solid" | "outline" | "ghost"; -} - -/** - * Button component for triggering generation options popover - */ -export function GenerationOptionsButton({ - label = "Options", - size = "sm", - variant = "outline", -}: GenerationOptionsButtonProps) { - const { togglePopover, isGenerating } = useGenerationOptions(); - - return ( - - - - ); -} diff --git a/graphcap_studio/src/features/inference/generation-options/components/GenerationOptionsPanel.tsx b/graphcap_studio/src/features/inference/generation-options/components/GenerationOptionsPanel.tsx new file mode 100644 index 00000000..a01b22e9 --- /dev/null +++ b/graphcap_studio/src/features/inference/generation-options/components/GenerationOptionsPanel.tsx @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Generation Options Panel + * + * This component displays generation options in the left action drawer. + */ + +import { Box, Button, VStack } from "@chakra-ui/react"; +import { useGenerationOptions } from "../context"; +import { + GlobalContextField, + MaxTokensField, + ModelSelectorField, + RepetitionPenaltyField, + ResizeResolutionField, + TemperatureField, + TopPField, +} from "./fields"; + +/** + * Panel component for generation options in the left action drawer + */ +export function GenerationOptionsPanel() { + const { actions, uiState } = useGenerationOptions(); + const { resetOptions } = actions; + const { isGenerating } = uiState; + + return ( + + + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/generation-options/components/GenerationOptionsPopover.tsx b/graphcap_studio/src/features/inference/generation-options/components/GenerationOptionsPopover.tsx deleted file mode 100644 index 7fc12e30..00000000 --- a/graphcap_studio/src/features/inference/generation-options/components/GenerationOptionsPopover.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -/** - * Generation Options Popover - * - * This component displays a popover with generation options form. - */ - -import { Button } from "@/components/ui"; -import { useColorModeValue } from "@/components/ui/theme/color-mode"; -import { Box, Flex, HStack, Popover, Portal } from "@chakra-ui/react"; -import React from "react"; -import { useGenerationOptions } from "../context"; -import { - GlobalContextField, - MaxTokensField, - RepetitionPenaltyField, - ResizeResolutionField, - TemperatureField, - TopPField, -} from "./fields"; - -interface GenerationOptionsPopoverProps { - readonly children: React.ReactNode; -} - -/** - * Popover component for generation options - */ -export function GenerationOptionsPopover({ - children, -}: GenerationOptionsPopoverProps) { - const { isPopoverOpen, closePopover, resetOptions, isGenerating } = - useGenerationOptions(); - - // Colors for theming - const bgColor = useColorModeValue("white", "gray.700"); - const borderColor = useColorModeValue("gray.200", "gray.600"); - const headerColor = useColorModeValue("gray.800", "white"); - - return ( - (e.open ? null : closePopover())} - > - {children} - - - - - - Generation Options - - - ✕ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/graphcap_studio/src/features/inference/generation-options/components/ProviderSelector.tsx b/graphcap_studio/src/features/inference/generation-options/components/ProviderSelector.tsx index b40fcf65..4d3603c4 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/ProviderSelector.tsx +++ b/graphcap_studio/src/features/inference/generation-options/components/ProviderSelector.tsx @@ -5,14 +5,7 @@ * A component for selecting an inference provider. */ -import { - SelectContent, - SelectItem, - SelectRoot, - SelectTrigger, - SelectValueText, -} from "@/components/ui/select"; -import { Box, createListCollection } from "@chakra-ui/react"; +import { ProviderSelector as CommonProviderSelector, type ProviderOption } from "@/components/common_inference/ProviderSelector"; export interface Provider { id: number; @@ -44,55 +37,25 @@ export function ProviderSelector({ placeholder = "Select provider", className, }: ProviderSelectorProps) { - // Convert providers to the format expected by SelectRoot - const providerItems = providers.map((provider) => ({ + // Convert providers to ProviderOption format + const providerOptions: ProviderOption[] = providers.map((provider) => ({ label: provider.name, value: provider.name, + id: provider.id, })); - const providerCollection = createListCollection({ - items: providerItems, - }); - - // Convert selectedProvider to string array format required by SelectRoot - const value = selectedProvider ? [selectedProvider] : []; - - const handleValueChange = (details: any) => { - if (details.value && details.value.length > 0) { - onChange(details.value[0]); - } else { - onChange(""); - } - }; - - const boxProps = { - ...(maxWidth ? { maxWidth } : {}), - ...(minWidth ? { minWidth } : {}), - ...(width ? { width } : {}), - className, - }; - return ( - - - - - - - {providerItems.map((item) => ( - - {item.label} - - ))} - - - + ); } diff --git a/graphcap_studio/src/features/inference/generation-options/components/fields/GlobalContextField.tsx b/graphcap_studio/src/features/inference/generation-options/components/fields/GlobalContextField.tsx index 70f84901..3ead3ae8 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/fields/GlobalContextField.tsx +++ b/graphcap_studio/src/features/inference/generation-options/components/fields/GlobalContextField.tsx @@ -7,14 +7,16 @@ import { useColorModeValue } from "@/components/ui/theme/color-mode"; import { Box, Textarea } from "@chakra-ui/react"; -import { ChangeEvent, useCallback, useEffect, useState } from "react"; +import { type ChangeEvent, useCallback, useEffect, useState } from "react"; import { useGenerationOptions } from "../../context"; /** * Global context control field component */ export function GlobalContextField() { - const { options, updateOption, isGenerating } = useGenerationOptions(); + const { options, actions, uiState } = useGenerationOptions(); + const { updateOption } = actions; + const { isGenerating } = uiState; const [localValue, setLocalValue] = useState(options.global_context); // Color values for theming @@ -32,7 +34,9 @@ export function GlobalContextField() { debounce((value: string) => { updateOption("global_context", value); }, 500), - [updateOption], + // updateOption is from context and won't change during component's lifecycle + // eslint-disable-next-line react-hooks/exhaustive-deps + [], ); const handleChange = (e: ChangeEvent) => { @@ -63,13 +67,14 @@ export function GlobalContextField() { } // Debounce utility function -function debounce any>( +function debounce) => ReturnType>( func: T, wait: number, ): (...args: Parameters) => void { let timeout: NodeJS.Timeout | null = null; - return function (...args: Parameters) { + // Using arrow function to avoid "this function expression can be turned into an arrow function" lint error + return (...args: Parameters) => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; diff --git a/graphcap_studio/src/features/inference/generation-options/components/fields/MaxTokensField.tsx b/graphcap_studio/src/features/inference/generation-options/components/fields/MaxTokensField.tsx index 1e3e8d42..f242561d 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/fields/MaxTokensField.tsx +++ b/graphcap_studio/src/features/inference/generation-options/components/fields/MaxTokensField.tsx @@ -2,7 +2,7 @@ /** * Max Tokens Field Component * - * This component renders the max tokens option field. + * This component renders the max_tokens option field. */ import { useGenerationOptions } from "../../context"; @@ -12,11 +12,12 @@ import { OptionField } from "./OptionField"; * Max tokens control field component */ export function MaxTokensField() { - const { options, updateOption, isGenerating } = useGenerationOptions(); + const { options, actions, uiState } = useGenerationOptions(); + const { updateOption } = actions; + const { isGenerating } = uiState; const handleChange = (value: number) => { - // Ensure we're using an integer - updateOption("max_tokens", Math.round(value)); + updateOption("max_tokens", value); }; return ( diff --git a/graphcap_studio/src/features/inference/generation-options/components/fields/ModelSelectorField.tsx b/graphcap_studio/src/features/inference/generation-options/components/fields/ModelSelectorField.tsx new file mode 100644 index 00000000..6b3a53b6 --- /dev/null +++ b/graphcap_studio/src/features/inference/generation-options/components/fields/ModelSelectorField.tsx @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Model Selector Field Component + * + * This component provides controls for selecting a provider and model. + */ + +import { Field } from "@/components/ui/field"; +import { useColorModeValue } from "@/components/ui/theme/color-mode"; +import { Box, Portal, Select, createListCollection } from "@chakra-ui/react"; +import { useGenerationOptions } from "../../context"; + +/** + * Field component for selecting model and provider + */ +export function ModelSelectorField() { + const { + options, + providers, + models, + actions, + uiState + } = useGenerationOptions(); + + const { selectProvider, selectModel } = actions; + const { isGenerating } = uiState; + + // Color values for theming + const labelColor = useColorModeValue("gray.700", "gray.300"); + const helperTextColor = useColorModeValue("gray.500", "gray.400"); + + // Create collections for selects - always include at least one item + const providerCollection = createListCollection({ + items: providers.items.length > 0 + ? providers.items.map((provider) => ({ + label: provider.name, + value: provider.name, + disabled: false, + })) + : [{ label: "No providers available", value: "none", disabled: false }] + }); + + // Create model collection using names as both label and value + const modelCollection = createListCollection({ + items: models.items.length > 0 + ? models.items.map((model) => ({ + label: model.name, + value: model.name, + disabled: false, + })) + : [{ label: "No models available", value: "none", disabled: false }] + }); + + // Handle provider change + const handleProviderChange = (details: { value: string[] }) => { + if (details.value.length > 0 && details.value[0] !== "none") { + selectProvider(details.value[0]); + } + }; + + // Handle model change + const handleModelChange = (details: { value: string[] }) => { + if (details.value.length > 0 && details.value[0] !== "none") { + selectModel(details.value[0]); + } + }; + + // Check if any providers are available + const hasProviders = providers.items.length > 0; + + // Loading state + const isProvidersLoading = providers.isLoading; + const isModelsLoading = models.isLoading; + + return ( + + + Provider & Model + + + + + + + + + + + + + + + + + + + {providerCollection.items.map((provider) => ( + + {provider.label} + + + ))} + + + + + + + + + + + + + + + + + + + + + + + + {modelCollection.items.map((model) => ( + + {model.label} + + + ))} + + + + + + + + + Select provider and model for generation + + + ); +} diff --git a/graphcap_studio/src/features/inference/generation-options/components/fields/OptionField.tsx b/graphcap_studio/src/features/inference/generation-options/components/fields/OptionField.tsx index bf82efbf..7e3fb06e 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/fields/OptionField.tsx +++ b/graphcap_studio/src/features/inference/generation-options/components/fields/OptionField.tsx @@ -7,9 +7,9 @@ import { Slider } from "@/components/ui/slider"; import { useColorModeValue } from "@/components/ui/theme/color-mode"; +import { OPTION_CONFIGS } from "@/types/generation-option-types"; import { Box, HStack, Input } from "@chakra-ui/react"; -import { ChangeEvent } from "react"; -import { OPTION_CONFIGS } from "../../schema"; +import type { ChangeEvent } from "react"; export type OptionFieldKey = keyof typeof OPTION_CONFIGS; @@ -46,8 +46,8 @@ export function OptionField({ // Handle direct input changes const handleInputChange = (e: ChangeEvent) => { - const valueAsNumber = parseFloat(e.target.value); - if (isNaN(valueAsNumber)) return; + const valueAsNumber = Number.parseFloat(e.target.value); + if (Number.isNaN(valueAsNumber)) return; // Ensure value is within bounds const boundedValue = Math.max( diff --git a/graphcap_studio/src/features/inference/generation-options/components/fields/RepetitionPenaltyField.tsx b/graphcap_studio/src/features/inference/generation-options/components/fields/RepetitionPenaltyField.tsx index ad0410cf..fd770f9b 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/fields/RepetitionPenaltyField.tsx +++ b/graphcap_studio/src/features/inference/generation-options/components/fields/RepetitionPenaltyField.tsx @@ -12,7 +12,9 @@ import { OptionField } from "./OptionField"; * Repetition penalty control field component */ export function RepetitionPenaltyField() { - const { options, updateOption, isGenerating } = useGenerationOptions(); + const { options, actions, uiState } = useGenerationOptions(); + const { updateOption } = actions; + const { isGenerating } = uiState; const handleChange = (value: number) => { updateOption("repetition_penalty", value); diff --git a/graphcap_studio/src/features/inference/generation-options/components/fields/ResizeResolutionField.tsx b/graphcap_studio/src/features/inference/generation-options/components/fields/ResizeResolutionField.tsx index f70ce65a..e7b370e3 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/fields/ResizeResolutionField.tsx +++ b/graphcap_studio/src/features/inference/generation-options/components/fields/ResizeResolutionField.tsx @@ -6,15 +6,17 @@ */ import { useColorModeValue } from "@/components/ui/theme/color-mode"; +import { RESOLUTION_PRESETS } from "@/types/generation-option-types"; import { Box, HStack } from "@chakra-ui/react"; import { useGenerationOptions } from "../../context"; -import { RESOLUTION_PRESETS } from "../../schema"; /** * Field component for adjusting image resize resolution */ export function ResizeResolutionField() { - const { options, updateOption, isGenerating } = useGenerationOptions(); + const { options, actions, uiState } = useGenerationOptions(); + const { updateOption } = actions; + const { isGenerating } = uiState; // Color values for theming const labelColor = useColorModeValue("gray.700", "gray.300"); diff --git a/graphcap_studio/src/features/inference/generation-options/components/fields/TemperatureField.tsx b/graphcap_studio/src/features/inference/generation-options/components/fields/TemperatureField.tsx index 7c56a5b0..c6536029 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/fields/TemperatureField.tsx +++ b/graphcap_studio/src/features/inference/generation-options/components/fields/TemperatureField.tsx @@ -12,7 +12,9 @@ import { OptionField } from "./OptionField"; * Temperature control field component */ export function TemperatureField() { - const { options, updateOption, isGenerating } = useGenerationOptions(); + const { options, actions, uiState } = useGenerationOptions(); + const { updateOption } = actions; + const { isGenerating } = uiState; const handleChange = (value: number) => { updateOption("temperature", value); diff --git a/graphcap_studio/src/features/inference/generation-options/components/fields/TopPField.tsx b/graphcap_studio/src/features/inference/generation-options/components/fields/TopPField.tsx index 1500fc41..84ce0a81 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/fields/TopPField.tsx +++ b/graphcap_studio/src/features/inference/generation-options/components/fields/TopPField.tsx @@ -9,10 +9,12 @@ import { useGenerationOptions } from "../../context"; import { OptionField } from "./OptionField"; /** - * Top P control field component + * Top-P (nucleus sampling) control field component */ export function TopPField() { - const { options, updateOption, isGenerating } = useGenerationOptions(); + const { options, actions, uiState } = useGenerationOptions(); + const { updateOption } = actions; + const { isGenerating } = uiState; const handleChange = (value: number) => { updateOption("top_p", value); diff --git a/graphcap_studio/src/features/inference/generation-options/components/fields/index.ts b/graphcap_studio/src/features/inference/generation-options/components/fields/index.ts index 2abc98cd..2d5d59ca 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/fields/index.ts +++ b/graphcap_studio/src/features/inference/generation-options/components/fields/index.ts @@ -12,3 +12,4 @@ export * from "./TopPField"; export * from "./RepetitionPenaltyField"; export * from "./GlobalContextField"; export * from "./ResizeResolutionField"; +export * from "./ModelSelectorField"; diff --git a/graphcap_studio/src/features/inference/generation-options/components/index.ts b/graphcap_studio/src/features/inference/generation-options/components/index.ts index 4cae6d1c..d8371081 100644 --- a/graphcap_studio/src/features/inference/generation-options/components/index.ts +++ b/graphcap_studio/src/features/inference/generation-options/components/index.ts @@ -5,6 +5,7 @@ * This module exports all components for generation options. */ -export * from "./GenerationOptionsPopover"; -export * from "./GenerationOptionsButton"; +export * from "./fields"; +export * from "./GenerationOptionsPanel"; export * from "./ProviderSelector"; + diff --git a/graphcap_studio/src/features/inference/generation-options/context/GenerationOptionsContext.tsx b/graphcap_studio/src/features/inference/generation-options/context/GenerationOptionsContext.tsx index 6311bdf3..51885305 100644 --- a/graphcap_studio/src/features/inference/generation-options/context/GenerationOptionsContext.tsx +++ b/graphcap_studio/src/features/inference/generation-options/context/GenerationOptionsContext.tsx @@ -2,45 +2,66 @@ /** * Generation Options Context * - * This module provides a context for managing generation options state. + * This module provides a context for managing generation options state, + * including provider and model selection. */ -import React, { +import { + DEFAULT_OPTIONS, + type GenerationOptions, + GenerationOptionsSchema, +} from "@/types/generation-option-types"; +import type { Provider, ProviderModelInfo } from "@/types/provider-config-types"; +import type React from "react"; +import { createContext, - useContext, - useState, useCallback, - useMemo, + useContext, useEffect, + useMemo, + useState, } from "react"; +import { useProviderModelOptions } from "../../hooks/useProviderModelOptions"; import { usePersistGenerationOptions } from "../persist-generation-options"; -import { - DEFAULT_OPTIONS, - GenerationOptions, - GenerationOptionsSchema, -} from "../schema"; // Define the context interface interface GenerationOptionsContextValue { - // State + // State groups options: GenerationOptions; - isPopoverOpen: boolean; - isGenerating: boolean; - - // Actions - updateOption: ( - key: K, - value: GenerationOptions[K], - ) => void; - resetOptions: () => void; - setOptions: (options: Partial) => void; - openPopover: () => void; - closePopover: () => void; - togglePopover: () => void; - setIsGenerating: (isGenerating: boolean) => void; + providers: { + items: Provider[]; + selected: Provider | null; + isLoading: boolean; + error: unknown; + }; + models: { + items: ProviderModelInfo[]; + defaultModel: ProviderModelInfo | null; + isLoading: boolean; + error: unknown; + }; + uiState: { + isDialogOpen: boolean; + isGenerating: boolean; + }; + + // Action groups + actions: { + updateOption: (key: K, value: GenerationOptions[K]) => void; + resetOptions: () => void; + setOptions: (options: Partial) => void; + selectProvider: (providerName: string) => void; + selectModel: (modelName: string) => void; + }; + uiActions: { + openDialog: () => void; + closeDialog: () => void; + toggleDialog: () => void; + setIsGenerating: (isGenerating: boolean) => void; + }; } -// Create the context with a default value +// Create the context with undefined default const GenerationOptionsContext = createContext< GenerationOptionsContextValue | undefined >(undefined); @@ -78,9 +99,21 @@ export function GenerationOptionsProvider({ // State const [options, setOptions] = useState(defaultOptions); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); const [isGenerating, setIsGenerating] = useState(initialGenerating); + // Provider and model data + const { + providers, + selectedProvider, + isLoadingProviders, + providersError, + models, + defaultModel, + isLoading, + hasError + } = useProviderModelOptions(options.provider_name); + // Save options to localStorage when they change useEffect(() => { saveOptions(options); @@ -91,6 +124,24 @@ export function GenerationOptionsProvider({ setIsGenerating(initialGenerating); }, [initialGenerating]); + // Initialize provider if available and not already set + useEffect(() => { + if (providers.length > 0 && !options.provider_name && !isLoadingProviders) { + const firstProvider = providers[0]; + updateOption("provider_name", firstProvider.name); + } + }, [providers, options.provider_name, isLoadingProviders]); + + + useEffect(() => { + // Only set model if we have a provider and no model is selected yet + if (options.provider_name && !options.model_name && models.length > 0) { + // Try to use default model first, otherwise use first available model + const modelToUse = defaultModel || models[0]; + updateOption("model_name", modelToUse.name); + } + }, [options.provider_name, options.model_name, models, defaultModel]); + // Update a single option const updateOption = useCallback( ( @@ -131,42 +182,82 @@ export function GenerationOptionsProvider({ [onOptionsChange], ); - // Popover controls - const openPopover = useCallback(() => setIsPopoverOpen(true), []); - const closePopover = useCallback(() => setIsPopoverOpen(false), []); - const togglePopover = useCallback( - () => setIsPopoverOpen((prev) => !prev), - [], - ); + // Provider selection + const selectProvider = useCallback((providerName: string) => { + if (providerName !== options.provider_name) { + updateOption("provider_name", providerName); + updateOption("model_name", ""); + } + }, [updateOption, options.provider_name]); - // Context value + // Model selection + const selectModel = useCallback((modelName: string) => { + updateOption("model_name", modelName); + }, [updateOption]); + + // Dialog controls + const openDialog = useCallback(() => setIsDialogOpen(true), []); + const closeDialog = useCallback(() => setIsDialogOpen(false), []); + const toggleDialog = useCallback(() => setIsDialogOpen((prev) => !prev), []); + + // Context value using grouped structure const value = useMemo( () => ({ - // State + // State groups options, - isPopoverOpen, - isGenerating, + providers: { + items: providers, + selected: selectedProvider, + isLoading: isLoadingProviders, + error: providersError + }, + models: { + items: models, + defaultModel, + isLoading: isLoading, + error: hasError ? new Error("Failed to load models") : null + }, + uiState: { + isDialogOpen, + isGenerating + }, - // Actions - updateOption, - resetOptions, - setOptions: mergeOptions, - openPopover, - closePopover, - togglePopover, - setIsGenerating, + // Action groups + actions: { + updateOption, + resetOptions, + setOptions: mergeOptions, + selectProvider, + selectModel + }, + uiActions: { + openDialog, + closeDialog, + toggleDialog, + setIsGenerating + } }), [ options, - isPopoverOpen, + providers, + selectedProvider, + isLoadingProviders, + providersError, + models, + defaultModel, + isLoading, + hasError, + isDialogOpen, isGenerating, updateOption, resetOptions, mergeOptions, - openPopover, - closePopover, - togglePopover, - ], + selectProvider, + selectModel, + openDialog, + closeDialog, + toggleDialog, + ] ); return ( @@ -177,17 +268,17 @@ export function GenerationOptionsProvider({ } /** - * Hook to access the generation options context + * Hook to use generation options context + * + * Must be used within a GenerationOptionsProvider */ export function useGenerationOptions() { const context = useContext(GenerationOptionsContext); - - if (context === undefined) { + if (!context) { throw new Error( "useGenerationOptions must be used within a GenerationOptionsProvider", ); } - return context; } diff --git a/graphcap_studio/src/features/inference/generation-options/index.ts b/graphcap_studio/src/features/inference/generation-options/index.ts index e6b81267..aa028147 100644 --- a/graphcap_studio/src/features/inference/generation-options/index.ts +++ b/graphcap_studio/src/features/inference/generation-options/index.ts @@ -7,5 +7,5 @@ export * from "./components"; export * from "./context"; -export * from "./schema"; export * from "./persist-generation-options"; + diff --git a/graphcap_studio/src/features/inference/generation-options/persist-generation-options.ts b/graphcap_studio/src/features/inference/generation-options/persist-generation-options.ts index 10954d49..a2e2cdae 100644 --- a/graphcap_studio/src/features/inference/generation-options/persist-generation-options.ts +++ b/graphcap_studio/src/features/inference/generation-options/persist-generation-options.ts @@ -5,7 +5,10 @@ * This module provides utilities for persisting generation options to localStorage. */ -import { GenerationOptions, GenerationOptionsSchema } from "./schema"; +import { + type GenerationOptions, + GenerationOptionsSchema, +} from "@/types/generation-option-types"; /** * Storage key for saving generation options in localStorage @@ -35,6 +38,7 @@ export function loadGenerationOptions(): GenerationOptions | null { if (!serialized) return null; const parsed = JSON.parse(serialized); + // Validate the loaded data against the schema return GenerationOptionsSchema.parse(parsed); } catch (error) { diff --git a/graphcap_studio/src/features/inference/hooks/index.ts b/graphcap_studio/src/features/inference/hooks/index.ts index 43f6696a..83285847 100644 --- a/graphcap_studio/src/features/inference/hooks/index.ts +++ b/graphcap_studio/src/features/inference/hooks/index.ts @@ -1,5 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 -export * from "./useProviderForm"; -export * from "./useModelSelection"; export * from "./useDatabaseHealth"; -export * from "./useProviderModelSelection"; +export * from "./useModelSelection"; + diff --git a/graphcap_studio/src/features/inference/hooks/useModelSelection.ts b/graphcap_studio/src/features/inference/hooks/useModelSelection.ts index 23be2ffe..037daf4c 100644 --- a/graphcap_studio/src/features/inference/hooks/useModelSelection.ts +++ b/graphcap_studio/src/features/inference/hooks/useModelSelection.ts @@ -1,53 +1,63 @@ +import type { Provider, ProviderModelInfo } from "@/types/provider-config-types"; // SPDX-License-Identifier: Apache-2.0 -import { useCallback, useEffect, useState } from "react"; -import { useProviderModels } from "../services/providers"; +import { useCallback, useEffect, useMemo, useState } from "react"; /** * Custom hook for managing model selection * - * @param providerName - Name of the provider to fetch models for + * @param provider - Provider to use models from, can be null or undefined * @param onModelSelect - Callback function when a model is selected * @returns Model selection state and handlers */ export function useModelSelection( - providerName: string, + provider: Provider | null | undefined, onModelSelect?: (providerName: string, modelId: string) => void, ) { // State for model selection const [selectedModelId, setSelectedModelId] = useState(""); - // Get models for the current provider - const { - data: providerModelsData, - isLoading: isLoadingModels, - isError: isModelsError, - error: modelsError, - } = useProviderModels(providerName); + // Process provider models + const models = useMemo(() => { + if (!provider?.models?.length) return []; + + // Map provider models to ProviderModelInfo format + return provider.models.map(model => ({ + id: model.id, + name: model.name, + is_default: model.name === provider.defaultModel + })); + }, [provider]); - // Update selected model when models are loaded + // Update selected model ID when provider changes useEffect(() => { - if (providerModelsData?.models && providerModelsData.models.length > 0) { - const defaultModel = providerModelsData.models.find( - (model) => model.is_default, - ); - setSelectedModelId(defaultModel?.id ?? providerModelsData.models[0].id); + if (provider?.defaultModel) { + // Try to find the model with the default name + const defaultModel = provider.models?.find(m => m.name === provider.defaultModel); + if (defaultModel) { + setSelectedModelId(defaultModel.id); + return; + } } - }, [providerModelsData]); + + // If no default or default not found, use first model or reset + if (provider?.models?.length) { + setSelectedModelId(provider.models[0].id); + } else { + setSelectedModelId(""); + } + }, [provider]); // Handle model selection const handleModelSelect = useCallback(() => { - if (onModelSelect && providerName && selectedModelId) { - onModelSelect(providerName, selectedModelId); + if (onModelSelect && provider?.name && selectedModelId) { + onModelSelect(provider.name, selectedModelId); } - }, [onModelSelect, providerName, selectedModelId]); + }, [onModelSelect, provider, selectedModelId]); return { selectedModelId, setSelectedModelId, - providerModelsData, - isLoadingModels, - isModelsError, - modelsError, + models, handleModelSelect, }; } diff --git a/graphcap_studio/src/features/inference/hooks/useProviderForm.ts b/graphcap_studio/src/features/inference/hooks/useProviderForm.ts deleted file mode 100644 index 241607ef..00000000 --- a/graphcap_studio/src/features/inference/hooks/useProviderForm.ts +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import type { - ProviderCreate, - ProviderUpdate, -} from "@/features/inference/providers/types"; -import { - useCreateProvider, - useUpdateProvider, -} from "@/features/inference/services/providers"; - -import { useCallback } from "react"; -import { useForm } from "react-hook-form"; -import { DEFAULT_PROVIDER_FORM_DATA } from "../constants"; - -type FormData = ProviderCreate | ProviderUpdate; - -/** - * Custom hook for managing provider form state and operations - */ -export function useProviderForm(initialData: Partial = {}) { - // Initialize react-hook-form - const { - control, - handleSubmit, - reset, - formState: { errors }, - watch, - } = useForm({ - defaultValues: { - ...DEFAULT_PROVIDER_FORM_DATA, - ...initialData, - }, - }); - - // Watch the provider name for use in UI - const providerName = watch("name"); - - // Mutations - const createProvider = useCreateProvider(); - const updateProvider = useUpdateProvider(); - - // Handle form submission - const onSubmit = useCallback( - async (data: FormData, isCreating: boolean, providerId?: number) => { - try { - if (isCreating) { - await createProvider.mutateAsync(data as ProviderCreate); - } else if (providerId) { - await updateProvider.mutateAsync({ - id: providerId, - data: data as ProviderUpdate, - }); - } - reset(DEFAULT_PROVIDER_FORM_DATA); - return { success: true }; - } catch (error) { - return { - error: error instanceof Error ? error.message : "Unknown error", - }; - } - }, - [createProvider, updateProvider, reset], - ); - - return { - // Form state - control, - handleSubmit, - errors, - watch, - providerName, - reset, - - // Form submission - onSubmit, - - // Loading state - isSubmitting: createProvider.isPending || updateProvider.isPending, - }; -} diff --git a/graphcap_studio/src/features/inference/hooks/useProviderModelOptions.ts b/graphcap_studio/src/features/inference/hooks/useProviderModelOptions.ts new file mode 100644 index 00000000..1275116c --- /dev/null +++ b/graphcap_studio/src/features/inference/hooks/useProviderModelOptions.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Provider Model Options Hook + * + * This hook provides access to providers and models data with support for selection. + * It consolidates provider and model data loading in a single hook. + */ + +import { useProviders } from "@/features/server-connections/services/providers"; +import type { Provider, ProviderModelInfo } from "@/types/provider-config-types"; +import { useMemo } from "react"; + +/** + * Hook for accessing provider and model selection options + * + * @param providerName - The selected provider name + * @returns Provider and model data with loading states + */ +export function useProviderModelOptions(providerName?: string) { + // Fetch all providers + const { + data: providers = [], + isLoading: isLoadingProviders, + error: providersError + } = useProviders(); + + // Find the selected provider object + const selectedProvider = useMemo(() => { + if (!providerName || !providers.length) return null; + + // Find provider by name + return providers.find((p: Provider) => p.name === providerName) || null; + }, [providers, providerName]); + + // Process models data directly from the provider + const models = useMemo(() => { + if (!selectedProvider?.models?.length) return []; + + // Map provider models to ProviderModelInfo format + return selectedProvider.models.map((model: { id: string; name: string }) => ({ + id: model.id, + name: model.name, + is_default: model.name === selectedProvider.defaultModel + })); + }, [selectedProvider]); + + // Check for default model + const defaultModel = useMemo(() => { + return models.find(model => model.is_default === true) || null; + }, [models]); + + return { + // Providers data + providers, + selectedProvider, + isLoadingProviders, + providersError, + + // Models data + models, + defaultModel, + + // Helper for status checking + isLoading: isLoadingProviders, + hasError: !!providersError + }; +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/hooks/useProviderModelSelection.ts b/graphcap_studio/src/features/inference/hooks/useProviderModelSelection.ts deleted file mode 100644 index a177e575..00000000 --- a/graphcap_studio/src/features/inference/hooks/useProviderModelSelection.ts +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import { useMemo } from "react"; -import { useProviderModels, useProviders } from "../services/providers"; - -/** - * Custom hook to handle provider and model selection logic - */ -export function useProviderModelSelection(providerName: string) { - // Fetch providers from API - const { - data: providers = [], - isLoading: isLoadingProviders, - isError: isProvidersError, - } = useProviders(); - - // Fetch models for the selected provider - const { - data: providerModelsData, - isLoading: isLoadingModels, - isError: isModelsError, - error: modelsError, - } = useProviderModels(providerName); - - // Memoize the available providers - const availableProviders = useMemo(() => { - return providers.filter((provider) => provider.isEnabled); - }, [providers]); - - // Determine providers with no models - const providersWithNoModels = useMemo(() => { - const noModelsSet = new Set(); - - if (providerModelsData?.models?.length === 0) { - noModelsSet.add(providerName); - } - - return noModelsSet; - }, [providerName, providerModelsData]); - - // Get default model if available - const defaultModel = useMemo(() => { - if (providerModelsData?.models && providerModelsData.models.length > 0) { - return ( - providerModelsData.models.find((model) => model.is_default) || - providerModelsData.models[0] - ); - } - return null; - }, [providerModelsData]); - - return { - providers: availableProviders, - models: providerModelsData?.models || [], - defaultModel, - providersWithNoModels, - isLoading: { - providers: isLoadingProviders, - models: isLoadingModels, - }, - isError: { - providers: isProvidersError, - models: isModelsError, - }, - error: { - models: modelsError, - }, - }; -} diff --git a/graphcap_studio/src/features/inference/providers/FormActions.tsx b/graphcap_studio/src/features/inference/providers/FormActions.tsx deleted file mode 100644 index c7392309..00000000 --- a/graphcap_studio/src/features/inference/providers/FormActions.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useColorMode } from "@/components/ui/theme/color-mode"; -import { Button, Flex, HStack } from "@chakra-ui/react"; -// SPDX-License-Identifier: Apache-2.0 -import { useInferenceProviderContext } from "./context"; - -/** - * Component for rendering form action buttons with Chakra UI styling - */ -export function FormActions() { - const { isSubmitting, isCreating, onCancel } = useInferenceProviderContext(); - - const { colorMode } = useColorMode(); - const isDark = colorMode === "dark"; - - // Determine the button text based on form state - let buttonText = "Save"; - if (isSubmitting) { - buttonText = "Saving..."; - } else if (isCreating) { - buttonText = "Create"; - } - - // Theme-based colors - const cancelBg = isDark ? "gray.700" : "gray.100"; - const cancelHoverBg = isDark ? "gray.600" : "gray.200"; - const cancelColor = isDark ? "gray.200" : "gray.800"; - - const primaryBg = "blue.500"; - const primaryHoverBg = "blue.600"; - - return ( - - - - - - - ); -} diff --git a/graphcap_studio/src/features/inference/providers/ModelSelectionSection.tsx b/graphcap_studio/src/features/inference/providers/ModelSelectionSection.tsx deleted file mode 100644 index 56bf95b6..00000000 --- a/graphcap_studio/src/features/inference/providers/ModelSelectionSection.tsx +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import { Box } from "@chakra-ui/react"; -import { ActionButton } from "../../../components/ui/buttons/ActionButton"; -import { StatusMessage } from "../../../components/ui/status/StatusMessage"; -import { useInferenceProviderContext } from "./context"; -import { ModelSelector } from "./form/ModelSelector"; - -// Define the model type -export interface ProviderModel { - id: string; - name: string; - is_default?: boolean; -} - -/** - * Component for selecting a model from a provider - */ -export function ModelSelectionSection() { - const { - providerName, - selectedModelId, - setSelectedModelId, - providerModelsData, - isLoadingModels, - isModelsError, - modelsError, - handleModelSelect, - isSubmitting, - } = useInferenceProviderContext(); - - // Handle different states - if (!providerName) { - return ( - - ); - } - - if (isLoadingModels) { - return ; - } - - if (isModelsError) { - return ( - - ); - } - - if (!providerModelsData?.models || providerModelsData.models.length === 0) { - return ( - - ); - } - - // Convert models to the format expected by SelectRoot - const modelItems = providerModelsData.models.map((model: ProviderModel) => ({ - label: `${model.name}${model.is_default ? " (Default)" : ""}`, - value: model.id, - })); - - return ( - - - - - - ); -} diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/component.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/component.tsx new file mode 100644 index 00000000..5d9f19f4 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/component.tsx @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { + ProviderCreate, + ProviderUpdate, +} from "@/types/provider-config-types"; +import { ProviderFormView } from "./components/ProviderFormView"; +import { ProviderFormContainer } from "./containers/ProviderFormContainer"; + +interface ProviderConnectionProps { + readonly initialData?: Partial; +} + +/** + * Main provider connection component that handles form state and submission + */ +export function ProviderConnection({ + initialData, +}: ProviderConnectionProps) { + + return ( + + + + ); +} diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ConnectionSteps.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ConnectionSteps.tsx new file mode 100644 index 00000000..ad68b8b1 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ConnectionSteps.tsx @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Box, HStack, Icon, Text, VStack } from "@chakra-ui/react"; +import { LuCheck, LuCircleAlert, LuSkipForward } from "react-icons/lu"; + +/** + * Component that displays connection test steps and their results + */ +interface ConnectionStep { + readonly step: string; + readonly status: "success" | "failed" | "skipped" | "pending"; + readonly timestamp: string; + readonly error?: string; + readonly message?: string; +} + +interface ConnectionStepsProps { + readonly steps: ConnectionStep[]; + readonly stepLabels?: Record; +} + +interface StepIconProps { + readonly status: ConnectionStep["status"]; +} + +interface StepLabelProps { + readonly step: ConnectionStep; + readonly labels?: Record; +} + +function StepIcon({ status }: StepIconProps) { + switch (status) { + case "success": + return ; + case "skipped": + return ; + case "failed": + return ; + default: + return null; + } +} + +function ConnectionStepResult({ step, labels }: StepLabelProps) { + const stepLabel = labels?.[step.step] ?? step.step; + + return ( + + + + + + {stepLabel} + {step.message && ( + {step.message} + )} + {step.error && ( + {step.error} + )} + + + ); +} + +export function ConnectionSteps({ steps, stepLabels = {} }: ConnectionStepsProps) { + return ( + + Connection Test Results: + {steps.map((step) => ( + + ))} + + ); +} + +export type { ConnectionStep }; + diff --git a/graphcap_studio/src/features/inference/providers/FormFields.module.css b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/FormFields.module.css similarity index 100% rename from graphcap_studio/src/features/inference/providers/FormFields.module.css rename to graphcap_studio/src/features/inference/providers/ProviderConnection/components/FormFields.module.css diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/LoadingMessage.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/LoadingMessage.tsx new file mode 100644 index 00000000..15c3f5b0 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/LoadingMessage.tsx @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Box, Spinner, Text } from "@chakra-ui/react"; + +interface LoadingMessageProps { + readonly isSubmitting: boolean; + readonly saveSuccess: boolean; +} + +export function LoadingMessage({ isSubmitting, saveSuccess }: LoadingMessageProps) { + if (!isSubmitting && !saveSuccess) return null; + + return ( + + {isSubmitting && ( + + + Saving changes... + + )} + + {!isSubmitting && saveSuccess && ( + + Provider saved successfully! + + )} + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderActions.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderActions.tsx new file mode 100644 index 00000000..d44f8482 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderActions.tsx @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Flex, HStack } from "@chakra-ui/react"; +import { useProviderFormContext } from "../../context/ProviderFormContext"; +import { CancelButton, EditButton, SaveButton, TestConnectionButton } from "./actions"; + +/** + * Component for rendering provider form actions based on current mode + */ +export function ProviderActions() { + const { mode } = useProviderFormContext(); + const isEditing = mode === "edit"; + const isCreating = mode === "create"; + + if (isEditing || isCreating) { + return ( + + + + + + + + ); + } + + return ( + + + + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderConnectionErrorDialog.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderConnectionErrorDialog.tsx new file mode 100644 index 00000000..486b770e --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderConnectionErrorDialog.tsx @@ -0,0 +1,137 @@ +import type { ErrorDetails as ContextErrorDetails } from "@/types/provider-config-types"; +// SPDX-License-Identifier: Apache-2.0 +import { + Box, + Button, + Code, + Dialog, + Grid, + GridItem, + Icon, + Portal, + Text, +} from "@chakra-ui/react"; +import { useEffect, useRef } from "react"; +import { LuTriangleAlert } from "react-icons/lu"; + +type ErrorDetails = { + message?: string; + name?: string; + details?: string | Record; + code?: string; + suggestions?: string[]; + requestDetails?: { + provider: string; + config: Record; + }; +} | string | null; + +type ProviderConnectionErrorDialogProps = { + readonly isOpen: boolean; + readonly onClose: () => void; + readonly error: ErrorDetails | ContextErrorDetails | null; + readonly providerName: string; +}; + +export function ProviderConnectionErrorDialog({ + isOpen, + onClose, + error, + providerName, +}: ProviderConnectionErrorDialogProps) { + const dialogContentRef = useRef(null); + + useEffect(() => { + function handleDialogClick(e: MouseEvent) { + e.stopPropagation(); + } + + const dialogElement = dialogContentRef.current; + if (dialogElement) { + dialogElement.addEventListener("click", handleDialogClick); + return () => { + dialogElement.removeEventListener("click", handleDialogClick); + }; + } + }, []); + + const errorObj = typeof error === 'string' ? { message: error } : error; + + return ( + !e.open && onClose()}> + + + + + + + Error: {providerName} + + + + + + + + + ); +} diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderConnectionSuccessDialog.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderConnectionSuccessDialog.tsx new file mode 100644 index 00000000..e3977c51 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderConnectionSuccessDialog.tsx @@ -0,0 +1,213 @@ +import type { ConnectionDetails as ContextConnectionDetails } from "@/types/provider-config-types"; +// SPDX-License-Identifier: Apache-2.0 +import { + Button, + Dialog, + Icon, + Portal, + Separator, + Text, + VStack, +} from "@chakra-ui/react"; +import { useEffect, useRef } from "react"; +import { LuCheck, LuCircleAlert } from "react-icons/lu"; +import { type ConnectionStep, ConnectionSteps } from "./ConnectionSteps"; + +/** + * Dialog component that displays connection test results + */ +interface ConnectionDetails { + result: { + provider: string; + details: { + method?: string; + models_count?: number; + chat_completion_test?: "success"; + test_model?: string; + }; + diagnostics: { + connection_steps: ConnectionStep[]; + warnings: Array<{ + warning_type: string; + message: string; + }>; + }; + } | boolean; +} + +type ProviderConnectionSuccessDialogProps = { + readonly isOpen: boolean; + readonly onClose: () => void; + readonly providerName: string; + readonly connectionDetails: ConnectionDetails | ContextConnectionDetails | null; +}; + +const STEP_LABELS: Record = { + initialize_client: "Initialize Client", + list_models: "List Available Models", + test_chat_completion: "Test Chat Completion", +}; + +export function ProviderConnectionSuccessDialog({ + isOpen, + onClose, + providerName, + connectionDetails, +}: ProviderConnectionSuccessDialogProps) { + + // Create a reference to the dialog content + const dialogContentRef = useRef(null); + + // Prevent clicks inside the dialog from triggering outside click handlers + useEffect(() => { + function handleDialogClick(e: MouseEvent) { + // Stop event propagation for all clicks inside the dialog + e.stopPropagation(); + } + + const dialogElement = dialogContentRef.current; + if (dialogElement) { + dialogElement.addEventListener("click", handleDialogClick); + + return () => { + dialogElement.removeEventListener("click", handleDialogClick); + }; + } + }, []); + + // Return early if connectionDetails is null + if (!connectionDetails) { + return null; + } + + const { result } = connectionDetails; + + if (typeof result === 'boolean') { + return null; + } + + const steps = result.diagnostics.connection_steps; + const warnings = result.diagnostics.warnings; + const details = result.details; + + // Check if any required steps were skipped or failed + const hasSkippedSteps = steps.some((step: ConnectionStep) => step.status === "skipped"); + const hasFailedSteps = steps.some((step: ConnectionStep) => step.status === "failed"); + const allStepsSuccessful = steps.every((step: ConnectionStep) => step.status === "success"); + + // Determine the overall status + const getStatusInfo = () => { + if (hasFailedSteps) { + return { + title: "Connection Failed", + icon: LuCircleAlert, + color: "red.500", + message: "Connection test failed. Please check the details below.", + }; + } + if (hasSkippedSteps) { + return { + title: "Connection Partial", + icon: LuCircleAlert, + color: "yellow.500", + message: + "Connected with limited functionality. Some tests were skipped.", + }; + } + return { + title: "Connection Successful", + icon: LuCheck, + color: "green.500", + message: `Successfully connected to ${providerName}!`, + }; + }; + + const status = getStatusInfo(); + + const getButtonColorScheme = () => { + if (allStepsSuccessful) return "green"; + if (hasFailedSteps) return "red"; + return "yellow"; + }; + + const buttonColorScheme = getButtonColorScheme(); + + return ( + !e.open && onClose()}> + + + + + + {status.title} + + + + + + + + ); +} diff --git a/graphcap_studio/src/features/inference/providers/FormFields.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderFormTabs.tsx similarity index 79% rename from graphcap_studio/src/features/inference/providers/FormFields.tsx rename to graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderFormTabs.tsx index 9603cea0..c6d154e5 100644 --- a/graphcap_studio/src/features/inference/providers/FormFields.tsx +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderFormTabs.tsx @@ -1,13 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 import { Tabs } from "@chakra-ui/react"; import styles from "./FormFields.module.css"; -import { ModelSelectionSection } from "./ModelSelectionSection"; -import { BasicInfoSection, ConnectionSection, RateLimitsSection } from "./form"; +import { BasicInfoSection, ConnectionSection } from "./form"; +import { ModelSelectionSection } from "./form/ModelSelectionSection"; /** * Component for rendering provider form fields in either view or edit mode */ -export function FormFields() { +export function ProviderFormTabs() { return ( Basic Info Connection - Rate Limits Model @@ -44,9 +43,6 @@ export function FormFields() { - - - ); diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderFormView.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderFormView.tsx new file mode 100644 index 00000000..85f49e66 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/ProviderFormView.tsx @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Box, Flex, Heading } from "@chakra-ui/react"; +import { useProviderFormContext } from "../../context/ProviderFormContext"; +import { LoadingMessage } from "./LoadingMessage"; +import { ProviderActions } from "./ProviderActions"; +import { ProviderConnectionErrorDialog } from "./ProviderConnectionErrorDialog"; +import { ProviderConnectionSuccessDialog } from "./ProviderConnectionSuccessDialog"; +import { ProviderFormTabs } from "./ProviderFormTabs"; +import { AddProviderButton } from "./actions/AddProviderButton"; +import { RemoveProviderButton } from "./actions/RemoveProviderButton"; +import { ProviderFormSelect } from "./form/ProviderFormSelect"; + +/** + * Presentational component for the provider form + */ +export function ProviderFormView() { + const { + handleSubmit, + isSubmitting, + dialog, + closeDialog, + error, + connectionDetails, + provider, + } = useProviderFormContext(); + + return ( +
+ + {/* Provider Selection Section */} + + Provider Configuration + + + + + + + + + + + + {/* Provider Form Tabs */} + + + + + + + {/* Form Error Dialog */} + closeDialog()} + error={error} + providerName={provider?.name ?? "Provider"} + /> + + {/* Connection Error Dialog */} + closeDialog()} + error={error} + providerName={provider?.name ?? "Provider"} + /> + + {/* Success Dialog */} + closeDialog()} + providerName={provider?.name ?? "Provider"} + connectionDetails={connectionDetails} + /> + +
+ ); +} diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/AddProviderButton.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/AddProviderButton.tsx new file mode 100644 index 00000000..8f144935 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/AddProviderButton.tsx @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Button } from "@chakra-ui/react"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; + +/** + * Button to add a new provider + */ +export function AddProviderButton() { + const { setMode, setProvider } = useProviderFormContext(); + + const handleAddProvider = () => { + // Clear the form and current provider when entering create mode + setProvider(null); + setMode("create"); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/CancelButton.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/CancelButton.tsx new file mode 100644 index 00000000..0c996b5c --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/CancelButton.tsx @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useColorMode } from "@/components/ui/theme/color-mode"; +import { Button } from "@chakra-ui/react"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; + +/** + * Button component for canceling provider form changes + */ +export function CancelButton() { + const { cancelEdit } = useProviderFormContext(); + const { colorMode } = useColorMode(); + const isDark = colorMode === "dark"; + + // Theme-based colors + const cancelBg = isDark ? "gray.700" : "gray.100"; + const cancelHoverBg = isDark ? "gray.600" : "gray.200"; + const cancelColor = isDark ? "gray.200" : "gray.800"; + + return ( + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/EditButton.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/EditButton.tsx new file mode 100644 index 00000000..c022263c --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/EditButton.tsx @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Button } from "@chakra-ui/react"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; + +/** + * Button component for editing provider + */ +export function EditButton() { + const { setMode } = useProviderFormContext(); + + return ( + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/ProviderModelActions.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/ProviderModelActions.tsx new file mode 100644 index 00000000..dfac049e --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/ProviderModelActions.tsx @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Button, Flex, Heading, Input, Text } from "@chakra-ui/react"; +import { useState } from "react"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; + +/** + * Component for managing provider models, allowing users to add and remove models + */ +export function ProviderModelActions() { + const { provider, setProvider, watch } = useProviderFormContext(); + const [newModelName, setNewModelName] = useState(""); + const currentModels = watch("models") || []; + + // Handler for adding a new model + const handleAddModel = () => { + if (!newModelName.trim() || !provider) return; + + // Create a complete model object with all required properties + const newModel = { + id: crypto.randomUUID(), + name: newModelName.trim(), + isEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + providerId: provider.id || "" + }; + + // Update the provider with the new model + setProvider({ + ...provider, + models: [...(provider.models || []), newModel], + }); + + // Clear the input field + setNewModelName(""); + }; + + // Handler for removing a model + const handleRemoveModel = (modelIndex: number) => { + if (!provider) return; + + setProvider({ + ...provider, + models: provider.models?.filter((_, index) => index !== modelIndex) || [], + }); + }; + + return ( + + + setNewModelName(e.target.value)} + placeholder="Enter model name" + flex="1" + /> + + + + {/* Show current models */} + {currentModels.length > 0 && ( + + Current Models + {currentModels.map((model, index) => ( + + {model.name} + + + ))} + + )} + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/ProviderSaveDialog.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/ProviderSaveDialog.tsx new file mode 100644 index 00000000..b9cfd4d6 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/ProviderSaveDialog.tsx @@ -0,0 +1,248 @@ +import type { + Provider +} from "@/types/provider-config-types"; +import { + Box, + Button, + CloseButton, + Dialog, + Portal, + Spinner, + Text, + VStack, +} from "@chakra-ui/react"; +// SPDX-License-Identifier: Apache-2.0 +import { useState } from "react"; +import { + useCreateProvider, + useUpdateProvider, +} from "../../../../services/providers"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; + + +/** + * Unified component that combines the save button and save dialog functionality + */ +export function SaveButton() { + const { + isSubmitting: isContextSubmitting, + mode, + provider: selectedProvider, + error: contextSaveError, + handleSubmit, + } = useProviderFormContext(); + + // Local state for dialog visibility and save state + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveComplete, setSaveComplete] = useState(false); + const [savingProvider, setSavingProvider] = useState(null); + const [saveError, setSaveError] = useState( + contextSaveError?.message, + ); + + // Get provider service functions + const { isPending: isCreatingProvider } = + useCreateProvider(); + const { isPending: isUpdatingProvider } = + useUpdateProvider(); + + // Determine if form is submitting + const isSubmitting = + isContextSubmitting || isSaving || isCreatingProvider || isUpdatingProvider; + + // Determine the button text based on form state + let buttonText = "Save"; + if (isSubmitting) { + buttonText = "Saving..."; + } else if (mode === "create") { + buttonText = "Create"; + } + + // Function to close the dialog + const closeDialog = () => { + setIsDialogOpen(false); + setSaveComplete(false); + setSavingProvider(null); + setSaveError(undefined); + }; + + // Handle form submission errors + + // Save the provider using the appropriate service function + + // Custom submit handler that shows the dialog and processes the form + const handleFormSubmit = async (e: React.FormEvent) => { + try { + setIsSaving(true); + setIsDialogOpen(true); + setSaveError(undefined); + + // Try to save the provider using the context handleSubmit + await handleSubmit(e as React.BaseSyntheticEvent); + + // If we get here without errors, it means the form was submitted successfully + setSaveComplete(true); + + } catch (error) { + console.error("Form submission error:", error); + if (error instanceof Error) { + setSaveError(error.message); + } else { + setSaveError("Form validation failed"); + } + } finally { + setIsSaving(false); + } + }; + + // Get the current provider to display + const displayProvider = savingProvider || selectedProvider; + + // Determine dialog title + let dialogTitle = "Processing..."; + if (saveError) { + dialogTitle = "Error Saving Provider"; + } else if (isSaving) { + dialogTitle = "Saving Provider..."; + } else if (saveComplete) { + dialogTitle = "Provider Saved"; + } + + // Render dialog body content based on state + const renderDialogBody = () => { + if (saveError) { + return ( + + {saveError || "An unknown error occurred"} + + ); + } + + if (isSaving) { + return ( + + + Saving provider configuration to server... + + Please wait while we process your request + + + ); + } + + if (saveComplete && displayProvider) { + return ( + + + + + Name: {displayProvider.name} + + + Kind: {displayProvider.kind} + + + Environment: {displayProvider.environment} + + + Base URL: {displayProvider.baseUrl} + + + Default Model:{" "} + {displayProvider.defaultModel ?? "Not set"} + + + + + ); + } + + return ( + + Initializing save process... + + ); + }; + + return ( + <> + + + {/* Provider Save Dialog */} + !isSaving && setIsDialogOpen(e.open)} + > + + + + + + {dialogTitle} + + + + + + {renderDialogBody()} + + + + + + + + + + ); +} diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/RemoveProviderButton.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/RemoveProviderButton.tsx new file mode 100644 index 00000000..145bfbae --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/RemoveProviderButton.tsx @@ -0,0 +1,109 @@ +import { denormalizeProviderId } from "@/types/provider-config-types"; +// SPDX-License-Identifier: Apache-2.0 +import { + Button, + CloseButton, + Dialog, + Portal, + Text, + useDisclosure +} from "@chakra-ui/react"; +import { useRef } from "react"; +import { useDeleteProvider } from "../../../../services/providers"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; + +/** + * Button to remove a provider with confirmation dialog + */ +export function RemoveProviderButton() { + const { provider, setProvider } = useProviderFormContext(); + const { open, onOpen, onClose } = useDisclosure(); + const cancelRef = useRef(null); + const deleteProvider = useDeleteProvider(); + + // Only show the button if we don't have a provider or the provider doesn't have an ID + if (!provider?.id) { + return null; + } + + const handleRemoveProvider = async () => { + try { + // Ensure we have a provider ID + const providerId = typeof provider.id === 'string' + ? denormalizeProviderId(provider.id) + : provider.id; + + await deleteProvider.mutateAsync(providerId); + + setProvider(null); + + onClose(); + + console.log(`Provider "${provider.name}" successfully removed`); + } catch (error) { + console.error("Failed to delete provider:", error); + } + }; + + return ( + <> + + + !deleteProvider.isPending && onClose()} + > + + + + + + Remove Provider + + + + + + + + Are you sure you want to remove the provider "{provider.name}"? + This action cannot be undone. + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/TestConnectionButton.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/TestConnectionButton.tsx new file mode 100644 index 00000000..698c8b48 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/TestConnectionButton.tsx @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Button } from "@chakra-ui/react"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; + +/** + * Button component for testing provider connection + */ +export function TestConnectionButton() { + const { isSubmitting, testConnection, provider } = useProviderFormContext(); + + return ( + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/index.ts b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/index.ts new file mode 100644 index 00000000..dc3c6c82 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/actions/index.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +export * from './AddProviderButton'; +export * from './CancelButton'; +export * from './EditButton'; +export * from './ProviderSaveDialog'; +export * from './TestConnectionButton'; \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/form/BasicInfoSection.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/BasicInfoSection.tsx similarity index 61% rename from graphcap_studio/src/features/inference/providers/form/BasicInfoSection.tsx rename to graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/BasicInfoSection.tsx index 7af6526d..b15da553 100644 --- a/graphcap_studio/src/features/inference/providers/form/BasicInfoSection.tsx +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/BasicInfoSection.tsx @@ -1,5 +1,12 @@ +import { + SelectContent, + SelectItem, + SelectRoot, + SelectTrigger, + SelectValueText, +} from "@/components/ui/select"; import { useColorModeValue } from "@/components/ui/theme/color-mode"; -import { useInferenceProviderContext } from "@/features/inference/providers/context"; +// SPDX-License-Identifier: Apache-2.0 import { Box, Field, @@ -8,16 +15,19 @@ import { Input, Text, VStack, + createListCollection, } from "@chakra-ui/react"; -// SPDX-License-Identifier: Apache-2.0 import { Controller } from "react-hook-form"; +import { PROVIDER_KINDS } from "../../../../constants"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; import { EnvironmentSelect } from "./EnvironmentSelect"; /** * Component for displaying and editing basic provider information */ export function BasicInfoSection() { - const { control, errors, watch, isEditing } = useInferenceProviderContext(); + const { control, errors, watch, mode } = useProviderFormContext(); + const isEditing = mode === "edit" || mode === "create"; const labelColor = useColorModeValue("gray.600", "gray.300"); const textColor = useColorModeValue("gray.700", "gray.200"); @@ -26,6 +36,16 @@ export function BasicInfoSection() { const kind = watch("kind"); const environment = watch("environment"); + // Create collection for provider kinds + const kindItems = PROVIDER_KINDS.map((kind) => ({ + label: kind.charAt(0) + kind.slice(1), + value: kind, + })); + + const kindCollection = createListCollection({ + items: kindItems, + }); + if (!isEditing) { return ( @@ -76,7 +96,29 @@ export function BasicInfoSection() { render={({ field }) => ( Kind - + { + if (details.value && details.value.length > 0) { + field.onChange(details.value[0]); + } else { + field.onChange(""); + } + }} + collection={kindCollection} + > + + + + + {kindItems.map((item) => ( + + {item.label} + + ))} + + {errors.kind?.message} )} diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ConnectionSection.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ConnectionSection.tsx new file mode 100644 index 00000000..710b05c9 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ConnectionSection.tsx @@ -0,0 +1,152 @@ +import { Switch } from "@/components/ui/buttons/Switch"; +import { useColorModeValue } from "@/components/ui/theme/color-mode"; +import { + Box, + Button, + Field, + Group, + Input, + InputElement, + Text, + VStack, +} from "@chakra-ui/react"; +// SPDX-License-Identifier: Apache-2.0 +import { useState } from "react"; +import { Controller } from "react-hook-form"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; + +/** + * Component for displaying and editing provider connection settings + */ +export function ConnectionSection() { + const { control, errors, watch, mode, provider } = + useProviderFormContext(); + const isEditing = mode === "edit" || mode === "create"; + const [showApiKey, setShowApiKey] = useState(false); + const labelColor = useColorModeValue("gray.600", "gray.300"); + const textColor = useColorModeValue("gray.700", "gray.200"); + + // Watch form values for read-only display + const baseUrl = watch("baseUrl"); + const isEnabled = watch("isEnabled"); + + // Toggle API key visibility + const toggleShowApiKey = () => setShowApiKey(!showApiKey); + + // Get API key display value + const getApiKeyDisplayValue = () => { + if (showApiKey) { + return provider?.apiKey; + } + return provider?.apiKey ? "••••••••••••••••" : "Not set"; + }; + + if (!isEditing) { + return ( + + + + Base URL + + {baseUrl} + + + + + API Key + + + + + + + + + + + + Status + + {isEnabled ? "Enabled" : "Disabled"} + + + ); + } + + return ( + + ( + + Base URL + + {errors.baseUrl?.message} + + )} + /> + + { + // Ensure we always have a defined string value + const value = field.value ?? ""; + return ( + + API Key + + field.onChange(e.target.value)} + /> + + + + + + {errors.apiKey?.message || + (value === "" && "API key is required")} + + + ); + }} + /> + + ( + + + + Enabled + + + + )} + /> + + ); +} diff --git a/graphcap_studio/src/features/inference/providers/form/EnvironmentSelect.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/EnvironmentSelect.tsx similarity index 70% rename from graphcap_studio/src/features/inference/providers/form/EnvironmentSelect.tsx rename to graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/EnvironmentSelect.tsx index 89928fd8..9c5b8355 100644 --- a/graphcap_studio/src/features/inference/providers/form/EnvironmentSelect.tsx +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/EnvironmentSelect.tsx @@ -3,16 +3,17 @@ import { SelectItem, SelectRoot, SelectTrigger, + SelectValueText, } from "@/components/ui/select"; import { useColorModeValue } from "@/components/ui/theme/color-mode"; import { Field, createListCollection } from "@chakra-ui/react"; // SPDX-License-Identifier: Apache-2.0 import { Controller } from "react-hook-form"; -import { PROVIDER_ENVIRONMENTS } from "../../constants"; -import { useInferenceProviderContext } from "../context"; +import { PROVIDER_ENVIRONMENTS } from "../../../../constants"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; export function EnvironmentSelect() { - const { control, errors } = useInferenceProviderContext(); + const { control, errors } = useProviderFormContext(); const labelColor = useColorModeValue("gray.600", "gray.300"); const environmentItems = PROVIDER_ENVIRONMENTS.map((env) => ({ @@ -34,10 +35,18 @@ export function EnvironmentSelect() { field.onChange(value)} + onValueChange={(details) => { + if (details.value && details.value.length > 0) { + field.onChange(details.value[0]); + } else { + field.onChange(""); + } + }} collection={collection} > - {field.value || "Select environment"} + + + {environmentItems.map((item) => ( diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ModelSelectionSection.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ModelSelectionSection.tsx new file mode 100644 index 00000000..bfc0e5b4 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ModelSelectionSection.tsx @@ -0,0 +1,138 @@ +import { ActionButton } from "@/components/ui/buttons/ActionButton"; +import { StatusMessage } from "@/components/ui/status/StatusMessage"; +// SPDX-License-Identifier: Apache-2.0 +import { Box, Heading, VStack } from "@chakra-ui/react"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; +import { ProviderModelActions } from "../actions/ProviderModelActions"; +import { ModelSelector } from "./ModelSelector"; + +// Define the model type +export interface ProviderModel { + id: string; + name: string; + is_default?: boolean; +} + +/** + * Component for selecting a model from a provider + */ +export function ModelSelectionSection() { + const { + provider, + providerModels, + selectedModelId, + setSelectedModelId, + isSubmitting, + mode, + watch, + } = useProviderFormContext(); + + const providerName = provider?.name; + const isEditMode = mode === "edit" || mode === "create"; + const customModels = watch("models") || []; + + // Prepare an array with all models to display + const allModels = []; + + // Always add custom/user-defined models + if (customModels && customModels.length > 0) { + // Map custom models to the format expected by the model selector + for (const model of customModels) { + // Generate a stable ID for custom models + allModels.push({ + // Generate a stable ID for custom models + id: `${model.name}`, + name: model.name, + is_default: provider?.defaultModel === model.name, + isCustom: true + }); + } + } + + // Add API-fetched models + if (providerModels && providerModels.length > 0) { + // Map API models to the format expected by the model selector + for (const model of providerModels) { + // Only add if not already included in custom models + if (!customModels.some(m => m.name === model.name)) { + allModels.push({ + id: model.id, + name: model.name, + is_default: model.is_default, + isApiModel: true + }); + } + } + } + + // Handle different states + if (!providerName) { + return ( + + ); + } + + // When in edit mode, show model management section + if (isEditMode) { + return ( + + + Model Configuration + + + + {allModels.length > 0 && ( + + Default Model Selection + ({ + label: `${model.name}${model.is_default ? " (Default)" : ""}${model.isCustom ? " (Custom)" : ""}${model.isApiModel ? " (API)" : ""}`, + value: model.id, + id: model.id, + }))} + selectedModelId={selectedModelId} + setSelectedModelId={setSelectedModelId} + /> + + )} + + ); + } + + // View mode + if (allModels.length === 0) { + return ( + + ); + } + + // Convert all models to the format expected by SelectRoot + const modelItems = allModels.map(model => ({ + label: `${model.name}${model.is_default ? " (Default)" : ""}${model.isCustom ? " (Custom)" : ""}${model.isApiModel ? " (API)" : ""}`, + value: model.id, + id: model.id, + })); + + return ( + + + + console.log("Selected model:", selectedModelId)} + disabled={!selectedModelId} + isLoading={isSubmitting} + /> + + ); +} diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ModelSelector.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ModelSelector.tsx new file mode 100644 index 00000000..49529913 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ModelSelector.tsx @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + ModelSelector as GenericModelSelector, + type ModelOption, +} from "@/components/common_inference/ModelSelector"; +import { useColorMode } from "@/components/ui/theme/color-mode"; +import { Box, Heading, Text } from "@chakra-ui/react"; + +export type ModelItem = ModelOption; + +export interface ModelSelectorProps { + readonly modelItems: ModelItem[]; + readonly selectedModelId: string | null; + readonly setSelectedModelId: (id: string | null) => void; +} + +/** + * Component for selecting a model from a list + */ +export function ModelSelector({ + modelItems, + selectedModelId, + setSelectedModelId, +}: ModelSelectorProps) { + const { colorMode } = useColorMode(); + const isDark = colorMode === "dark"; + + const cardBg = isDark ? "gray.800" : "white"; + const borderColor = isDark ? "gray.700" : "gray.200"; + const headingColor = isDark ? "gray.100" : "gray.700"; + const labelColor = isDark ? "gray.300" : "gray.600"; + + const handleModelChange = (value: string) => { + setSelectedModelId(value || null); + }; + + return ( + + + Model + + + Select a model to use with this provider + + + + ); +} diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ProviderFormSelect.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ProviderFormSelect.tsx new file mode 100644 index 00000000..00136cc7 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/ProviderFormSelect.tsx @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +import { type ProviderOption, ProviderSelector } from "@/components/common_inference/ProviderSelector"; +import type { Provider } from "@/types/provider-config-types"; +import { useProviders } from "../../../../services/providers"; +import { useProviderFormContext } from "../../../context/ProviderFormContext"; + +type ProviderFormSelectProps = { + readonly className?: string; + readonly "aria-label"?: string; +}; + +/** + * Component for selecting a provider from a dropdown within the provider form + * Uses the form context to manage provider selection + */ +export function ProviderFormSelect({ + className, + "aria-label": ariaLabel = "Select Provider", +}: ProviderFormSelectProps) { + // Get provider data from context + const { provider, setProvider } = useProviderFormContext(); + + // Fetch providers directly + const { data: providers = [] } = useProviders(); + + // Convert providers to the format expected by ProviderSelector + const providerOptions: ProviderOption[] = providers.map((p: Provider) => ({ + label: p.name, + value: p.name, + id: String(p.id), + })); + + const handleProviderChange = (value: string) => { + if (!value) return; + + // Find the selected provider from the providers list by name + const selectedProvider = providers.find((p: Provider) => p.name === value); + if (selectedProvider) { + setProvider(selectedProvider); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/index.ts b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/index.ts new file mode 100644 index 00000000..0eec7288 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/components/form/index.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Form component exports + */ + +export * from "./BasicInfoSection"; +export * from "./ConnectionSection"; +export * from "./EnvironmentSelect"; +export * from "./ModelSelectionSection"; +export * from "./ModelSelector"; +export * from "./ProviderFormSelect"; + diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/containers/ProviderFormContainer.tsx b/graphcap_studio/src/features/inference/providers/ProviderConnection/containers/ProviderFormContainer.tsx new file mode 100644 index 00000000..b81f30c3 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/containers/ProviderFormContainer.tsx @@ -0,0 +1,282 @@ +import { DEFAULT_PROVIDER_FORM_DATA } from "@/features/inference/constants"; +import type { ConnectionDetails, ErrorDetails, Provider, ProviderCreate, ProviderUpdate } from "@/types/provider-config-types"; +import { denormalizeProviderId, toServerConfig } from "@/types/provider-config-types"; +import { useQueryClient } from '@tanstack/react-query'; +// SPDX-License-Identifier: Apache-2.0 +import type { ReactNode } from "react"; +import { useCallback, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useCreateProvider, useProviders, useTestProviderConnection, useUpdateProvider } from "../../../services/providers"; +import { useInferenceProviderContext } from "../../context/InferenceProviderContext"; +import { ProviderFormProvider } from "../../context/ProviderFormContext"; +// Simplified dialog state type +type DialogType = null | "error" | "success" | "formError" | "save"; + +interface ProviderFormContainerProps { + readonly children: ReactNode; + readonly initialData?: Partial; +} + +export function ProviderFormContainer({ + children, + initialData, +}: ProviderFormContainerProps) { + // Get required context from parent + const { + mode: contextMode, + setMode: setContextMode, + selectedProvider: contextSelectedProvider, + selectedModelId, + setSelectedModelId, + onCancel: onContextCancel, + } = useInferenceProviderContext(); + + // State for the provider form + const [mode, setMode] = useState(contextMode); + const [provider, setProvider] = useState(contextSelectedProvider); + const [isSubmitting, setIsSubmitting] = useState(false); + const [dialog, setDialog] = useState(null); + const [error, setError] = useState(null); + const [connectionDetails, setConnectionDetails] = useState(null); + + // Get query client for cache invalidation + const queryClient = useQueryClient(); + + // Form setup + const { + control, + handleSubmit: formHandleSubmit, + formState: { errors }, + watch, + reset + } = useForm({ + defaultValues: initialData || provider || {}, + }); + + // API hooks + useProviders(); + const testConnection = useTestProviderConnection(); + const createProvider = useCreateProvider(); + const updateProvider = useUpdateProvider(); + + // Handle provider selection + const handleProviderSelect = useCallback((newProvider: Provider | null) => { + setProvider(newProvider); + + if (newProvider) { + // Reset form with the selected provider's data instead of default data + reset({ + name: newProvider.name, + kind: newProvider.kind, + environment: newProvider.environment, + baseUrl: newProvider.baseUrl, + apiKey: newProvider.apiKey ?? "", + isEnabled: newProvider.isEnabled, + defaultModel: newProvider.defaultModel, + models: newProvider.models + }); + } else { + // Reset to default data if no provider is selected + reset(DEFAULT_PROVIDER_FORM_DATA); + } + }, [reset]); + + // Dialog handlers + const openDialog = useCallback((type: DialogType, newError?: ErrorDetails) => { + setDialog(type); + if (newError) setError(newError); + }, []); + + const closeDialog = useCallback(() => { + setDialog(null); + }, []); + + // Form submission + const handleSubmit = async (e?: React.BaseSyntheticEvent) => { + // If an event was provided, prevent default + if (e) { + e.preventDefault(); + } + + try { + setIsSubmitting(true); + setError(null); + + // Use formHandleSubmit to get data from the form + const formData = await new Promise((resolve) => { + formHandleSubmit((data) => { + resolve(data); + })(e); + }); + + let savedProvider: Provider | null = null; + + if (mode === "edit" && provider?.id) { + savedProvider = await updateProvider.mutateAsync({ + id: denormalizeProviderId(provider.id), + data: formData as ProviderUpdate + }); + } else if (mode === "create") { + // Create the provider + savedProvider = await createProvider.mutateAsync(formData as ProviderCreate); + } + + if (savedProvider) { + setProvider(savedProvider); + } + + await queryClient.invalidateQueries({ queryKey: ['providers'] }); + + openDialog("success"); + setMode("view"); + setContextMode("view"); + + } catch (err) { + console.error("Provider form submission error:", err); + const errorDetails: ErrorDetails = err instanceof Error + ? { message: err.message, code: err.name, details: { error: err.toString() } } + : { message: String(err), details: { error } }; + + setError(errorDetails); + openDialog("formError"); + + // Re-throw the error so the caller knows something went wrong + throw err; + } finally { + setIsSubmitting(false); + } + }; + + // Connection test + const testProviderConnection = async () => { + if (!provider) return; + + if (!provider.apiKey) { + setError({ + message: "API key is required", + code: "ValidationError", + details: { message: "API key is required" } + }); + openDialog("error"); + return; + } + + try { + setIsSubmitting(true); + setError(null); + + const config = toServerConfig(provider); + const result = await testConnection.mutateAsync({ + providerName: provider.name, + config, + }); + + setConnectionDetails(result); + openDialog("success"); + } catch (err) { + console.error("Connection test failed:", err); + + const errorDetails: ErrorDetails = err instanceof Error + ? { message: err.message, code: err.name, details: { error: err.toString() } } + : { message: String(err), details: { error } }; + + setError(errorDetails); + openDialog("error"); + } finally { + setIsSubmitting(false); + } + }; + + // Update mode in both local and context state + const handleSetMode = useCallback((newMode: "view" | "edit" | "create") => { + setMode(newMode); + setContextMode(newMode); + + // When switching to create mode, reset the form to empty values + if (newMode === "create") { + reset({ + name: "", + kind: "openai", // Default to the first provider kind + environment: "cloud", + baseUrl: "", + apiKey: "", + isEnabled: true, + defaultModel: "", + models: [] + }); + } + }, [setContextMode, reset]); + + // Handle model selection with proper type handling + const handleSetSelectedModelId = useCallback((id: string | null) => { + if (id !== null) { + setSelectedModelId(id); + } + }, [setSelectedModelId]); + + // Enhanced cancel handler to properly reset the form + const handleCancel = useCallback(() => { + // Reset the form data to the original provider values + if (provider) { + reset({ + name: provider.name, + kind: provider.kind, + environment: provider.environment, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey ?? "", + isEnabled: provider.isEnabled, + defaultModel: provider.defaultModel, + models: provider.models + }); + } + + // Switch mode back to view + setMode("view"); + setContextMode("view"); + + // Call the parent context cancel if provided + onContextCancel(); + }, [provider, reset, setContextMode, onContextCancel]); + + // Provide context value to ProviderFormContext + const providerFormValue = { + // Core state + provider, + mode, + + // Form state + control, + errors, + watch, + + // UI state + isSubmitting, + dialog, + error, + connectionDetails, + + // Selected model state + selectedModelId, + providerModels: provider?.models || null, + + // Actions + setProvider: handleProviderSelect, + setMode: handleSetMode, + setSelectedModelId: handleSetSelectedModelId, + openDialog, + closeDialog, + + // Form actions + handleSubmit, + cancelEdit: handleCancel, + testConnection: testProviderConnection + }; + + return ( + + {children} + + ); +} diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/context/useProviderForm.ts b/graphcap_studio/src/features/inference/providers/ProviderConnection/context/useProviderForm.ts new file mode 100644 index 00000000..46e14394 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/context/useProviderForm.ts @@ -0,0 +1,193 @@ +import { type Provider, type ProviderCreate, type ProviderUpdate, toServerConfig } from "@/types/provider-config-types"; +// SPDX-License-Identifier: Apache-2.0 +import { useState } from "react"; +import { type Control, type FieldErrors, type UseFormHandleSubmit, type UseFormReset, type UseFormWatch, useForm } from "react-hook-form"; +import { useTestProviderConnection } from "../../../services/providers"; +import { useInferenceProviderContext } from "../../context/InferenceProviderContext"; + +interface UseProviderFormResult { + mode: 'view' | 'edit' | 'create'; + isSubmitting: boolean; + saveSuccess: boolean; + isTestingConnection: boolean; + selectedProvider?: Provider | null; + formError: unknown; + connectionError: Record | string | null; + connectionDetails: Record | null; + control: Control; + handleSubmit: UseFormHandleSubmit; + errors: FieldErrors; + watch: UseFormWatch; + reset: UseFormReset; + dialogs: { + error: boolean; + success: boolean; + formError: boolean; + save: boolean; + }; + onSubmit: (data: ProviderCreate | ProviderUpdate) => Promise; + handleTestConnection: () => Promise; + setMode: (mode: 'view' | 'edit' | 'create') => void; + closeDialog: (dialog: 'error' | 'success' | 'formError' | 'save') => void; +} + +/** + * Custom hook that manages provider form state and logic + */ +export function useProviderForm(initialData: Partial | null = null): UseProviderFormResult { + const { + mode, + setMode, + selectedProvider, + } = useInferenceProviderContext(); + + // Create a form using react-hook-form + const { + control, + handleSubmit: hookHandleSubmit, + formState: { errors }, + watch, + reset + } = useForm({ + defaultValues: initialData || {}, + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [connectionError, setConnectionError] = useState | string | null>(null); + const [connectionDetails, setConnectionDetails] = useState | null>(null); + const [formError, setFormError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); + const [dialogs, setDialogs] = useState({ + error: false, + success: false, + formError: false, + save: false + }); + + const testConnection = useTestProviderConnection(); + + const closeDialog = (dialog: keyof typeof dialogs) => { + setDialogs(prev => ({ ...prev, [dialog]: false })); + }; + + const onSubmit = async (handler: (data: ProviderCreate | ProviderUpdate) => Promise) => { + return hookHandleSubmit(async (data) => { + try { + setIsSubmitting(true); + setFormError(null); + setSaveSuccess(false); + + await handler(data); + + setSaveSuccess(true); + setDialogs(prev => ({ ...prev, save: true })); + + // Reset success message after 3 seconds + setTimeout(() => { + setSaveSuccess(false); + }, 3000); + } catch (error) { + console.error("Provider form submission error:", error); + setFormError(error); + setDialogs(prev => ({ ...prev, formError: true })); + } finally { + setIsSubmitting(false); + } + }); + }; + + const handleTestConnection = async () => { + if (!selectedProvider) return; + + // Validate API key is present + if (!selectedProvider.apiKey) { + setConnectionError({ + title: "Connection failed", + timestamp: new Date().toISOString(), + message: "API key is required", + name: "ValidationError", + details: "Please provide an API key in the provider configuration.", + suggestions: [ + "Edit the provider to add an API key", + "API keys should be non-empty strings", + ], + }); + setDialogs(prev => ({ ...prev, error: true })); + return; + } + + setIsTestingConnection(true); + setConnectionError(null); + + try { + const config = toServerConfig(selectedProvider); + const result = await testConnection.mutateAsync({ + providerName: selectedProvider.name, + config, + }); + + setConnectionDetails(result); + setDialogs(prev => ({ ...prev, success: true })); + } catch (error) { + console.error("Connection test failed:", error); + + let errorObj: Record = { + title: "Connection failed", + timestamp: new Date().toISOString(), + }; + + if (error instanceof Error) { + errorObj.message = error.message; + errorObj.name = error.name; + + if (error.message?.includes("[object Object]")) { + errorObj.message = "Invalid provider configuration"; + errorObj.details = "The server rejected the request due to invalid parameters."; + errorObj.suggestions = [ + "Check API key and endpoint URL", + "Verify the provider is correctly configured", + "Check server logs for more details", + ]; + } + + if ('cause' in error && typeof error.cause === 'object') { + errorObj.errorDetails = error.cause; + } + } else if (typeof error === "object" && error !== null) { + errorObj = { + ...errorObj, + ...(error as Record), + }; + } else { + errorObj.message = String(error); + } + + setConnectionError(errorObj); + setDialogs(prev => ({ ...prev, error: true })); + } finally { + setIsTestingConnection(false); + } + }; + + return { + mode, + isSubmitting, + saveSuccess, + isTestingConnection, + selectedProvider, + formError, + connectionError, + connectionDetails, + control, + handleSubmit: onSubmit, + errors, + watch, + reset, + dialogs, + onSubmit: () => Promise.resolve(), // Placeholder to maintain compatibility + handleTestConnection, + setMode, + closeDialog, + }; +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/hooks/useProviderConnection.ts b/graphcap_studio/src/features/inference/providers/ProviderConnection/hooks/useProviderConnection.ts new file mode 100644 index 00000000..38290f91 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/hooks/useProviderConnection.ts @@ -0,0 +1,142 @@ +import { type Provider, toServerConfig } from "@/types/provider-config-types"; +// SPDX-License-Identifier: Apache-2.0 +import { useState } from "react"; +import { useTestProviderConnection } from "../../../services/providers"; +import { useInferenceProviderContext } from "../../context"; + +interface UseProviderConnectionResult { + isTestingConnection: boolean; + connectionError: Record | string | null; + connectionDetails: Record | null; + dialogs: { + error: boolean; + success: boolean; + }; + handleTestConnection: () => Promise; + closeDialog: (dialog: 'error' | 'success') => void; +} + +/** + * Hook for managing provider connection testing + */ +export function useProviderConnection(selectedProvider: Provider | null): UseProviderConnectionResult { + const { watch } = useInferenceProviderContext(); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [connectionError, setConnectionError] = useState | string | null>(null); + const [connectionDetails, setConnectionDetails] = useState | null>(null); + const [dialogs, setDialogs] = useState({ + error: false, + success: false + }); + + const testConnection = useTestProviderConnection(); + + const closeDialog = (dialog: keyof typeof dialogs) => { + setDialogs(prev => ({ ...prev, [dialog]: false })); + }; + + const handleTestConnection = async () => { + // Get current form values + const currentFormValues = { + ...selectedProvider, // Base values from saved provider + name: watch('name'), + apiKey: watch('apiKey'), + baseUrl: watch('baseUrl'), + kind: watch('kind'), + environment: watch('environment'), + // Add other necessary fields from the form + } as Provider; + + if (!currentFormValues.apiKey) { + setConnectionError({ + title: "Connection failed", + timestamp: new Date().toISOString(), + message: "API key is required", + name: "ValidationError", + details: "Please provide an API key in the provider configuration.", + suggestions: [ + "Enter an API key in the form", + "API keys should be non-empty strings", + ], + requestDetails: { + provider: currentFormValues.name, + config: { + ...toServerConfig(currentFormValues), + api_key: '[MISSING]' + } + } + }); + setDialogs(prev => ({ ...prev, error: true })); + return; + } + + setIsTestingConnection(true); + setConnectionError(null); + + try { + const config = toServerConfig(currentFormValues); + + const result = await testConnection.mutateAsync({ + providerName: currentFormValues.name, + config, + }); + + setConnectionDetails(result); + setDialogs(prev => ({ ...prev, success: true })); + } catch (error) { + console.error("Connection test failed:", error); + + let errorObj: Record = { + title: "Connection failed", + timestamp: new Date().toISOString(), + requestDetails: { + provider: currentFormValues.name, + config: { + ...toServerConfig(currentFormValues), + api_key: '[REDACTED]' + } + } + }; + + if (error instanceof Error) { + errorObj.message = error.message; + errorObj.name = error.name; + + if (error.message?.includes("[object Object]")) { + errorObj.message = "Invalid provider configuration"; + errorObj.details = "The server rejected the request due to invalid parameters."; + errorObj.suggestions = [ + "Check API key and endpoint URL in the form", + "Verify the provider configuration is correct", + "Check server logs for more details", + ]; + } + + if ('cause' in error && typeof error.cause === 'object') { + errorObj.errorDetails = error.cause; + } + } else if (typeof error === "object" && error !== null) { + errorObj = { + ...errorObj, + ...(error as Record), + }; + } else { + errorObj.message = String(error); + } + + setConnectionError(errorObj); + setDialogs(prev => ({ ...prev, error: true })); + } finally { + setIsTestingConnection(false); + } + }; + + return { + isTestingConnection, + connectionError, + connectionDetails, + dialogs, + handleTestConnection, + closeDialog, + }; +} \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderConnection/index.ts b/graphcap_studio/src/features/inference/providers/ProviderConnection/index.ts new file mode 100644 index 00000000..c5901f0d --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/ProviderConnection/index.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: Apache-2.0 +export { ProviderFormView } from './components/ProviderFormView'; +export * from './component'; \ No newline at end of file diff --git a/graphcap_studio/src/features/inference/providers/ProviderForm.tsx b/graphcap_studio/src/features/inference/providers/ProviderForm.tsx deleted file mode 100644 index 5eb00d80..00000000 --- a/graphcap_studio/src/features/inference/providers/ProviderForm.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Box, Button, Flex } from "@chakra-ui/react"; -// SPDX-License-Identifier: Apache-2.0 -import { memo } from "react"; -import { FormFields } from "./FormFields"; -import { useInferenceProviderContext } from "./context"; - -/** - * Component for provider form that displays fields in either view or edit mode - */ -function ProviderForm() { - const { handleSubmit, isSubmitting, onSubmit, onCancel, mode, setMode } = - useInferenceProviderContext(); - - const isEditing = mode === "edit"; - const isCreating = mode === "create"; - - return ( - - - - {/* Actions */} - - {isEditing || isCreating ? ( - <> - - - - ) : ( - - )} - - - ); -} - -export default memo(ProviderForm); diff --git a/graphcap_studio/src/features/inference/providers/ProvidersList.tsx b/graphcap_studio/src/features/inference/providers/ProvidersList.tsx index c38bb2dd..e79434df 100644 --- a/graphcap_studio/src/features/inference/providers/ProvidersList.tsx +++ b/graphcap_studio/src/features/inference/providers/ProvidersList.tsx @@ -1,20 +1,18 @@ -import { useProviderFormContext } from "./context"; +import type { ProviderCreate, ProviderUpdate } from "@/types/provider-config-types"; // SPDX-License-Identifier: Apache-2.0 -import { ProviderSelect } from "./form"; - -type ProvidersListProps = { - readonly onSelectProvider: (id: number) => void; -}; +import { useProviders } from "../services/providers"; +import { ProviderFormSelect } from "./ProviderConnection/components/form/ProviderFormSelect"; +import { ProviderFormContainer } from "./ProviderConnection/containers/ProviderFormContainer"; /** * Component for displaying a list of providers as a dropdown + * This component includes the necessary context providers */ -export default function ProvidersList({ - onSelectProvider, -}: ProvidersListProps) { - const { providers } = useProviderFormContext(); +export default function ProvidersList() { + const { data: providers = [], isLoading } = useProviders(); - if (providers.length === 0) { + // No need to check context.providers as we fetch directly here + if (providers.length === 0 && !isLoading) { return (

No providers available

@@ -22,9 +20,18 @@ export default function ProvidersList({ ); } + // Create a simple handler for form submission - just updates the global context + const handleSubmit = async (data: ProviderCreate | ProviderUpdate) => { + // This is a simple wrapper, so we don't actually need to do anything on submit + console.log("Provider selected in list:", data); + }; + return (
- + {/* Wrap in provider form container to provide the necessary context */} + + +
); } diff --git a/graphcap_studio/src/features/inference/providers/ProvidersPanel.tsx b/graphcap_studio/src/features/inference/providers/ProvidersPanel.tsx index 5711a3ce..3154e996 100644 --- a/graphcap_studio/src/features/inference/providers/ProvidersPanel.tsx +++ b/graphcap_studio/src/features/inference/providers/ProvidersPanel.tsx @@ -1,66 +1,23 @@ import { useColorMode } from "@/components/ui/theme/color-mode"; -import { Box, Button, Center, Flex, Text, VStack } from "@chakra-ui/react"; +import { Box, Center, Flex, Text } from "@chakra-ui/react"; // SPDX-License-Identifier: Apache-2.0 import { useMemo } from "react"; import { useProviders } from "../services/providers"; -import ProviderForm from "./ProviderForm"; +import { ProviderConnection } from "./ProviderConnection"; import { InferenceProviderProvider, - useInferenceProviderContext, } from "./context"; -import { ProviderSelect } from "./form"; /** * Panel content that requires context */ function PanelContent() { - const { setMode, providers } = - useInferenceProviderContext(); - - const { colorMode } = useColorMode(); - const textColor = colorMode === "light" ? "gray.600" : "gray.300"; - const borderColor = colorMode === "light" ? "gray.200" : "gray.700"; - - // No providers state - if (providers.length === 0) { - return ( - - No providers configured - - - ); - } return ( - {/* Header */} - - {/* Provider Selection Dropdown */} - - - - - - {/* Content */} - + ); @@ -69,8 +26,8 @@ function PanelContent() { /** * Providers Panel Component * - * This component displays a list of providers and allows viewing and editing - * provider configurations. + * This component displays provider configurations in a panel. + * It acts as a container for the provider connection form. */ export function ProvidersPanel() { const { @@ -111,9 +68,6 @@ export function ProvidersPanel() { providers={providersData} selectedProvider={initialSelectedProvider} isCreating={false} - onSubmit={() => {}} - onCancel={() => {}} - isSubmitting={false} > diff --git a/graphcap_studio/src/features/inference/providers/context/InferenceProviderContext.tsx b/graphcap_studio/src/features/inference/providers/context/InferenceProviderContext.tsx index 7401a316..bdc1fd02 100644 --- a/graphcap_studio/src/features/inference/providers/context/InferenceProviderContext.tsx +++ b/graphcap_studio/src/features/inference/providers/context/InferenceProviderContext.tsx @@ -1,3 +1,4 @@ +import type { Provider } from "@/types/provider-config-types"; // SPDX-License-Identifier: Apache-2.0 /** * Inference Provider Context @@ -8,9 +9,8 @@ * The context includes: * - View state (mode, selected provider) * - Providers data - * - Form state and validation * - Model selection state - * - Form actions and callbacks + * - Basic view actions */ import { type ReactNode, @@ -21,15 +21,12 @@ import { useMemo, useState, } from "react"; -import { DEFAULT_PROVIDER_FORM_DATA } from "../../constants"; -import { useModelSelection, useProviderForm } from "../../hooks"; -import type { Provider, ProviderCreate, ProviderUpdate } from "../types"; +import { useModelSelection } from "../../hooks"; // Local storage key for selected provider const SELECTED_PROVIDER_STORAGE_KEY = "graphcap-selected-provider"; type ViewMode = "view" | "edit" | "create"; -type FormData = ProviderCreate | ProviderUpdate; /** * Type definition for the Inference Provider Context @@ -47,29 +44,12 @@ type InferenceProviderContextType = { providers: Provider[]; setProviders: (providers: Provider[]) => void; - // Form state - control: any; - handleSubmit: any; - errors: any; - watch: any; - providerName: string | undefined; - reset: any; - // Model selection state selectedModelId: string; setSelectedModelId: (id: string) => void; - providerModelsData: any; - isLoadingModels: boolean; - isModelsError: boolean; - modelsError: any; - - // Form actions handleModelSelect: () => void; - isSubmitting: boolean; - isCreating: boolean; - // Form callbacks - onSubmit: (data: FormData) => Promise; + // Basic actions onCancel: () => void; }; @@ -86,29 +66,12 @@ const defaultContextValue: InferenceProviderContextType = { providers: [], setProviders: () => {}, - // Form state - control: null, - handleSubmit: () => ({}), - errors: {}, - watch: () => undefined, - providerName: undefined, - reset: () => {}, - // Model selection state selectedModelId: "", setSelectedModelId: () => {}, - providerModelsData: null, - isLoadingModels: false, - isModelsError: false, - modelsError: null, - - // Form actions handleModelSelect: () => {}, - isSubmitting: false, - isCreating: false, - // Form callbacks - onSubmit: async () => Promise.resolve(), + // Basic actions onCancel: () => {}, }; @@ -135,9 +98,6 @@ export function useInferenceProviderContext() { return context; } -// For backward compatibility -export const useProviderFormContext = useInferenceProviderContext; - /** * Save provider to localStorage * @param provider - The provider to save @@ -176,11 +136,8 @@ const loadProviderFromStorage = (): Provider | null => { */ type InferenceProviderProviderProps = { readonly children: ReactNode; - readonly initialData?: Partial; - readonly isCreating: boolean; - readonly onSubmit: (data: FormData) => void; - readonly onCancel: () => void; - readonly isSubmitting: boolean; + readonly isCreating?: boolean; + readonly onCancel?: () => void; readonly onModelSelect?: (providerName: string, modelId: string) => void; readonly selectedProvider?: Provider | null; readonly providers?: Provider[]; @@ -193,20 +150,16 @@ type InferenceProviderProviderProps = { * available to all child components through the context. It handles: * * - Provider selection and management - * - Form state and validation * - Model selection - * - Form submission and cancellation + * - Basic view actions * * @param props - The provider props * @returns A context provider component */ export function InferenceProviderProvider({ children, - initialData = {}, - isCreating, - onSubmit: onSubmitProp, - onCancel, - isSubmitting, + isCreating = false, + onCancel = () => {}, onModelSelect, selectedProvider: selectedProviderProp, providers: providersProp = [], @@ -223,79 +176,41 @@ export function InferenceProviderProvider({ // Update selected provider when prop changes useEffect(() => { - if (selectedProviderProp) { + if (selectedProviderProp && JSON.stringify(selectedProviderProp) !== JSON.stringify(selectedProvider)) { setSelectedProvider(selectedProviderProp); } - }, [selectedProviderProp]); + }, [selectedProviderProp, selectedProvider]); // Save selected provider to localStorage when it changes useEffect(() => { - saveProviderToStorage(selectedProvider); + if (selectedProvider) { + saveProviderToStorage(selectedProvider); + } }, [selectedProvider]); - // Update providers when prop changes + // Update providers when prop changes - only if we have providers and they're different useEffect(() => { - setProviders(providersProp); - }, [providersProp]); - - // Use the form hook - const { - control, - handleSubmit, - errors, - providerName, - onSubmit: onSubmitForm, - watch, - reset, - } = useProviderForm(initialData); - - // Reset form data when selected provider changes - useEffect(() => { - if (selectedProvider && mode !== "create") { - reset({ - name: selectedProvider.name, - kind: selectedProvider.kind, - environment: selectedProvider.environment, - baseUrl: selectedProvider.baseUrl, - envVar: selectedProvider.envVar, - isEnabled: selectedProvider.isEnabled, - rateLimits: selectedProvider.rateLimits || { - requestsPerMinute: 0, - tokensPerMinute: 0, - }, - }); - } else if (mode === "create") { - reset(DEFAULT_PROVIDER_FORM_DATA); + const hasProviders = Array.isArray(providersProp) && providersProp.length > 0; + const providersChanged = JSON.stringify(providersProp) !== JSON.stringify(providers); + + if (hasProviders && providersChanged) { + setProviders(providersProp); } - }, [selectedProvider, mode, reset]); + }, [providersProp, providers]); - // Use the model selection hook with null check + // Use the model selection hook with selectedProvider const { selectedModelId, setSelectedModelId, - providerModelsData, - isLoadingModels, - isModelsError, - modelsError, handleModelSelect: handleModelSelectBase, - } = useModelSelection(selectedProvider?.name ?? "", onModelSelect); + } = useModelSelection(selectedProvider, onModelSelect); // Create a memoized version of handleModelSelect const handleModelSelect = useCallback(() => { - handleModelSelectBase(); - }, [handleModelSelectBase]); - - // Create a memoized version of onSubmit that calls both form and prop handlers - const onSubmitHandler = useCallback( - async (data: FormData) => { - const result = await onSubmitForm(data, isCreating, selectedProvider?.id); - if (result.success) { - onSubmitProp(data); - setMode("view"); - } - }, - [onSubmitForm, onSubmitProp, setMode, isCreating, selectedProvider?.id], - ); + if (selectedProvider) { + handleModelSelectBase(); + } + }, [handleModelSelectBase, selectedProvider]); // Create a memoized version of onCancel that resets mode const onCancelHandler = useCallback(() => { @@ -317,51 +232,21 @@ export function InferenceProviderProvider({ providers, setProviders, - // Form state - control, - handleSubmit, - errors, - watch, - providerName, - reset, - // Model selection state selectedModelId, setSelectedModelId, - providerModelsData, - isLoadingModels, - isModelsError, - modelsError, - - // Form actions handleModelSelect, - isSubmitting, - isCreating, - // Form callbacks - onSubmit: onSubmitHandler, + // Basic actions onCancel: onCancelHandler, }), [ mode, selectedProvider, providers, - control, - handleSubmit, - errors, - watch, - providerName, - reset, selectedModelId, setSelectedModelId, - providerModelsData, - isLoadingModels, - isModelsError, - modelsError, handleModelSelect, - isSubmitting, - isCreating, - onSubmitHandler, onCancelHandler, ], ); @@ -372,6 +257,3 @@ export function InferenceProviderProvider({ ); } - -// For backward compatibility -export const ProviderFormProvider = InferenceProviderProvider; diff --git a/graphcap_studio/src/features/inference/providers/context/ProviderFormContext.tsx b/graphcap_studio/src/features/inference/providers/context/ProviderFormContext.tsx new file mode 100644 index 00000000..5cce41e3 --- /dev/null +++ b/graphcap_studio/src/features/inference/providers/context/ProviderFormContext.tsx @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { ConnectionDetails, ErrorDetails, Provider, ProviderCreate, ProviderUpdate } from "@/types/provider-config-types"; +import { type ReactNode, createContext, useContext } from "react"; +import type { Control, FieldErrors, UseFormWatch } from "react-hook-form"; + +// Simplified dialog state type +type DialogType = null | "error" | "success" | "formError" | "save"; + +interface ProviderFormContextType { + // Core state + provider: Provider | null; + mode: "view" | "edit" | "create"; + + // Form state + control: Control; + errors: FieldErrors; + watch: UseFormWatch; + + // UI state + isSubmitting: boolean; + dialog: DialogType; + error: ErrorDetails | null; + connectionDetails: ConnectionDetails | null; + + // Selected model state + selectedModelId: string | null; + providerModels: Array<{ id: string; name: string; is_default?: boolean }> | null; + + // Actions + setProvider: (provider: Provider | null) => void; + setMode: (mode: "view" | "edit" | "create") => void; + setSelectedModelId: (id: string | null) => void; + openDialog: (type: DialogType, error?: ErrorDetails) => void; + closeDialog: () => void; + + // Form actions + handleSubmit: (e?: React.BaseSyntheticEvent) => Promise; + cancelEdit: () => void; + testConnection: () => Promise; +} + +const ProviderFormContext = createContext(undefined); + +export function useProviderFormContext() { + const context = useContext(ProviderFormContext); + if (context === undefined) { + throw new Error("useProviderFormContext must be used within a ProviderFormProvider"); + } + return context; +} + +interface ProviderFormProviderProps { + readonly children: ReactNode; + readonly value: ProviderFormContextType; +} + +export function ProviderFormProvider({ children, value }: ProviderFormProviderProps) { + return ( + + {children} + + ); +} diff --git a/graphcap_studio/src/features/inference/providers/form/ConnectionSection.tsx b/graphcap_studio/src/features/inference/providers/form/ConnectionSection.tsx deleted file mode 100644 index d424a855..00000000 --- a/graphcap_studio/src/features/inference/providers/form/ConnectionSection.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Switch } from "@/components/ui/buttons/Switch"; -import { useColorModeValue } from "@/components/ui/theme/color-mode"; -import { Box, Field, Input, Text, VStack } from "@chakra-ui/react"; -// SPDX-License-Identifier: Apache-2.0 -import { Controller } from "react-hook-form"; -import { useInferenceProviderContext } from "../context"; - -/** - * Component for displaying and editing provider connection settings - */ -export function ConnectionSection() { - const { control, errors, watch, isEditing } = useInferenceProviderContext(); - const labelColor = useColorModeValue("gray.600", "gray.300"); - const textColor = useColorModeValue("gray.700", "gray.200"); - - // Watch form values for read-only display - const baseUrl = watch("baseUrl"); - const envVar = watch("envVar"); - const isEnabled = watch("isEnabled"); - - if (!isEditing) { - return ( - - - - Base URL - - {baseUrl} - - - - - Environment Variable - - {envVar} - - - - - Status - - {isEnabled ? "Enabled" : "Disabled"} - - - ); - } - - return ( - - ( - - Base URL - - {errors.baseUrl?.message} - - )} - /> - - ( - - Environment Variable - - {errors.envVar?.message} - - )} - /> - - ( - - - - Enabled - - - - )} - /> - - ); -} diff --git a/graphcap_studio/src/features/inference/providers/form/ModelSelector.tsx b/graphcap_studio/src/features/inference/providers/form/ModelSelector.tsx deleted file mode 100644 index e535ee60..00000000 --- a/graphcap_studio/src/features/inference/providers/form/ModelSelector.tsx +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import { Field } from "@/components/ui/field"; -import { - SelectContent, - SelectItem, - SelectRoot, - SelectTrigger, - SelectValueText, -} from "@/components/ui/select"; -import { useColorMode } from "@/components/ui/theme/color-mode"; -import { Box, Heading, Text, createListCollection } from "@chakra-ui/react"; - -// Define the model item type for the select component -export interface ModelItem { - label: string; - value: string; -} - -export interface ModelSelectorProps { - modelItems: ModelItem[]; - selectedModelId: string | null; - setSelectedModelId: (id: string) => void; -} - -/** - * Component for selecting a model from a list - */ -export function ModelSelector({ - modelItems, - selectedModelId, - setSelectedModelId, -}: ModelSelectorProps) { - const { colorMode } = useColorMode(); - const isDark = colorMode === "dark"; - - const cardBg = isDark ? "gray.800" : "white"; - const borderColor = isDark ? "gray.700" : "gray.200"; - const headingColor = isDark ? "gray.100" : "gray.700"; - const labelColor = isDark ? "gray.300" : "gray.600"; - - const modelCollection = createListCollection({ - items: modelItems, - }); - - // Convert selectedModelId to string array format - const value = selectedModelId ? [selectedModelId] : []; - - return ( - - - Model - - - Select a model to use with this provider - - - setSelectedModelId(details.value[0])} - > - - - - - {modelItems.map((item: ModelItem) => ( - - {item.label} - - ))} - - - - - ); -} diff --git a/graphcap_studio/src/features/inference/providers/form/ProviderSelect.tsx b/graphcap_studio/src/features/inference/providers/form/ProviderSelect.tsx deleted file mode 100644 index d3124ee1..00000000 --- a/graphcap_studio/src/features/inference/providers/form/ProviderSelect.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import { - SelectContent, - SelectItem, - SelectRoot, - SelectTrigger, - SelectValueText, -} from "@/components/ui/select"; -import { createListCollection } from "@chakra-ui/react"; -import { useInferenceProviderContext } from "../context"; - -type ProviderSelectProps = { - readonly className?: string; - readonly "aria-label"?: string; -}; - -/** - * Component for selecting a provider from a dropdown - */ -export function ProviderSelect({ - className, - "aria-label": ariaLabel = "Select Provider", -}: ProviderSelectProps) { - const { providers, selectedProvider, setSelectedProvider, setMode } = - useInferenceProviderContext(); - - const selectedProviderId = selectedProvider?.id ?? null; - - // Convert providers to the format expected by SelectRoot - const providerItems = providers.map((provider) => ({ - label: provider.name, - value: String(provider.id), - })); - - const providerCollection = createListCollection({ - items: providerItems, - }); - - // Convert selectedProviderId to string array format - const value = selectedProviderId ? [String(selectedProviderId)] : []; - - const handleProviderChange = (details: any) => { - const id = Number(details.value[0]); - const provider = providers.find((p) => p.id === id); - if (provider) { - setSelectedProvider(provider); - setMode("view"); - } - }; - - return ( - - - - - - {providerItems.map((item) => ( - - {item.label} - - ))} - - - ); -} diff --git a/graphcap_studio/src/features/inference/providers/form/RateLimitsSection.tsx b/graphcap_studio/src/features/inference/providers/form/RateLimitsSection.tsx deleted file mode 100644 index 0c6f8089..00000000 --- a/graphcap_studio/src/features/inference/providers/form/RateLimitsSection.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useColorModeValue } from "@/components/ui/theme/color-mode"; -import { - Box, - Field, - Grid, - GridItem, - Input, - Text, - VStack, -} from "@chakra-ui/react"; -// SPDX-License-Identifier: Apache-2.0 -import { ChangeEvent } from "react"; -import { Controller } from "react-hook-form"; -import { useInferenceProviderContext } from "../context"; - -/** - * Component for displaying and editing provider rate limits - */ -export function RateLimitsSection() { - const { control, errors, watch, isEditing } = useInferenceProviderContext(); - const labelColor = useColorModeValue("gray.600", "gray.300"); - const textColor = useColorModeValue("gray.700", "gray.200"); - - // Watch form values for read-only display - const rateLimits = watch("rateLimits"); - - if (!isEditing) { - return ( - - - - Rate Limits - - - - - Requests per minute - - - {rateLimits?.requestsPerMinute ?? 0} - - - - - Tokens per minute - - {rateLimits?.tokensPerMinute ?? 0} - - - - - ); - } - - return ( - - - - Rate Limits - - - - ( - - - Requests per minute - - ) => - onChange(parseInt(e.target.value) || 0) - } - min={0} - /> - - {errors.rateLimits?.requestsPerMinute?.message} - - - )} - /> - - - - ( - - - Tokens per minute - - ) => - onChange(parseInt(e.target.value) || 0) - } - min={0} - /> - - {errors.rateLimits?.tokensPerMinute?.message} - - - )} - /> - - - - - ); -} diff --git a/graphcap_studio/src/features/inference/providers/form/index.ts b/graphcap_studio/src/features/inference/providers/form/index.ts deleted file mode 100644 index 74080701..00000000 --- a/graphcap_studio/src/features/inference/providers/form/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -export * from "./BasicInfoSection"; -export * from "./ConnectionSection"; -export * from "./RateLimitsSection"; -export * from "./EnvironmentSelect"; -export * from "./ProviderSelect"; -export * from "../../../../components/ui/status/StatusMessage"; -export * from "./ModelSelector"; -export * from "../../../../components/ui/buttons/ActionButton"; diff --git a/graphcap_studio/src/features/inference/providers/index.ts b/graphcap_studio/src/features/inference/providers/index.ts index 351881e1..386cead9 100644 --- a/graphcap_studio/src/features/inference/providers/index.ts +++ b/graphcap_studio/src/features/inference/providers/index.ts @@ -1,10 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -export { default as ProviderForm } from "./ProviderForm"; -export { ProvidersPanel } from "./ProvidersPanel"; -export { default as ProvidersList } from "./ProvidersList"; -export { ModelSelectionSection } from "./ModelSelectionSection"; -export { FormFields } from "./FormFields"; -export { FormActions } from "./FormActions"; -export * from "../hooks"; -export * from "./context"; +export * from '@/types/provider-config-types'; +export * from './context'; +export * from './ProviderConnection'; +export * from './ProvidersPanel'; + diff --git a/graphcap_studio/src/features/inference/providers/types.ts b/graphcap_studio/src/features/inference/providers/types.ts deleted file mode 100644 index d6e2915f..00000000 --- a/graphcap_studio/src/features/inference/providers/types.ts +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -/** - * Provider Types - * - * Type definitions for provider-related data. - */ - -/** - * Provider model - */ -export interface ProviderModel { - id: number; - providerId: number; - name: string; - isEnabled: boolean; - createdAt: string | Date; - updatedAt: string | Date; -} - -/** - * Rate limits configuration - */ -export interface RateLimits { - id: number; - providerId: number; - requestsPerMinute?: number; - tokensPerMinute?: number; - createdAt: string | Date; - updatedAt: string | Date; -} - -/** - * Provider configuration - */ -export interface Provider { - id: number; - name: string; - kind: string; - environment: "cloud" | "local"; - envVar: string; - baseUrl: string; - apiKey?: string; - isEnabled: boolean; - createdAt: string | Date; - updatedAt: string | Date; - models?: ProviderModel[]; - rateLimits?: RateLimits; -} - -/** - * Provider creation payload - */ -export interface ProviderCreate { - name: string; - kind: string; - environment: "cloud" | "local"; - envVar: string; - baseUrl: string; - apiKey?: string; - isEnabled?: boolean; - models?: Array<{ - name: string; - isEnabled?: boolean; - }>; - rateLimits?: { - requestsPerMinute?: number; - tokensPerMinute?: number; - }; -} - -/** - * Provider update payload - */ -export interface ProviderUpdate { - name?: string; - kind?: string; - environment?: "cloud" | "local"; - envVar?: string; - baseUrl?: string; - isEnabled?: boolean; - models?: Array<{ - id?: number; - name: string; - isEnabled?: boolean; - }>; - rateLimits?: { - requestsPerMinute?: number; - tokensPerMinute?: number; - }; -} - -/** - * Provider API key update payload - */ -export interface ProviderApiKey { - apiKey: string; -} - -/** - * Success response - */ -export interface SuccessResponse { - success: boolean; - message: string; -} - -/** - * Provider model info from GraphCap server - */ -export interface ProviderModelInfo { - id: string; - name: string; - is_default: boolean; -} - -/** - * Provider models response from GraphCap server - */ -export interface ProviderModelsResponse { - provider: string; - models: ProviderModelInfo[]; -} diff --git a/graphcap_studio/src/features/inference/services/providers.ts b/graphcap_studio/src/features/inference/services/providers.ts index fd95a03e..9d5ce231 100644 --- a/graphcap_studio/src/features/inference/services/providers.ts +++ b/graphcap_studio/src/features/inference/services/providers.ts @@ -8,69 +8,32 @@ import { useServerConnectionsContext } from "@/context/ServerConnectionsContext"; import { SERVER_IDS } from "@/features/server-connections/constants"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { hc } from "hono/client"; -import type { AppType } from "../../../../../data_service/src/app"; // TODO: Refactor +import { + createDataServiceClient, + createInferenceBridgeClient, +} from "@/features/server-connections/services/apiClients"; import type { Provider, - ProviderApiKey, ProviderCreate, - ProviderModelsResponse, ProviderUpdate, - SuccessResponse, -} from "../providers/types"; + ServerProviderConfig, + SuccessResponse +} from "@/types/provider-config-types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; // Query keys for TanStack Query export const queryKeys = { providers: ["providers"] as const, - provider: (id: number) => [...queryKeys.providers, id] as const, + provider: (id: number) => ["providers", id] as const, providerModels: (providerName: string) => - [...queryKeys.providers, "models", providerName] as const, + ["providers", "models", providerName] as const, }; -// Define a more specific type for the client -interface DataServiceClient { - providers: { - $get: () => Promise; - $post: (options: { json: ProviderCreate }) => Promise; - [":id"]: { - $get: (options: { param: { id: string } }) => Promise; - $put: (options: { - param: { id: string }; - json: ProviderUpdate; - }) => Promise; - $delete: (options: { param: { id: string } }) => Promise; - "api-key": { - $put: (options: { - param: { id: string }; - json: ProviderApiKey; - }) => Promise; - }; - }; - }; -} - -/** - * Get the Data Service URL from server connections context - */ -function getDataServiceUrl(connections: any[]): string { - const dataServiceConnection = connections.find( - (conn) => conn.id === SERVER_IDS.DATA_SERVICE, - ); - - return ( - dataServiceConnection?.url || - import.meta.env.VITE_DATA_SERVICE_URL || - "http://localhost:32550" - ); -} - /** - * Create a Hono client for the Data Service + * Extended Error interface with cause property */ -function createDataServiceClient(connections: any[]): DataServiceClient { - const baseUrl = getDataServiceUrl(connections); - return hc(`${baseUrl}/api/v1`) as DataServiceClient; +interface ErrorWithCause extends Error { + cause?: unknown; } /** @@ -143,7 +106,33 @@ export function useCreateProvider() { }); if (!response.ok) { - throw new Error(`Failed to create provider: ${response.status}`); + // Try to get detailed error information + try { + const errorData = await response.json(); + console.error("Provider creation error:", errorData); + + // Check if we have a structured error response + if (errorData.status === "error" || errorData.validationErrors) { + throw errorData; + } + + // Simple error with a message + if (errorData.message) { + throw new Error(errorData.message); + } + + // Fallback error + throw new Error(`Failed to create provider: ${response.status}`); + } catch (parseError) { + // If we can't parse the error as JSON, throw a general error + if ( + parseError instanceof Error && + parseError.message !== "Failed to create provider" + ) { + throw parseError; + } + throw new Error(`Failed to create provider: ${response.status}`); + } } return response.json() as Promise; @@ -164,21 +153,30 @@ export function useUpdateProvider() { return useMutation({ mutationFn: async ({ id, data }: { id: number; data: ProviderUpdate }) => { + console.log("Updating provider with data:", data); + + const apiData = { ...data }; + const client = createDataServiceClient(connections); const response = await client.providers[":id"].$put({ param: { id: id.toString() }, - json: data, + json: apiData, }); if (!response.ok) { - throw new Error(`Failed to update provider: ${response.status}`); + const errorData = await response.json(); + console.error("Provider update error:", errorData); + throw errorData; } return response.json() as Promise; }, onSuccess: (data) => { + // Convert string ID to number for query invalidation + const numericId = typeof data.id === 'string' ? Number.parseInt(data.id, 10) : data.id; + // Invalidate specific provider query - queryClient.invalidateQueries({ queryKey: queryKeys.provider(data.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.provider(numericId) }); // Invalidate providers list queryClient.invalidateQueries({ queryKey: queryKeys.providers }); }, @@ -215,73 +213,59 @@ export function useDeleteProvider() { } /** - * Hook to update a provider's API key + * Hook to test provider connection */ -export function useUpdateProviderApiKey() { - const queryClient = useQueryClient(); +export function useTestProviderConnection() { const { connections } = useServerConnectionsContext(); return useMutation({ - mutationFn: async ({ id, apiKey }: { id: number; apiKey: string }) => { - const client = createDataServiceClient(connections); - const response = await client.providers[":id"]["api-key"].$put({ - param: { id: id.toString() }, - json: { apiKey } as ProviderApiKey, + mutationFn: async ({ + providerName, + config, + }: { providerName: string; config: ServerProviderConfig }) => { + const client = createInferenceBridgeClient(connections); + + console.log("Testing connection with config:", JSON.stringify(config)); + + const response = await client.providers[":provider_name"][ + "test-connection" + ].$post({ + param: { provider_name: providerName }, + json: config, }); if (!response.ok) { - throw new Error(`Failed to update API key: ${response.status}`); + const errorData = await response.json(); + console.error("Error response:", errorData); + + if (errorData.status === "error" && errorData.details) { + const error = new Error( + errorData.message || "Connection test failed", + ) as ErrorWithCause; + error.cause = errorData; + throw error; + } + + // Handle different error formats + if (errorData.detail) { + throw new Error(errorData.detail); + } + + if (errorData.message) { + throw new Error(errorData.message); + } + + if (typeof errorData === "object") { + const error = new Error("Connection test failed") as ErrorWithCause; + error.cause = errorData; + throw error; + } + + // Fallback to simple error + throw new Error(`Connection test failed: ${response.status}`); } - return response.json() as Promise; + return response.json(); }, - onSuccess: (_, { id }) => { - // Invalidate specific provider query - queryClient.invalidateQueries({ queryKey: queryKeys.provider(id) }); - }, - }); -} - -/** - * Get the GraphCap Server URL from server connections context - */ -function getGraphCapServerUrl(connections: any[]): string { - const graphcapServerConnection = connections.find( - (conn) => conn.id === SERVER_IDS.GRAPHCAP_SERVER, - ); - - return ( - graphcapServerConnection?.url || - import.meta.env.VITE_GRAPHCAP_SERVER_URL || - "http://localhost:32100" - ); -} - -/** - * Hook to get available models for a provider from the GraphCap server - */ -export function useProviderModels(providerName: string) { - const { connections } = useServerConnectionsContext(); - const graphcapServerConnection = connections.find( - (conn) => conn.id === SERVER_IDS.GRAPHCAP_SERVER, - ); - const isConnected = graphcapServerConnection?.status === "connected"; - - return useQuery({ - queryKey: queryKeys.providerModels(providerName), - queryFn: async () => { - const baseUrl = getGraphCapServerUrl(connections); - const response = await fetch( - `${baseUrl}/providers/${providerName}/models`, - ); - - if (!response.ok) { - throw new Error(`Failed to fetch provider models: ${response.status}`); - } - - return response.json() as Promise; - }, - enabled: isConnected && !!providerName, - staleTime: 1000 * 60 * 5, // 5 minutes }); } diff --git a/graphcap_studio/src/features/perspectives/README.md b/graphcap_studio/src/features/perspectives/README.md index 0c30ae8b..d5f4313b 100644 --- a/graphcap_studio/src/features/perspectives/README.md +++ b/graphcap_studio/src/features/perspectives/README.md @@ -165,4 +165,3 @@ Custom hooks are provided for working with perspectives: - **usePerspectives** - Fetches available perspectives from the server - **useGeneratePerspectiveCaption** - Generates captions for images using perspectives -- **useImagePerspectives** - Manages perspective data for a specific image \ No newline at end of file diff --git a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveActions/PerspectivesFooter.tsx b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveActions/PerspectivesFooter.tsx index 8435416d..b756cb30 100644 --- a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveActions/PerspectivesFooter.tsx +++ b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveActions/PerspectivesFooter.tsx @@ -6,27 +6,20 @@ */ import { useColorModeValue } from "@/components/ui/theme/color-mode"; -import { - GenerationOptionsButton, - GenerationOptionsProvider, - ProviderSelector, -} from "@/features/inference/generation-options"; -import { DEFAULT_OPTIONS } from "@/features/inference/generation-options/schema"; import { usePerspectiveUI, usePerspectivesData, } from "@/features/perspectives/context"; -import type { CaptionOptions } from "@/features/perspectives/types"; import { Box, Button, Flex, - HStack, Icon, - useBreakpointValue, + Text, + chakra, } from "@chakra-ui/react"; -import { useCallback, useEffect } from "react"; -import { LuRefreshCw, LuSettings } from "react-icons/lu"; +import { useCallback, useEffect, useMemo } from "react"; +import { LuRefreshCw } from "react-icons/lu"; /** * Helper function to determine button title text @@ -64,10 +57,7 @@ export function PerspectivesFooter() { generatePerspective, isGenerating, currentImage, - captionOptions, - setCaptionOptions, - selectedProvider, - handleProviderChange, + generationOptions, } = usePerspectivesData(); // Use UI context @@ -83,13 +73,19 @@ export function PerspectivesFooter() { const bgColor = useColorModeValue("white", "gray.800"); const borderColor = useColorModeValue("gray.200", "gray.700"); - - // Use responsive selector width based on screen size - const selectorWidth = useBreakpointValue({ - base: "100%", - sm: "12rem", - md: "16rem", - }); + const infoTextColor = useColorModeValue("gray.600", "gray.400"); + + // Log information for debugging + console.log("GenerationOptions:", generationOptions); + console.log("Available providers:", availableProviders); + + // Get provider information safely + const { providerName, modelName } = useMemo(() => { + return { + providerName: generationOptions.provider_name || "Select Provider", + modelName: generationOptions.model_name || "Select Model" + }; + }, [generationOptions.provider_name, generationOptions.model_name]); // Fetch providers on component mount useEffect(() => { @@ -109,7 +105,7 @@ export function PerspectivesFooter() { return false; } - if (!selectedProvider) { + if (!generationOptions.provider_name) { showMessage( "No provider selected", "Please select an inference provider", @@ -128,21 +124,13 @@ export function PerspectivesFooter() { } return true; - }, [activeSchemaName, selectedProvider, currentImage, showMessage]); + }, [activeSchemaName, generationOptions.provider_name, currentImage, showMessage]); // Handle generate button click const handleGenerate = useCallback(async () => { console.log("Generate button clicked"); console.log("Active schema:", activeSchemaName); - console.log("Selected provider:", selectedProvider); - - // Ensure we have valid options by applying defaults if needed - const effectiveOptions = - Object.keys(captionOptions).length === 0 - ? DEFAULT_OPTIONS - : captionOptions; - - console.log("Using caption options:", effectiveOptions); + console.log("Generation options:", generationOptions); if (!validateGeneration()) { return; @@ -150,12 +138,20 @@ export function PerspectivesFooter() { try { console.log("Calling generatePerspective..."); + // Find the provider object from the available providers using provider_name + const providerObject = availableProviders.find(p => p.name === generationOptions.provider_name); + + if (!providerObject) { + throw new Error(`Provider "${generationOptions.provider_name}" not found in available providers`); + } + await generatePerspective( - activeSchemaName!, - currentImage!.path, - selectedProvider, - effectiveOptions, + activeSchemaName as string, + currentImage?.path as string, + providerObject, + generationOptions ); + showMessage( "Generation started", `Generating ${activeSchemaName} perspective`, @@ -171,9 +167,9 @@ export function PerspectivesFooter() { } }, [ activeSchemaName, - selectedProvider, + availableProviders, generatePerspective, - captionOptions, + generationOptions, showMessage, currentImage, validateGeneration, @@ -184,37 +180,16 @@ export function PerspectivesFooter() { // Check if button should be disabled const isGenerateDisabled = - isProcessing || !activeSchemaName || !selectedProvider; + isProcessing || !activeSchemaName || !generationOptions.provider_name; // Get title for the generate button const buttonTitle = getButtonTitle( - selectedProvider, + generationOptions.provider_name, activeSchemaName, isProcessing, isGenerated, ); - // Handle options change - const handleOptionsChange = useCallback( - (newOptions: CaptionOptions) => { - setCaptionOptions(newOptions); - }, - [setCaptionOptions], - ); - - // Create a handler for the new ProviderSelector component - const handleProviderSelection = useCallback( - (provider: string) => { - // Create a synthetic event to pass to the original handler - const syntheticEvent = { - target: { value: provider }, - } as React.ChangeEvent; - - handleProviderChange(syntheticEvent); - }, - [handleProviderChange], - ); - return ( - {/* Provider Selection */} - {availableProviders.length > 0 ? ( - - ) : ( - - )} - - - {/* Options Button with Popover */} - - - - Options - - } - size="sm" - variant="ghost" + {/* Provider and Model Info */} + + Using: {providerName} / {modelName} + + + {/* Generate/Regenerate Button */} + - + )} + {isGenerated && !isGenerating && } + {isGenerated ? "Regenerate" : "Generate"} + ); diff --git a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/PerspectiveCardTabbed.tsx b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/PerspectiveCardTabbed.tsx index c6453bdc..e4d6eac5 100644 --- a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/PerspectiveCardTabbed.tsx +++ b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/PerspectiveCardTabbed.tsx @@ -1,5 +1,6 @@ import { ClipboardButton } from "@/components/ui/buttons"; import { useColorModeValue } from "@/components/ui/theme/color-mode"; +import type { PerspectiveSchema } from "@/types/perspective-types"; // SPDX-License-Identifier: Apache-2.0 /** * PerspectiveCardTabbed Component @@ -8,7 +9,6 @@ import { useColorModeValue } from "@/components/ui/theme/color-mode"; * This component uses Chakra UI tabs for the tabbed interface. */ import { Box, Card, Stack, Tabs, Text } from "@chakra-ui/react"; -import type { PerspectiveSchema } from "../../../types"; import { PerspectiveDebug } from "./PerspectiveDebug"; import { SchemaView } from "./SchemaView"; import { CaptionRenderer } from "./schema-fields"; @@ -175,8 +175,9 @@ export function PerspectiveCardTabbed({ {/* Metadata - e.g., timestamps or version info */} - {data?.metadata?.timestamp && - new Date(data.metadata.timestamp).toLocaleString()} + {data?.metadata?.generatedAt || data?.metadata?.timestamp ? + new Date(data?.metadata?.generatedAt || data?.metadata?.timestamp || '').toLocaleString() : + ''} diff --git a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/PerspectiveDebug.tsx b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/PerspectiveDebug.tsx index 15f54073..6e7ad947 100644 --- a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/PerspectiveDebug.tsx +++ b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/PerspectiveDebug.tsx @@ -1,4 +1,5 @@ import { ClipboardButton } from "@/components/ui/buttons"; +import type { PerspectiveData, PerspectiveSchema } from "@/types"; import { Box, Stack } from "@chakra-ui/react"; // SPDX-License-Identifier: Apache-2.0 /** @@ -8,7 +9,6 @@ import { Box, Stack } from "@chakra-ui/react"; * including its data, options, and metadata. */ import { useEffect } from "react"; -import type { PerspectiveData, PerspectiveSchema } from "../../../types"; import { DataStatistics, MetadataSection, @@ -49,9 +49,7 @@ function processDebugInfo( model: perspectiveData?.model, version: perspectiveData?.version, config_name: perspectiveData?.config_name ?? schema.name, - generatedAt: data.metadata?.timestamp - ? new Date(data.metadata.timestamp).toISOString() - : null, + generatedAt: data.metadata?.generatedAt ?? null, }, // Generation options - directly from the PerspectiveData interface options: perspectiveData?.options || null, diff --git a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/SchemaView.tsx b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/SchemaView.tsx index 34b72a5b..b0cbb982 100644 --- a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/SchemaView.tsx +++ b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/SchemaView.tsx @@ -5,13 +5,12 @@ * This component displays the schema information for a perspective. */ -import React from "react"; -import type { PerspectiveSchema } from "../../../types"; +import type { PerspectiveSchema } from "@/types"; import { SchemaFieldFactory } from "./schema-fields"; interface SchemaViewProps { - schema: PerspectiveSchema; - className?: string; + readonly schema: PerspectiveSchema; + readonly className?: string; } export function SchemaView({ schema, className = "" }: SchemaViewProps) { diff --git a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/debug-fields/MetadataSection.tsx b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/debug-fields/MetadataSection.tsx index 143ddaee..13663e43 100644 --- a/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/debug-fields/MetadataSection.tsx +++ b/graphcap_studio/src/features/perspectives/components/PerspectiveCaption/PerspectiveCard/debug-fields/MetadataSection.tsx @@ -77,6 +77,11 @@ function MetadataItem({ labelColor, valueColor, }: MetadataItemProps) { + // Format date if this is the Generated timestamp field + const formattedValue = label === "Generated:" && value + ? new Date(value).toLocaleString() + : value; + return ( @@ -84,7 +89,7 @@ function MetadataItem({ {value ? ( - {value} + {formattedValue} ) : ( diff --git a/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/PerspectiveFilterPanel.tsx b/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/PerspectiveFilterPanel.tsx deleted file mode 100644 index 177d6f2c..00000000 --- a/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/PerspectiveFilterPanel.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -/** - * Perspective Filter Panel - * - * This component provides a UI for toggling the visibility of different perspectives. - */ - -import { Checkbox } from "@/components/ui/checkbox"; -import { - Box, - Button, - Flex, - HStack, - Heading, - Text, - VStack, -} from "@chakra-ui/react"; -import { useMemo } from "react"; -import { usePerspectivesData } from "../../context/PerspectivesDataContext"; - -/** - * Component for filtering which perspectives are visible in the UI - */ -export function PerspectiveFilterPanel() { - const { - perspectives, - hiddenPerspectives, - togglePerspectiveVisibility, - isPerspectiveVisible, - setAllPerspectivesVisible, - } = usePerspectivesData(); - - // Count how many perspectives are visible/hidden - const counts = useMemo(() => { - const totalCount = perspectives.length; - const hiddenCount = hiddenPerspectives.length; - const visibleCount = totalCount - hiddenCount; - - return { totalCount, hiddenCount, visibleCount }; - }, [perspectives, hiddenPerspectives]); - - return ( - - - - Perspective Visibility - - {counts.visibleCount} of {counts.totalCount} visible - - - - - - - {perspectives.map((perspective) => ( - togglePerspectiveVisibility(perspective.name)} - colorScheme="blue" - size="sm" - > - - {perspective.display_name || perspective.name} - - - ))} - - - - - - - - - - - ); -} diff --git a/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/PerspectiveModuleFilter.tsx b/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/PerspectiveModuleFilter.tsx index 808298cc..762df226 100644 --- a/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/PerspectiveModuleFilter.tsx +++ b/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/PerspectiveModuleFilter.tsx @@ -170,7 +170,7 @@ export function PerspectiveModuleFilter({ alignItems="center" justifyContent="center" color={buttonColor} - width="20px" + width="60px" height="20px" borderWidth="1px" borderColor="currentColor" @@ -178,7 +178,7 @@ export function PerspectiveModuleFilter({ ml={1} _hover={{ bg: hoverBgColor }} > - → + View → diff --git a/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/index.ts b/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/index.ts index 2efe2459..e6565208 100644 --- a/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/index.ts +++ b/graphcap_studio/src/features/perspectives/components/PerspectiveManagement/index.ts @@ -1,10 +1,10 @@ -export { ModuleList } from './PerspectiveModules/ModuleList'; -export { ModuleInfo } from './PerspectiveModules/ModuleInfo'; -export { NotFound } from './NotFound'; export { ErrorDisplay } from './ErrorDisplay'; export { LoadingDisplay } from './LoadingDisplay'; +export { NotFound } from './NotFound'; export { PerspectiveEditor } from './PerspectiveEditor/PerspectiveEditor'; -export { SchemaValidationError } from './SchemaValidationError'; -export { PerspectiveModuleFilter } from './PerspectiveModuleFilter'; export { PerspectiveManagementPanel } from './PerspectiveManagementPanel'; -export { PerspectiveFilterPanel } from './PerspectiveFilterPanel'; +export { PerspectiveModuleFilter } from './PerspectiveModuleFilter'; +export { ModuleInfo } from './PerspectiveModules/ModuleInfo'; +export { ModuleList } from './PerspectiveModules/ModuleList'; +export { SchemaValidationError } from './SchemaValidationError'; + diff --git a/graphcap_studio/src/features/perspectives/components/PerspectivesErrorState.tsx b/graphcap_studio/src/features/perspectives/components/PerspectivesErrorState.tsx index af12d179..908a091c 100644 --- a/graphcap_studio/src/features/perspectives/components/PerspectivesErrorState.tsx +++ b/graphcap_studio/src/features/perspectives/components/PerspectivesErrorState.tsx @@ -42,7 +42,7 @@ export function PerspectivesErrorState({ Server Connection Error - Unable to connect to the GraphCap server. Please check your + Unable to connect to the Inference Bridge. Please check your connection settings and try again.