diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index 57f00a0..4062b19 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -5,6 +5,7 @@ import App from '@/App' import { InstancesProvider } from '@/contexts/InstancesContext' import { instancesApi } from '@/lib/api' import type { Instance } from '@/types/instance' +import { BackendType } from '@/types/instance' import { AuthProvider } from '@/contexts/AuthContext' // Mock the API @@ -46,8 +47,8 @@ function renderApp() { describe('App Component - Critical Business Logic Only', () => { const mockInstances: Instance[] = [ - { name: 'test-instance-1', status: 'stopped', options: { model: 'model1.gguf' } }, - { name: 'test-instance-2', status: 'running', options: { model: 'model2.gguf' } } + { name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model1.gguf' } } }, + { name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model2.gguf' } } } ] beforeEach(() => { @@ -82,7 +83,7 @@ describe('App Component - Critical Business Logic Only', () => { const newInstance: Instance = { name: 'new-test-instance', status: 'stopped', - options: { model: 'new-model.gguf' } + options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'new-model.gguf' } } } vi.mocked(instancesApi.create).mockResolvedValue(newInstance) @@ -105,6 +106,7 @@ describe('App Component - Critical Business Logic Only', () => { await waitFor(() => { expect(instancesApi.create).toHaveBeenCalledWith('new-test-instance', { auto_restart: true, // Default value + backend_type: BackendType.LLAMA_SERVER }) }) @@ -119,7 +121,7 @@ describe('App Component - Critical Business Logic Only', () => { const updatedInstance: Instance = { name: 'test-instance-1', status: 'stopped', - options: { model: 'updated-model.gguf' } + options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'updated-model.gguf' } } } vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance) @@ -138,7 +140,8 @@ describe('App Component - Critical Business Logic Only', () => { // Verify correct API call with existing instance data await waitFor(() => { expect(instancesApi.update).toHaveBeenCalledWith('test-instance-1', { - model: "model1.gguf", // Pre-filled from existing instance + backend_type: BackendType.LLAMA_SERVER, + backend_options: { model: "model1.gguf" } // Pre-filled from existing instance }) }) }) diff --git a/webui/src/components/BackendFormField.tsx b/webui/src/components/BackendFormField.tsx new file mode 100644 index 0000000..a210626 --- /dev/null +++ b/webui/src/components/BackendFormField.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import type { BackendOptions } from '@/schemas/instanceOptions' +import { getBackendFieldType, basicBackendFieldsConfig } from '@/lib/zodFormUtils' + +interface BackendFormFieldProps { + fieldKey: keyof BackendOptions + value: string | number | boolean | string[] | undefined + onChange: (key: string, value: string | number | boolean | string[] | undefined) => void +} + +const BackendFormField: React.FC = ({ fieldKey, value, onChange }) => { + // Get configuration for basic fields, or use field name for advanced fields + const config = basicBackendFieldsConfig[fieldKey as string] || { label: fieldKey } + + // Get type from Zod schema + const fieldType = getBackendFieldType(fieldKey) + + const handleChange = (newValue: string | number | boolean | string[] | undefined) => { + onChange(fieldKey as string, newValue) + } + + const renderField = () => { + switch (fieldType) { + case 'boolean': + return ( +
+ handleChange(checked)} + /> + +
+ ) + + case 'number': + return ( +
+ + { + const numValue = e.target.value ? parseFloat(e.target.value) : undefined + // Only update if the parsed value is valid or the input is empty + if (e.target.value === '' || (numValue !== undefined && !isNaN(numValue))) { + handleChange(numValue) + } + }} + placeholder={config.placeholder} + /> + {config.description && ( +

{config.description}

+ )} +
+ ) + + case 'array': + return ( +
+ + { + const arrayValue = e.target.value + ? e.target.value.split(',').map(s => s.trim()).filter(Boolean) + : undefined + handleChange(arrayValue) + }} + placeholder="item1, item2, item3" + /> + {config.description && ( +

{config.description}

+ )} +

Separate multiple values with commas

