Implement backend configuration options and refactor related components

This commit is contained in:
2025-09-02 21:12:14 +02:00
parent d9542ba117
commit 4f6bb6292e
11 changed files with 358 additions and 62 deletions

View File

@@ -5,6 +5,7 @@ import App from '@/App'
import { InstancesProvider } from '@/contexts/InstancesContext' import { InstancesProvider } from '@/contexts/InstancesContext'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
import type { Instance } from '@/types/instance' import type { Instance } from '@/types/instance'
import { BackendType } from '@/types/instance'
import { AuthProvider } from '@/contexts/AuthContext' import { AuthProvider } from '@/contexts/AuthContext'
// Mock the API // Mock the API
@@ -46,8 +47,8 @@ function renderApp() {
describe('App Component - Critical Business Logic Only', () => { describe('App Component - Critical Business Logic Only', () => {
const mockInstances: Instance[] = [ const mockInstances: Instance[] = [
{ name: 'test-instance-1', status: 'stopped', options: { model: 'model1.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: { model: 'model2.gguf' } } { name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model2.gguf' } } }
] ]
beforeEach(() => { beforeEach(() => {
@@ -82,7 +83,7 @@ describe('App Component - Critical Business Logic Only', () => {
const newInstance: Instance = { const newInstance: Instance = {
name: 'new-test-instance', name: 'new-test-instance',
status: 'stopped', 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) vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
@@ -105,6 +106,7 @@ describe('App Component - Critical Business Logic Only', () => {
await waitFor(() => { await waitFor(() => {
expect(instancesApi.create).toHaveBeenCalledWith('new-test-instance', { expect(instancesApi.create).toHaveBeenCalledWith('new-test-instance', {
auto_restart: true, // Default value auto_restart: true, // Default value
backend_type: BackendType.LLAMA_SERVER
}) })
}) })
@@ -119,7 +121,7 @@ describe('App Component - Critical Business Logic Only', () => {
const updatedInstance: Instance = { const updatedInstance: Instance = {
name: 'test-instance-1', name: 'test-instance-1',
status: 'stopped', 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) 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 // Verify correct API call with existing instance data
await waitFor(() => { await waitFor(() => {
expect(instancesApi.update).toHaveBeenCalledWith('test-instance-1', { 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
}) })
}) })
}) })

View File

@@ -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<BackendFormFieldProps> = ({ 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 (
<div className="flex items-center space-x-2">
<Checkbox
id={fieldKey}
checked={typeof value === 'boolean' ? value : false}
onCheckedChange={(checked) => handleChange(checked)}
/>
<Label htmlFor={fieldKey} className="text-sm font-normal">
{config.label}
{config.description && (
<span className="text-muted-foreground ml-1">- {config.description}</span>
)}
</Label>
</div>
)
case 'number':
return (
<div className="grid gap-2">
<Label htmlFor={fieldKey}>
{config.label}
{config.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
id={fieldKey}
type="number"
step="any" // This allows decimal numbers
value={typeof value === 'string' || typeof value === 'number' ? value : ''}
onChange={(e) => {
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 && (
<p className="text-sm text-muted-foreground">{config.description}</p>
)}
</div>
)
case 'array':
return (
<div className="grid gap-2">
<Label htmlFor={fieldKey}>
{config.label}
{config.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
id={fieldKey}
type="text"
value={Array.isArray(value) ? value.join(', ') : ''}
onChange={(e) => {
const arrayValue = e.target.value
? e.target.value.split(',').map(s => s.trim()).filter(Boolean)
: undefined
handleChange(arrayValue)
}}
placeholder="item1, item2, item3"
/>
{config.description && (
<p className="text-sm text-muted-foreground">{config.description}</p>
)}
<p className="text-xs text-muted-foreground">Separate multiple values with commas</p>
</div>
)
case 'text':
default:
return (
<div className="grid gap-2">
<Label htmlFor={fieldKey}>
{config.label}
{config.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
id={fieldKey}
type="text"
value={typeof value === 'string' || typeof value === 'number' ? value : ''}
onChange={(e) => handleChange(e.target.value || undefined)}
placeholder={config.placeholder}
/>
{config.description && (
<p className="text-sm text-muted-foreground">{config.description}</p>
)}
</div>
)
}
}
return <div className="space-y-2">{renderField()}</div>
}
export default BackendFormField

View File

@@ -11,9 +11,11 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import type { CreateInstanceOptions, Instance } from "@/types/instance"; 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 { ChevronDown, ChevronRight } from "lucide-react";
import ZodFormField from "@/components/ZodFormField"; import ZodFormField from "@/components/ZodFormField";
import BackendFormField from "@/components/BackendFormField";
interface InstanceDialogProps { interface InstanceDialogProps {
open: boolean; open: boolean;
@@ -38,6 +40,8 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
// Get field lists dynamically from the type // Get field lists dynamically from the type
const basicFields = getBasicFields(); const basicFields = getBasicFields();
const advancedFields = getAdvancedFields(); const advancedFields = getAdvancedFields();
const basicBackendFields = getBasicBackendFields();
const advancedBackendFields = getAdvancedBackendFields();
// Reset form when dialog opens/closes or when instance changes // Reset form when dialog opens/closes or when instance changes
useEffect(() => { useEffect(() => {
@@ -51,6 +55,8 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
setInstanceName(""); setInstanceName("");
setFormData({ setFormData({
auto_restart: true, // Default value auto_restart: true, // Default value
backend_type: BackendType.LLAMA_SERVER, // Default backend type
backend_options: {},
}); });
} }
setShowAdvanced(false); // Always start with basic view setShowAdvanced(false); // Always start with basic view
@@ -65,6 +71,16 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
})); }));
}; };
const handleBackendFieldChange = (key: string, value: any) => {
setFormData((prev) => ({
...prev,
backend_options: {
...prev.backend_options,
[key]: value,
},
}));
};
const handleNameChange = (name: string) => { const handleNameChange = (name: string) => {
setInstanceName(name); setInstanceName(name);
// Validate instance name // Validate instance name
@@ -89,7 +105,24 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
// Clean up undefined values to avoid sending empty fields // Clean up undefined values to avoid sending empty fields
const cleanOptions: CreateInstanceOptions = {}; const cleanOptions: CreateInstanceOptions = {};
Object.entries(formData).forEach(([key, value]) => { 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 // Handle arrays - don't include empty arrays
if (Array.isArray(value) && value.length === 0) { if (Array.isArray(value) && value.length === 0) {
return; return;
@@ -196,8 +229,9 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
(fieldKey) => (fieldKey) =>
fieldKey !== "auto_restart" && fieldKey !== "auto_restart" &&
fieldKey !== "max_restarts" && fieldKey !== "max_restarts" &&
fieldKey !== "restart_delay" fieldKey !== "restart_delay" &&
) // Exclude auto_restart, max_restarts, and restart_delay as they're handled above fieldKey !== "backend_options" // backend_options is handled separately
)
.map((fieldKey) => ( .map((fieldKey) => (
<ZodFormField <ZodFormField
key={fieldKey} key={fieldKey}
@@ -208,6 +242,21 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
))} ))}
</div> </div>
{/* Backend Configuration Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Backend Configuration</h3>
{/* Basic backend fields */}
{basicBackendFields.map((fieldKey) => (
<BackendFormField
key={fieldKey}
fieldKey={fieldKey}
value={formData.backend_options?.[fieldKey]}
onChange={handleBackendFieldChange}
/>
))}
</div>
{/* Advanced Fields Toggle */} {/* Advanced Fields Toggle */}
<div className="border-t pt-4"> <div className="border-t pt-4">
<Button <Button
@@ -226,8 +275,8 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
{ {
advancedFields.filter( advancedFields.filter(
(f) => (f) =>
!["max_restarts", "restart_delay"].includes(f as string) !["max_restarts", "restart_delay", "backend_options"].includes(f as string)
).length ).length + advancedBackendFields.length
}{" "} }{" "}
options) options)
</span> </span>
@@ -237,24 +286,51 @@ const InstanceDialog: React.FC<InstanceDialogProps> = ({
{/* Advanced Fields - Automatically generated from type (excluding restart options) */} {/* Advanced Fields - Automatically generated from type (excluding restart options) */}
{showAdvanced && ( {showAdvanced && (
<div className="space-y-4 pl-6 border-l-2 border-muted"> <div className="space-y-4 pl-6 border-l-2 border-muted">
<div className="space-y-4"> {/* Advanced instance fields */}
{advancedFields {advancedFields
.filter( .filter(
(fieldKey) => (fieldKey) =>
!["max_restarts", "restart_delay"].includes( !["max_restarts", "restart_delay", "backend_options"].includes(
fieldKey as string fieldKey as string
) )
) // Exclude restart options as they're handled above ).length > 0 && (
<div className="space-y-4">
<h4 className="text-md font-medium">Advanced Instance Configuration</h4>
{advancedFields
.filter(
(fieldKey) =>
!["max_restarts", "restart_delay", "backend_options"].includes(
fieldKey as string
)
)
.sort() .sort()
.map((fieldKey) => ( .map((fieldKey) => (
<ZodFormField <ZodFormField
key={fieldKey} key={fieldKey}
fieldKey={fieldKey} fieldKey={fieldKey}
value={formData[fieldKey]} value={fieldKey === 'backend_options' ? undefined : formData[fieldKey]}
onChange={handleFieldChange} onChange={handleFieldChange}
/> />
))} ))}
</div> </div>
)}
{/* Advanced backend fields */}
{advancedBackendFields.length > 0 && (
<div className="space-y-4">
<h4 className="text-md font-medium">Advanced Backend Configuration</h4>
{advancedBackendFields
.sort()
.map((fieldKey) => (
<BackendFormField
key={fieldKey}
fieldKey={fieldKey}
value={formData.backend_options?.[fieldKey]}
onChange={handleBackendFieldChange}
/>
))}
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import type { CreateInstanceOptions } from '@/types/instance' import type { CreateInstanceOptions } from '@/types/instance'
import { BackendType } from '@/types/instance'
import { getFieldType, basicFieldsConfig } from '@/lib/zodFormUtils' import { getFieldType, basicFieldsConfig } from '@/lib/zodFormUtils'
interface ZodFormFieldProps { interface ZodFormFieldProps {
@@ -23,6 +24,30 @@ const ZodFormField: React.FC<ZodFormFieldProps> = ({ fieldKey, value, onChange }
} }
const renderField = () => { const renderField = () => {
// Special handling for backend_type field - render as dropdown
if (fieldKey === 'backend_type') {
return (
<div className="grid gap-2">
<Label htmlFor={fieldKey}>
{config.label}
{config.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<select
id={fieldKey}
value={typeof value === 'string' ? value : BackendType.LLAMA_SERVER}
onChange={(e) => handleChange(e.target.value || undefined)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value={BackendType.LLAMA_SERVER}>Llama Server</option>
{/* Add more backend types here as they become available */}
</select>
{config.description && (
<p className="text-sm text-muted-foreground">{config.description}</p>
)}
</div>
)
}
switch (fieldType) { switch (fieldType) {
case 'boolean': case 'boolean':
return ( return (

View File

@@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import InstanceCard from '@/components/InstanceCard' import InstanceCard from '@/components/InstanceCard'
import type { Instance } from '@/types/instance' import type { Instance } from '@/types/instance'
import { BackendType } from '@/types/instance'
// Mock the health hook since we're not testing health logic here // Mock the health hook since we're not testing health logic here
vi.mock('@/hooks/useInstanceHealth', () => ({ vi.mock('@/hooks/useInstanceHealth', () => ({
@@ -18,13 +19,13 @@ describe('InstanceCard - Instance Actions and State', () => {
const stoppedInstance: Instance = { const stoppedInstance: Instance = {
name: 'test-instance', name: 'test-instance',
status: 'stopped', status: 'stopped',
options: { model: 'test-model.gguf' } options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'test-model.gguf' } }
} }
const runningInstance: Instance = { const runningInstance: Instance = {
name: 'running-instance', name: 'running-instance',
status: 'running', status: 'running',
options: { model: 'running-model.gguf' } options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'running-model.gguf' } }
} }
beforeEach(() => { beforeEach(() => {

View File

@@ -5,6 +5,7 @@ import InstanceList from '@/components/InstanceList'
import { InstancesProvider } from '@/contexts/InstancesContext' import { InstancesProvider } from '@/contexts/InstancesContext'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
import type { Instance } from '@/types/instance' import type { Instance } from '@/types/instance'
import { BackendType } from '@/types/instance'
import { AuthProvider } from '@/contexts/AuthContext' import { AuthProvider } from '@/contexts/AuthContext'
// Mock the API // Mock the API
@@ -44,9 +45,9 @@ describe('InstanceList - State Management and UI Logic', () => {
const mockEditInstance = vi.fn() const mockEditInstance = vi.fn()
const mockInstances: Instance[] = [ const mockInstances: Instance[] = [
{ name: 'instance-1', status: 'stopped', options: { model: 'model1.gguf' } }, { name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model1.gguf' } } },
{ name: 'instance-2', status: 'running', options: { model: 'model2.gguf' } }, { name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: 'model2.gguf' } } },
{ name: 'instance-3', status: 'stopped', options: { model: 'model3.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' const DUMMY_API_KEY = 'test-api-key-123'

View File

@@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import InstanceDialog from '@/components/InstanceDialog' import InstanceDialog from '@/components/InstanceDialog'
import type { Instance } from '@/types/instance' import type { Instance } from '@/types/instance'
import { BackendType } from '@/types/instance'
describe('InstanceModal - Form Logic and Validation', () => { describe('InstanceModal - Form Logic and Validation', () => {
const mockOnSave = vi.fn() const mockOnSave = vi.fn()
@@ -91,6 +92,7 @@ afterEach(() => {
expect(mockOnSave).toHaveBeenCalledWith('my-instance', { expect(mockOnSave).toHaveBeenCalledWith('my-instance', {
auto_restart: true, // Default value auto_restart: true, // Default value
backend_type: BackendType.LLAMA_SERVER
}) })
}) })
@@ -136,8 +138,8 @@ afterEach(() => {
name: 'existing-instance', name: 'existing-instance',
status: 'stopped', status: 'stopped',
options: { options: {
model: 'test-model.gguf', backend_type: BackendType.LLAMA_SERVER,
gpu_layers: 10, backend_options: { model: 'test-model.gguf', gpu_layers: 10 },
auto_restart: false auto_restart: false
} }
} }
@@ -177,8 +179,8 @@ afterEach(() => {
await user.click(screen.getByTestId('dialog-save-button')) await user.click(screen.getByTestId('dialog-save-button'))
expect(mockOnSave).toHaveBeenCalledWith('existing-instance', { expect(mockOnSave).toHaveBeenCalledWith('existing-instance', {
model: 'test-model.gguf', backend_type: BackendType.LLAMA_SERVER,
gpu_layers: 10, backend_options: { model: 'test-model.gguf', gpu_layers: 10 },
auto_restart: false auto_restart: false
}) })
}) })
@@ -271,6 +273,7 @@ afterEach(() => {
expect(mockOnSave).toHaveBeenCalledWith('test-instance', { expect(mockOnSave).toHaveBeenCalledWith('test-instance', {
auto_restart: true, auto_restart: true,
backend_type: BackendType.LLAMA_SERVER,
max_restarts: 5, max_restarts: 5,
restart_delay: 10 restart_delay: 10
}) })
@@ -321,6 +324,7 @@ afterEach(() => {
// Should only include non-empty values // Should only include non-empty values
expect(mockOnSave).toHaveBeenCalledWith('clean-instance', { expect(mockOnSave).toHaveBeenCalledWith('clean-instance', {
auto_restart: true, // Only this default value should be included 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', { expect(mockOnSave).toHaveBeenCalledWith('numeric-test', {
auto_restart: true, 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
}) })
}) })
}) })

View File

@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { InstancesProvider, useInstances } from "@/contexts/InstancesContext"; import { InstancesProvider, useInstances } from "@/contexts/InstancesContext";
import { instancesApi } from "@/lib/api"; import { instancesApi } from "@/lib/api";
import type { Instance } from "@/types/instance"; import type { Instance } from "@/types/instance";
import { BackendType } from "@/types/instance";
import { AuthProvider } from "../AuthContext"; import { AuthProvider } from "../AuthContext";
// Mock the API module // Mock the API module
@@ -47,13 +48,13 @@ function TestComponent() {
{/* Action buttons for testing with specific instances */} {/* Action buttons for testing with specific instances */}
<button <button
onClick={() => createInstance("new-instance", { model: "test.gguf" })} onClick={() => createInstance("new-instance", { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "test.gguf" } })}
data-testid="create-instance" data-testid="create-instance"
> >
Create Instance Create Instance
</button> </button>
<button <button
onClick={() => updateInstance("instance1", { model: "updated.gguf" })} onClick={() => updateInstance("instance1", { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "updated.gguf" } })}
data-testid="update-instance" data-testid="update-instance"
> >
Update Instance Update Instance
@@ -99,8 +100,8 @@ function renderWithProvider(children: ReactNode) {
describe("InstancesContext", () => { describe("InstancesContext", () => {
const mockInstances: Instance[] = [ const mockInstances: Instance[] = [
{ name: "instance1", status: "running", options: { model: "model1.gguf" } }, { name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "model1.gguf" } } },
{ name: "instance2", status: "stopped", options: { model: "model2.gguf" } }, { name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "model2.gguf" } } },
]; ];
beforeEach(() => { beforeEach(() => {
@@ -159,7 +160,7 @@ describe("InstancesContext", () => {
const newInstance: Instance = { const newInstance: Instance = {
name: "new-instance", name: "new-instance",
status: "stopped", status: "stopped",
options: { model: "test.gguf" }, options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "test.gguf" } },
}; };
vi.mocked(instancesApi.create).mockResolvedValue(newInstance); vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
@@ -174,7 +175,8 @@ describe("InstancesContext", () => {
await waitFor(() => { await waitFor(() => {
expect(instancesApi.create).toHaveBeenCalledWith("new-instance", { expect(instancesApi.create).toHaveBeenCalledWith("new-instance", {
model: "test.gguf", backend_type: BackendType.LLAMA_SERVER,
backend_options: { model: "test.gguf" }
}); });
}); });
@@ -215,7 +217,7 @@ describe("InstancesContext", () => {
const updatedInstance: Instance = { const updatedInstance: Instance = {
name: "instance1", name: "instance1",
status: "running", status: "running",
options: { model: "updated.gguf" }, options: { backend_type: BackendType.LLAMA_SERVER, backend_options: { model: "updated.gguf" } },
}; };
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance); vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance);
@@ -230,7 +232,8 @@ describe("InstancesContext", () => {
await waitFor(() => { await waitFor(() => {
expect(instancesApi.update).toHaveBeenCalledWith("instance1", { expect(instancesApi.update).toHaveBeenCalledWith("instance1", {
model: "updated.gguf", backend_type: BackendType.LLAMA_SERVER,
backend_options: { model: "updated.gguf" }
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { type CreateInstanceOptions, getAllFieldKeys } from '@/schemas/instanceOptions' import { type CreateInstanceOptions, type BackendOptions, getAllFieldKeys, getAllBackendFieldKeys } from '@/schemas/instanceOptions'
// Only define the basic fields we want to show by default // Instance-level basic fields (not backend-specific)
export const basicFieldsConfig: Record<string, { export const basicFieldsConfig: Record<string, {
label: string label: string
description?: string description?: string
@@ -30,6 +30,19 @@ export const basicFieldsConfig: Record<string, {
label: 'On-Demand Start', label: 'On-Demand Start',
description: 'Start instance upon receiving OpenAI-compatible API request' description: 'Start instance upon receiving OpenAI-compatible API request'
}, },
backend_type: {
label: 'Backend Type',
description: 'Type of backend to use for this instance'
}
}
// Backend-specific basic fields (these go in backend_options)
export const basicBackendFieldsConfig: Record<string, {
label: string
description?: string
placeholder?: string
required?: boolean
}> = {
model: { model: {
label: 'Model Path', label: 'Model Path',
placeholder: '/path/to/model.gguf', placeholder: '/path/to/model.gguf',
@@ -56,6 +69,10 @@ export function isBasicField(key: keyof CreateInstanceOptions): boolean {
return key in basicFieldsConfig return key in basicFieldsConfig
} }
export function isBasicBackendField(key: keyof BackendOptions): boolean {
return key in basicBackendFieldsConfig
}
export function getBasicFields(): (keyof CreateInstanceOptions)[] { export function getBasicFields(): (keyof CreateInstanceOptions)[] {
return Object.keys(basicFieldsConfig) as (keyof CreateInstanceOptions)[] return Object.keys(basicFieldsConfig) as (keyof CreateInstanceOptions)[]
} }
@@ -64,5 +81,13 @@ export function getAdvancedFields(): (keyof CreateInstanceOptions)[] {
return getAllFieldKeys().filter(key => !isBasicField(key)) return getAllFieldKeys().filter(key => !isBasicField(key))
} }
export function getBasicBackendFields(): (keyof BackendOptions)[] {
return Object.keys(basicBackendFieldsConfig) as (keyof BackendOptions)[]
}
export function getAdvancedBackendFields(): (keyof BackendOptions)[] {
return getAllBackendFieldKeys().filter(key => !isBasicBackendField(key))
}
// Re-export the Zod-based functions // Re-export the Zod-based functions
export { getFieldType } from '@/schemas/instanceOptions' export { getFieldType, getBackendFieldType } from '@/schemas/instanceOptions'

View File

@@ -1,14 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
// Define the Zod schema // Define the backend options schema (previously embedded in CreateInstanceOptionsSchema)
export const CreateInstanceOptionsSchema = z.object({ export const BackendOptionsSchema = z.object({
// Restart options
auto_restart: z.boolean().optional(),
max_restarts: z.number().optional(),
restart_delay: z.number().optional(),
idle_timeout: z.number().optional(),
on_demand_start: z.boolean().optional(),
// Common params // Common params
verbose_prompt: z.boolean().optional(), verbose_prompt: z.boolean().optional(),
threads: z.number().optional(), threads: z.number().optional(),
@@ -176,22 +169,57 @@ export const CreateInstanceOptionsSchema = z.object({
fim_qwen_14b_spec: z.boolean().optional(), fim_qwen_14b_spec: z.boolean().optional(),
}) })
// Infer the TypeScript type from the schema // Define the main create instance options schema
export const CreateInstanceOptionsSchema = z.object({
// Restart options
auto_restart: z.boolean().optional(),
max_restarts: z.number().optional(),
restart_delay: z.number().optional(),
idle_timeout: z.number().optional(),
on_demand_start: z.boolean().optional(),
// Backend configuration
backend_type: z.enum(['llama_server']).optional(),
backend_options: BackendOptionsSchema.optional(),
})
// Infer the TypeScript types from the schemas
export type BackendOptions = z.infer<typeof BackendOptionsSchema>
export type CreateInstanceOptions = z.infer<typeof CreateInstanceOptionsSchema> export type CreateInstanceOptions = z.infer<typeof CreateInstanceOptionsSchema>
// Helper to get all field keys // Helper to get all field keys for CreateInstanceOptions
export function getAllFieldKeys(): (keyof CreateInstanceOptions)[] { export function getAllFieldKeys(): (keyof CreateInstanceOptions)[] {
return Object.keys(CreateInstanceOptionsSchema.shape) as (keyof CreateInstanceOptions)[] return Object.keys(CreateInstanceOptionsSchema.shape) as (keyof CreateInstanceOptions)[]
} }
// Helper to get all backend option field keys
export function getAllBackendFieldKeys(): (keyof BackendOptions)[] {
return Object.keys(BackendOptionsSchema.shape) as (keyof BackendOptions)[]
}
// Get field type from Zod schema // Get field type from Zod schema
export function getFieldType(key: keyof CreateInstanceOptions): 'text' | 'number' | 'boolean' | 'array' { export function getFieldType(key: keyof CreateInstanceOptions): 'text' | 'number' | 'boolean' | 'array' | 'object' {
const fieldSchema = CreateInstanceOptionsSchema.shape[key] const fieldSchema = CreateInstanceOptionsSchema.shape[key]
if (!fieldSchema) return 'text' if (!fieldSchema) return 'text'
// Handle ZodOptional wrapper // Handle ZodOptional wrapper
const innerSchema = fieldSchema instanceof z.ZodOptional ? fieldSchema.unwrap() : fieldSchema const innerSchema = fieldSchema instanceof z.ZodOptional ? fieldSchema.unwrap() : fieldSchema
if (innerSchema instanceof z.ZodBoolean) return 'boolean'
if (innerSchema instanceof z.ZodNumber) return 'number'
if (innerSchema instanceof z.ZodArray) return 'array'
if (innerSchema instanceof z.ZodObject) return 'object'
return 'text' // ZodString and others default to text
}
// Get field type for backend options
export function getBackendFieldType(key: keyof BackendOptions): 'text' | 'number' | 'boolean' | 'array' {
const fieldSchema = BackendOptionsSchema.shape[key]
if (!fieldSchema) return 'text'
// Handle ZodOptional wrapper
const innerSchema = fieldSchema instanceof z.ZodOptional ? fieldSchema.unwrap() : fieldSchema
if (innerSchema instanceof z.ZodBoolean) return 'boolean' if (innerSchema instanceof z.ZodBoolean) return 'boolean'
if (innerSchema instanceof z.ZodNumber) return 'number' if (innerSchema instanceof z.ZodNumber) return 'number'
if (innerSchema instanceof z.ZodArray) return 'array' if (innerSchema instanceof z.ZodArray) return 'array'

View File

@@ -2,6 +2,12 @@ import type { CreateInstanceOptions } from '@/schemas/instanceOptions'
export { type CreateInstanceOptions } from '@/schemas/instanceOptions' export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
export const BackendType = {
LLAMA_SERVER: 'llama_server'
} as const
export type BackendTypeValue = typeof BackendType[keyof typeof BackendType]
export type InstanceStatus = 'running' | 'stopped' | 'failed' export type InstanceStatus = 'running' | 'stopped' | 'failed'
export interface HealthStatus { export interface HealthStatus {