From 323056096cc86ac9fe77570e99ed15f18851ac11 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 15 Sep 2025 21:04:14 +0200 Subject: [PATCH] Implement llama-server command parsing and add UI components for command input --- pkg/backends/llamacpp/parser.go | 144 +++++++++++++++++ pkg/backends/llamacpp/parser_test.go | 169 ++++++++++++++++++++ pkg/server/handlers.go | 52 ++++++ pkg/server/routes.go | 7 + webui/package-lock.json | 11 ++ webui/package.json | 1 + webui/src/App.tsx | 3 + webui/src/components/InstanceDialog.tsx | 73 ++++++--- webui/src/components/ParseCommandDialog.tsx | 117 ++++++++++++++ webui/src/lib/api.ts | 12 ++ 10 files changed, 566 insertions(+), 23 deletions(-) create mode 100644 pkg/backends/llamacpp/parser.go create mode 100644 pkg/backends/llamacpp/parser_test.go create mode 100644 webui/src/components/ParseCommandDialog.tsx diff --git a/pkg/backends/llamacpp/parser.go b/pkg/backends/llamacpp/parser.go new file mode 100644 index 0000000..c8382bd --- /dev/null +++ b/pkg/backends/llamacpp/parser.go @@ -0,0 +1,144 @@ +package llamacpp + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// ParseLlamaCommand parses a llama-server command string into LlamaServerOptions +func ParseLlamaCommand(command string) (*LlamaServerOptions, error) { + // 1. Validate command starts with llama-server + trimmed := strings.TrimSpace(command) + if trimmed == "" { + return nil, fmt.Errorf("command cannot be empty") + } + + // Check if command starts with llama-server (case-insensitive) + lowerCommand := strings.ToLower(trimmed) + if !strings.HasPrefix(lowerCommand, "llama-server") { + return nil, fmt.Errorf("command must start with 'llama-server'") + } + + // 2. Extract arguments (everything after llama-server) + parts := strings.Fields(trimmed) + if len(parts) < 1 { + return nil, fmt.Errorf("invalid command format") + } + + args := parts[1:] // Skip binary name + + // 3. Parse arguments into map + options := make(map[string]any) + i := 0 + for i < len(args) { + arg := args[i] + + // Skip non-flag arguments + if !strings.HasPrefix(arg, "-") { + i++ + continue + } + + // Handle --flag=value format + if strings.Contains(arg, "=") { + parts := strings.SplitN(arg, "=", 2) + flag := strings.TrimPrefix(parts[0], "-") + flag = strings.TrimPrefix(flag, "-") + + // Convert flag from kebab-case to snake_case for consistency with JSON field names + flagName := strings.ReplaceAll(flag, "-", "_") + + // Convert value to appropriate type + value := parseValue(parts[1]) + + // Handle array flags by checking if flag already exists + if existingValue, exists := options[flagName]; exists { + // Convert to array if not already + switch existing := existingValue.(type) { + case []string: + options[flagName] = append(existing, parts[1]) + case string: + options[flagName] = []string{existing, parts[1]} + default: + options[flagName] = []string{fmt.Sprintf("%v", existing), parts[1]} + } + } else { + options[flagName] = value + } + i++ + continue + } + + // Handle --flag value format + flag := strings.TrimPrefix(arg, "-") + flag = strings.TrimPrefix(flag, "-") + + // Convert flag from kebab-case to snake_case for consistency with JSON field names + flagName := strings.ReplaceAll(flag, "-", "_") + + // Check if next arg is a value (not a flag) + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + value := parseValue(args[i+1]) + + // Handle array flags by checking if flag already exists + if existingValue, exists := options[flagName]; exists { + // Convert to array if not already + switch existing := existingValue.(type) { + case []string: + options[flagName] = append(existing, args[i+1]) + case string: + options[flagName] = []string{existing, args[i+1]} + default: + options[flagName] = []string{fmt.Sprintf("%v", existing), args[i+1]} + } + } else { + options[flagName] = value + } + i += 2 // Skip flag and value + } else { + // Boolean flag + options[flagName] = true + i++ + } + } + + // 4. Convert to LlamaServerOptions using existing UnmarshalJSON + jsonData, err := json.Marshal(options) + if err != nil { + return nil, fmt.Errorf("failed to marshal parsed options: %w", err) + } + + var llamaOptions LlamaServerOptions + if err := json.Unmarshal(jsonData, &llamaOptions); err != nil { + return nil, fmt.Errorf("failed to parse command options: %w", err) + } + + // 5. Return LlamaServerOptions + return &llamaOptions, nil +} + +// parseValue attempts to parse a string value into the most appropriate type +func parseValue(value string) any { + // Try to parse as boolean + if strings.ToLower(value) == "true" { + return true + } + if strings.ToLower(value) == "false" { + return false + } + + // Try to parse as integer + if intVal, err := strconv.Atoi(value); err == nil { + return intVal + } + + // Try to parse as float + if floatVal, err := strconv.ParseFloat(value, 64); err == nil { + return floatVal + } + + // Default to string + return value +} diff --git a/pkg/backends/llamacpp/parser_test.go b/pkg/backends/llamacpp/parser_test.go new file mode 100644 index 0000000..bfdaafd --- /dev/null +++ b/pkg/backends/llamacpp/parser_test.go @@ -0,0 +1,169 @@ +package llamacpp + +import ( + "testing" +) + +func TestParseLlamaCommand(t *testing.T) { + tests := []struct { + name string + command string + expectErr bool + }{ + { + name: "basic command with model", + command: "llama-server --model /path/to/model.gguf", + expectErr: false, + }, + { + name: "command with multiple flags", + command: "llama-server --model /path/to/model.gguf --gpu-layers 32 --ctx-size 4096", + expectErr: false, + }, + { + name: "command with short flags", + command: "llama-server -m /path/to/model.gguf -ngl 32 -c 4096", + expectErr: false, + }, + { + name: "command with equals format", + command: "llama-server --model=/path/to/model.gguf --gpu-layers=32", + expectErr: false, + }, + { + name: "command with boolean flags", + command: "llama-server --model /path/to/model.gguf --verbose --no-mmap", + expectErr: false, + }, + { + name: "empty command", + command: "", + expectErr: true, + }, + { + name: "invalid command without llama-server", + command: "other-command --model /path/to/model.gguf", + expectErr: true, + }, + { + name: "case insensitive command", + command: "LLAMA-SERVER --model /path/to/model.gguf", + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseLlamaCommand(tt.command) + + if tt.expectErr { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if result == nil { + t.Errorf("expected result but got nil") + return + } + }) + } +} + +func TestParseLlamaCommandSpecificValues(t *testing.T) { + // Test specific value parsing + command := "llama-server --model /test/model.gguf --gpu-layers 32 --ctx-size 4096 --verbose" + result, err := ParseLlamaCommand(command) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Model != "/test/model.gguf" { + t.Errorf("expected model '/test/model.gguf', got '%s'", result.Model) + } + + if result.GPULayers != 32 { + t.Errorf("expected gpu_layers 32, got %d", result.GPULayers) + } + + if result.CtxSize != 4096 { + t.Errorf("expected ctx_size 4096, got %d", result.CtxSize) + } + + if !result.Verbose { + t.Errorf("expected verbose to be true, got %v", result.Verbose) + } +} + +func TestParseLlamaCommandArrayFlags(t *testing.T) { + // Test array flag handling (critical for lora, override-tensor, etc.) + command := "llama-server --model test.gguf --lora adapter1.bin --lora adapter2.bin" + result, err := ParseLlamaCommand(command) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Lora) != 2 { + t.Errorf("expected 2 lora adapters, got %d", len(result.Lora)) + } + + if result.Lora[0] != "adapter1.bin" || result.Lora[1] != "adapter2.bin" { + t.Errorf("expected lora adapters [adapter1.bin, adapter2.bin], got %v", result.Lora) + } +} + +func TestParseLlamaCommandMixedFormats(t *testing.T) { + // Test mixing --flag=value and --flag value formats + command := "llama-server --model=/path/model.gguf --gpu-layers 16 --batch-size=512 --verbose" + result, err := ParseLlamaCommand(command) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Model != "/path/model.gguf" { + t.Errorf("expected model '/path/model.gguf', got '%s'", result.Model) + } + + if result.GPULayers != 16 { + t.Errorf("expected gpu_layers 16, got %d", result.GPULayers) + } + + if result.BatchSize != 512 { + t.Errorf("expected batch_size 512, got %d", result.BatchSize) + } + + if !result.Verbose { + t.Errorf("expected verbose to be true, got %v", result.Verbose) + } +} + +func TestParseLlamaCommandTypeConversion(t *testing.T) { + // Test that values are converted to appropriate types + command := "llama-server --model test.gguf --temp 0.7 --top-k 40 --no-mmap" + result, err := ParseLlamaCommand(command) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Temperature != 0.7 { + t.Errorf("expected temperature 0.7, got %f", result.Temperature) + } + + if result.TopK != 40 { + t.Errorf("expected top_k 40, got %d", result.TopK) + } + + if !result.NoMmap { + t.Errorf("expected no_mmap to be true, got %v", result.NoMmap) + } +} diff --git a/pkg/server/handlers.go b/pkg/server/handlers.go index 36918ba..81ae23a 100644 --- a/pkg/server/handlers.go +++ b/pkg/server/handlers.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "io" + "llamactl/pkg/backends" + "llamactl/pkg/backends/llamacpp" "llamactl/pkg/config" "llamactl/pkg/instance" "llamactl/pkg/manager" @@ -629,3 +631,53 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc { proxy.ServeHTTP(w, r) } } + +// ParseCommandRequest represents the request body for command parsing +type ParseCommandRequest struct { + Command string `json:"command"` +} + +// ParseLlamaCommand godoc +// @Summary Parse llama-server command +// @Description Parses a llama-server command string into instance options +// @Tags backends +// @Security ApiKeyAuth +// @Accept json +// @Produce json +// @Param request body ParseCommandRequest true "Command to parse" +// @Success 200 {object} instance.CreateInstanceOptions "Parsed options" +// @Failure 400 {string} string "Invalid request or command" +// @Failure 500 {string} string "Internal Server Error" +// @Router /backends/llama-cpp/parse-command [post] +func (h *Handler) ParseLlamaCommand() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req ParseCommandRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Command == "" { + http.Error(w, "Command cannot be empty", http.StatusBadRequest) + return + } + + // Parse the command using llamacpp parser + llamaOptions, err := llamacpp.ParseLlamaCommand(req.Command) + if err != nil { + http.Error(w, "Failed to parse command: "+err.Error(), http.StatusBadRequest) + return + } + + // Create the full CreateInstanceOptions + options := &instance.CreateInstanceOptions{ + BackendType: backends.BackendTypeLlamaCpp, + LlamaServerOptions: llamaOptions, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(options); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } + } +} diff --git a/pkg/server/routes.go b/pkg/server/routes.go index a81f585..0f2568d 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -50,6 +50,13 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Get("/devices", handler.LlamaServerListDevicesHandler()) }) + // Backend-specific endpoints + r.Route("/backends", func(r chi.Router) { + r.Route("/llama-cpp", func(r chi.Router) { + r.Post("/parse-command", handler.ParseLlamaCommand()) + }) + }) + // Instance management endpoints r.Route("/instances", func(r chi.Router) { r.Get("/", handler.ListInstances()) // List all instances diff --git a/webui/package-lock.json b/webui/package-lock.json index 3dbfb61..5eeaf2f 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -19,6 +19,7 @@ "lucide-react": "^0.525.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "zod": "^4.0.5" @@ -6750,6 +6751,16 @@ "node": ">=18" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/webui/package.json b/webui/package.json index 4b6512d..33104ea 100644 --- a/webui/package.json +++ b/webui/package.json @@ -28,6 +28,7 @@ "lucide-react": "^0.525.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", "zod": "^4.0.5" diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 8704e6b..04c8c01 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -8,6 +8,7 @@ import { type CreateInstanceOptions, type Instance } from "@/types/instance"; import { useInstances } from "@/contexts/InstancesContext"; import { useAuth } from "@/contexts/AuthContext"; import { ThemeProvider } from "@/contexts/ThemeContext"; +import { Toaster } from "sonner"; function App() { const { isAuthenticated, isLoading: authLoading } = useAuth(); @@ -85,6 +86,8 @@ function App() { open={isSystemInfoModalOpen} onOpenChange={setIsSystemInfoModalOpen} /> + + ); diff --git a/webui/src/components/InstanceDialog.tsx b/webui/src/components/InstanceDialog.tsx index dc46e31..cc4b7e4 100644 --- a/webui/src/components/InstanceDialog.tsx +++ b/webui/src/components/InstanceDialog.tsx @@ -12,9 +12,10 @@ import { } from "@/components/ui/dialog"; import { BackendType, type CreateInstanceOptions, type Instance } from "@/types/instance"; import { getBasicFields, getAdvancedFields, getBasicBackendFields, getAdvancedBackendFields } from "@/lib/zodFormUtils"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronRight, Terminal } from "lucide-react"; import ZodFormField from "@/components/ZodFormField"; import BackendFormField from "@/components/BackendFormField"; +import ParseCommandDialog from "@/components/ParseCommandDialog"; interface InstanceDialogProps { open: boolean; @@ -35,6 +36,7 @@ const InstanceDialog: React.FC = ({ const [formData, setFormData] = useState({}); const [showAdvanced, setShowAdvanced] = useState(false); const [nameError, setNameError] = useState(""); + const [showParseDialog, setShowParseDialog] = useState(false); // Get field lists dynamically from the type const basicFields = getBasicFields(); @@ -142,6 +144,14 @@ const InstanceDialog: React.FC = ({ setShowAdvanced(!showAdvanced); }; + const handleCommandParsed = (parsedOptions: CreateInstanceOptions) => { + setFormData(prev => ({ + ...prev, + ...parsedOptions, + })); + setShowParseDialog(false); + }; + // Check if auto_restart is enabled const isAutoRestartEnabled = formData.auto_restart === true; @@ -258,28 +268,39 @@ const InstanceDialog: React.FC = ({ {/* Advanced Fields Toggle */}
- +
+ + + +
{/* Advanced Fields - Automatically generated from type (excluding restart options) */} @@ -352,6 +373,12 @@ const InstanceDialog: React.FC = ({ + + ); }; diff --git a/webui/src/components/ParseCommandDialog.tsx b/webui/src/components/ParseCommandDialog.tsx new file mode 100644 index 0000000..52631ea --- /dev/null +++ b/webui/src/components/ParseCommandDialog.tsx @@ -0,0 +1,117 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { type CreateInstanceOptions } from "@/types/instance"; +import { backendsApi } from "@/lib/api"; +import { toast } from "sonner"; + +interface ParseCommandDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onParsed: (options: CreateInstanceOptions) => void; +} + +const ParseCommandDialog: React.FC = ({ + open, + onOpenChange, + onParsed, +}) => { + const [command, setCommand] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleParse = async () => { + if (!command.trim()) { + setError("Command cannot be empty"); + return; + } + + setLoading(true); + setError(null); + + try { + const options = await backendsApi.llamaCpp.parseCommand(command); + onParsed(options); + onOpenChange(false); + // Reset form + setCommand(''); + setError(null); + // Show success toast + toast.success('Command parsed successfully'); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to parse command'; + setError(errorMessage); + // Show error toast + toast.error('Failed to parse command', { + description: errorMessage + }); + } finally { + setLoading(false); + } + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + // Reset form when closing + setCommand(''); + setError(null); + } + onOpenChange(open); + }; + + return ( + + + + Parse Llama Server Command + + Paste your llama-server command to automatically populate the form fields + + + +
+
+ +