+
+ ) + + case 'text': + default: + return ( +
+ + handleChange(e.target.value || undefined)} + placeholder={config.placeholder} + /> + {config.description && ( +

{config.description}

+ )} +
+ ) + } + } + + return
{renderField()}
+} + +export default BackendFormField \ No newline at end of file diff --git a/webui/src/components/InstanceDialog.tsx b/webui/src/components/InstanceDialog.tsx index 56792e6..1c873a8 100644 --- a/webui/src/components/InstanceDialog.tsx +++ b/webui/src/components/InstanceDialog.tsx @@ -11,9 +11,11 @@ import { DialogTitle, } from "@/components/ui/dialog"; import type { CreateInstanceOptions, Instance } from "@/types/instance"; -import { getBasicFields, getAdvancedFields } from "@/lib/zodFormUtils"; +import { BackendType } from "@/types/instance"; +import { getBasicFields, getAdvancedFields, getBasicBackendFields, getAdvancedBackendFields } from "@/lib/zodFormUtils"; import { ChevronDown, ChevronRight } from "lucide-react"; import ZodFormField from "@/components/ZodFormField"; +import BackendFormField from "@/components/BackendFormField"; interface InstanceDialogProps { open: boolean; @@ -38,6 +40,8 @@ const InstanceDialog: React.FC = ({ // Get field lists dynamically from the type const basicFields = getBasicFields(); const advancedFields = getAdvancedFields(); + const basicBackendFields = getBasicBackendFields(); + const advancedBackendFields = getAdvancedBackendFields(); // Reset form when dialog opens/closes or when instance changes useEffect(() => { @@ -51,6 +55,8 @@ const InstanceDialog: React.FC = ({ setInstanceName(""); setFormData({ auto_restart: true, // Default value + backend_type: BackendType.LLAMA_SERVER, // Default backend type + backend_options: {}, }); } setShowAdvanced(false); // Always start with basic view @@ -65,6 +71,16 @@ const InstanceDialog: React.FC = ({ })); }; + const handleBackendFieldChange = (key: string, value: any) => { + setFormData((prev) => ({ + ...prev, + backend_options: { + ...prev.backend_options, + [key]: value, + }, + })); + }; + const handleNameChange = (name: string) => { setInstanceName(name); // Validate instance name @@ -89,7 +105,24 @@ const InstanceDialog: React.FC = ({ // Clean up undefined values to avoid sending empty fields const cleanOptions: CreateInstanceOptions = {}; Object.entries(formData).forEach(([key, value]) => { - if (value !== undefined && value !== "" && value !== null) { + if (key === 'backend_options' && value && typeof value === 'object') { + // Handle backend_options specially - clean nested object + const cleanBackendOptions: any = {}; + Object.entries(value).forEach(([backendKey, backendValue]) => { + if (backendValue !== undefined && backendValue !== null && (typeof backendValue !== 'string' || backendValue.trim() !== "")) { + // Handle arrays - don't include empty arrays + if (Array.isArray(backendValue) && backendValue.length === 0) { + return; + } + cleanBackendOptions[backendKey] = backendValue; + } + }); + + // Only include backend_options if it has content + if (Object.keys(cleanBackendOptions).length > 0) { + (cleanOptions as any)[key] = cleanBackendOptions; + } + } else if (value !== undefined && value !== null && (typeof value !== 'string' || value.trim() !== "")) { // Handle arrays - don't include empty arrays if (Array.isArray(value) && value.length === 0) { return; @@ -196,8 +229,9 @@ const InstanceDialog: React.FC = ({ (fieldKey) => fieldKey !== "auto_restart" && fieldKey !== "max_restarts" && - fieldKey !== "restart_delay" - ) // Exclude auto_restart, max_restarts, and restart_delay as they're handled above + fieldKey !== "restart_delay" && + fieldKey !== "backend_options" // backend_options is handled separately + ) .map((fieldKey) => ( = ({ ))} + {/* Backend Configuration Section */} +
+

Backend Configuration

+ + {/* Basic backend fields */} + {basicBackendFields.map((fieldKey) => ( + + ))} +
+ {/* Advanced Fields Toggle */}
diff --git a/webui/src/components/ZodFormField.tsx b/webui/src/components/ZodFormField.tsx index 2ee912d..90720c9 100644 --- a/webui/src/components/ZodFormField.tsx +++ b/webui/src/components/ZodFormField.tsx @@ -3,6 +3,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Checkbox } from '@/components/ui/checkbox' import type { CreateInstanceOptions } from '@/types/instance' +import { BackendType } from '@/types/instance' import { getFieldType, basicFieldsConfig } from '@/lib/zodFormUtils' interface ZodFormFieldProps { @@ -23,6 +24,30 @@ const ZodFormField: React.FC = ({ fieldKey, value, onChange } } const renderField = () => { + // Special handling for backend_type field - render as dropdown + if (fieldKey === 'backend_type') { + return ( +
+ + + {config.description && ( +

{config.description}

+ )} +
+ ) + } + switch (fieldType) { case 'boolean': return ( diff --git a/webui/src/components/__tests__/InstanceCard.test.tsx b/webui/src/components/__tests__/InstanceCard.test.tsx index 5daebe4..40fc446 100644 --- a/webui/src/components/__tests__/InstanceCard.test.tsx +++ b/webui/src/components/__tests__/InstanceCard.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import InstanceCard from '@/components/InstanceCard' import type { Instance } from '@/types/instance' +import { BackendType } from '@/types/instance' // Mock the health hook since we're not testing health logic here vi.mock('@/hooks/useInstanceHealth', () => ({ @@ -18,13 +19,13 @@ describe('InstanceCard - Instance Actions and State', () => { const stoppedInstance: Instance = { name: 'test-instance', status: 'stopped', - options: { model: 'test-model.gguf' } + options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'test-model.gguf' } } } const runningInstance: Instance = { name: 'running-instance', status: 'running', - options: { model: 'running-model.gguf' } + options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'running-model.gguf' } } } beforeEach(() => { diff --git a/webui/src/components/__tests__/InstanceList.test.tsx b/webui/src/components/__tests__/InstanceList.test.tsx index a38f873..2aa6cc0 100644 --- a/webui/src/components/__tests__/InstanceList.test.tsx +++ b/webui/src/components/__tests__/InstanceList.test.tsx @@ -5,6 +5,7 @@ import InstanceList from '@/components/InstanceList' import { InstancesProvider } from '@/contexts/InstancesContext' import { instancesApi } from '@/lib/api' import type { Instance } from '@/types/instance' +import { BackendType } from '@/types/instance' import { AuthProvider } from '@/contexts/AuthContext' // Mock the API @@ -44,9 +45,9 @@ describe('InstanceList - State Management and UI Logic', () => { const mockEditInstance = vi.fn() const mockInstances: Instance[] = [ - { name: 'instance-1', status: 'stopped', options: { model: 'model1.gguf' } }, - { name: 'instance-2', status: 'running', options: { model: 'model2.gguf' } }, - { name: 'instance-3', status: 'stopped', options: { model: 'model3.gguf' } } + { name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model1.gguf' } } }, + { name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model2.gguf' } } }, + { name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model3.gguf' } } } ] const DUMMY_API_KEY = 'test-api-key-123' diff --git a/webui/src/components/__tests__/InstanceModal.test.tsx b/webui/src/components/__tests__/InstanceModal.test.tsx index 8468379..dea389b 100644 --- a/webui/src/components/__tests__/InstanceModal.test.tsx +++ b/webui/src/components/__tests__/InstanceModal.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import InstanceDialog from '@/components/InstanceDialog' import type { Instance } from '@/types/instance' +import { BackendType } from '@/types/instance' describe('InstanceModal - Form Logic and Validation', () => { const mockOnSave = vi.fn() @@ -91,6 +92,7 @@ afterEach(() => { expect(mockOnSave).toHaveBeenCalledWith('my-instance', { auto_restart: true, // Default value + backend_type: BackendType.LLAMA_SERVER }) }) @@ -136,8 +138,8 @@ afterEach(() => { name: 'existing-instance', status: 'stopped', options: { - model: 'test-model.gguf', - gpu_layers: 10, + backend_type: BackendType.LLAMA_SERVER, + backend_options: { model: 'test-model.gguf', gpu_layers: 10 }, auto_restart: false } } @@ -177,8 +179,8 @@ afterEach(() => { await user.click(screen.getByTestId('dialog-save-button')) expect(mockOnSave).toHaveBeenCalledWith('existing-instance', { - model: 'test-model.gguf', - gpu_layers: 10, + backend_type: BackendType.LLAMA_SERVER, + backend_options: { model: 'test-model.gguf', gpu_layers: 10 }, auto_restart: false }) }) @@ -271,6 +273,7 @@ afterEach(() => { expect(mockOnSave).toHaveBeenCalledWith('test-instance', { auto_restart: true, + backend_type: BackendType.LLAMA_SERVER, max_restarts: 5, restart_delay: 10 }) @@ -321,6 +324,7 @@ afterEach(() => { // Should only include non-empty values expect(mockOnSave).toHaveBeenCalledWith('clean-instance', { auto_restart: true, // Only this default value should be included + backend_type: BackendType.LLAMA_SERVER }) }) @@ -345,7 +349,8 @@ afterEach(() => { expect(mockOnSave).toHaveBeenCalledWith('numeric-test', { auto_restart: true, - gpu_layers: 15, // Should be number, not string + backend_type: BackendType.LLAMA_SERVER, + backend_options: { gpu_layers: 15 }, // Should be number, not string }) }) }) diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index c271730..3ff9d34 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { InstancesProvider, useInstances } from "@/contexts/InstancesContext"; import { instancesApi } from "@/lib/api"; import type { Instance } from "@/types/instance"; +import { BackendType } from "@/types/instance"; import { AuthProvider } from "../AuthContext"; // Mock the API module @@ -47,13 +48,13 @@ function TestComponent() { {/* Action buttons for testing with specific instances */}