Ver Fonte

Add WebUI improvements and server restart endpoint

Major changes:
- Add server restart endpoint (POST /api/system/restart)
- Refactor layout system: Sidebar now renders as grid column instead of fixed position
- Fix PromptTextarea syntax highlighting with transparent text overlay
- Fix autocomplete suggestion replacement logic
- Add utility buttons: Clear Prompts, Reset to Defaults, Restart Server
- Update all pages to use new AppLayout component
- Improve sidebar component architecture and documentation

WebUI fixes:
- Fix sidebar z-index conflicts with autocomplete dropdowns
- Fix textarea caret visibility with proper color styling
- Fix suggestion replacement when editing existing LoRA tags
- Add pointer-events handling for proper click-through behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh há 3 meses atrás
pai
commit
2d9f62859c

+ 1 - 0
CLAUDE.md

@@ -644,3 +644,4 @@ The `VersionChecker` component:
 **Issue**: Assets returning 304 even after version change
 - **Cause**: Server not reading new version.json
 - **Solution**: Restart server to reload version file
+- when starting the server, use this parameters:  --models-dir /data/SD_MODELS --port 8082 --host 0.0.0.0 --ui-dir ./webui  so the webui will be usable

+ 5 - 0
include/server.h

@@ -246,6 +246,11 @@ private:
      */
     void handleSystem(const httplib::Request& req, httplib::Response& res);
 
+    /**
+     * @brief System restart endpoint handler
+     */
+    void handleSystemRestart(const httplib::Request& req, httplib::Response& res);
+
     /**
      * @brief Send JSON response with proper headers
      */

+ 27 - 0
src/server.cpp

@@ -203,6 +203,10 @@ void Server::registerEndpoints() {
         handleSystem(req, res);
     });
 
+    m_httpServer->Post("/api/system/restart", [this](const httplib::Request& req, httplib::Response& res) {
+        handleSystemRestart(req, res);
+    });
+
     // Models list endpoint
     m_httpServer->Get("/api/models", [this](const httplib::Request& req, httplib::Response& res) {
         handleModelsList(req, res);
@@ -2381,6 +2385,29 @@ void Server::handleSystem(const httplib::Request& req, httplib::Response& res) {
     }
 }
 
+void Server::handleSystemRestart(const httplib::Request& req, httplib::Response& res) {
+    try {
+        json response = {
+            {"message", "Server restart initiated. The server will shut down gracefully and exit. Please use a process manager to automatically restart it."},
+            {"status", "restarting"}
+        };
+
+        sendJsonResponse(res, response);
+
+        // Schedule server stop after response is sent
+        // Using a separate thread to allow the response to be sent first
+        std::thread([this]() {
+            std::this_thread::sleep_for(std::chrono::seconds(1));
+            this->stop();
+            // Exit with code 42 to signal restart intent to process manager
+            std::exit(42);
+        }).detach();
+
+    } catch (const std::exception& e) {
+        sendErrorResponse(res, std::string("Restart failed: ") + e.what(), 500);
+    }
+}
+
 // Helper methods for model management
 json Server::getModelCapabilities(ModelType type) {
     json capabilities = json::object();

+ 15 - 0
webui/app/globals.css

@@ -52,3 +52,18 @@ body {
   color: var(--color-foreground);
   font-feature-settings: "rlig" 1, "calt" 1;
 }
+
+/* Prompt textarea with syntax highlighting */
+.prompt-textarea-input {
+  caret-color: var(--color-foreground) !important;
+}
+
+.prompt-textarea-input::selection {
+  background-color: rgba(59, 130, 246, 0.3);
+  color: transparent;
+}
+
+.prompt-textarea-input::-moz-selection {
+  background-color: rgba(59, 130, 246, 0.3);
+  color: transparent;
+}

+ 3 - 3
webui/app/img2img/page.tsx

@@ -2,7 +2,7 @@
 
 import { useState, useRef, useEffect } from 'react';
 import { Header } from '@/components/header';
-import { MainLayout } from '@/components/main-layout';
+import { AppLayout } from '@/components/layout';
 import { Button } from '@/components/ui/button';
 import { Input } from '@/components/ui/input';
 import { Textarea } from '@/components/ui/textarea';
@@ -170,7 +170,7 @@ export default function Img2ImgPage() {
   };
 
   return (
-    <MainLayout>
+    <AppLayout>
       <Header title="Image to Image" description="Transform images with AI using text prompts" />
       <div className="container mx-auto p-6">
         <div className="grid gap-6 lg:grid-cols-2">
@@ -389,6 +389,6 @@ export default function Img2ImgPage() {
           </Card>
         </div>
       </div>
-    </MainLayout>
+    </AppLayout>
   );
 }

+ 3 - 3
webui/app/page.tsx

@@ -3,7 +3,7 @@
 import { useEffect, useState } from 'react';
 import Link from 'next/link';
 import { Header } from '@/components/header';
-import { MainLayout } from '@/components/main-layout';
+import { AppLayout } from '@/components/layout';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
 import { Button } from '@/components/ui/button';
 import { apiClient } from '@/lib/api';
@@ -75,7 +75,7 @@ export default function HomePage() {
   };
 
   return (
-    <MainLayout>
+    <AppLayout>
       <Header title="Stable Diffusion REST" description="Modern web interface for AI image generation" />
       <div className="container mx-auto p-6">
         <div className="space-y-8">
@@ -222,6 +222,6 @@ export default function HomePage() {
           </Card>
         </div>
       </div>
-    </MainLayout>
+    </AppLayout>
   );
 }

+ 70 - 5
webui/app/text2img/page.tsx

@@ -2,7 +2,7 @@
 
 import { useState, useEffect } from 'react';
 import { Header } from '@/components/header';
-import { MainLayout } from '@/components/main-layout';
+import { AppLayout } from '@/components/layout';
 import { Button } from '@/components/ui/button';
 import { Input } from '@/components/ui/input';
 import { Textarea } from '@/components/ui/textarea';
@@ -10,7 +10,7 @@ import { PromptTextarea } from '@/components/prompt-textarea';
 import { Label } from '@/components/ui/label';
 import { Card, CardContent } from '@/components/ui/card';
 import { apiClient, type GenerationRequest, type JobInfo, type ModelInfo } from '@/lib/api';
-import { Loader2, Download, X } from 'lucide-react';
+import { Loader2, Download, X, Trash2, RotateCcw, Power } from 'lucide-react';
 import { downloadImage } from '@/lib/utils';
 import { useLocalStorage } from '@/lib/hooks';
 
@@ -72,7 +72,7 @@ export default function Text2ImgPage() {
     const { name, value } = e.target;
     setFormData((prev) => ({
       ...prev,
-      [name]: name === 'prompt' || name === 'negative_prompt' || name === 'seed' || name === 'sampling_method'
+      [name]: name === 'prompt' || name === 'negative_prompt' || name === 'seed' || name === 'sampling_method' || name === 'scheduler'
         ? value
         : Number(value),
     }));
@@ -148,8 +148,34 @@ export default function Text2ImgPage() {
     }
   };
 
+  const handleClearPrompts = () => {
+    setFormData({ ...formData, prompt: '', negative_prompt: '' });
+  };
+
+  const handleResetToDefaults = () => {
+    setFormData(defaultFormData);
+    setSelectedVae('');
+  };
+
+  const handleServerRestart = async () => {
+    if (!confirm('Are you sure you want to restart the server? This will cancel all running jobs.')) {
+      return;
+    }
+    try {
+      setLoading(true);
+      await apiClient.restartServer();
+      setError('Server restart initiated. Please wait...');
+      setTimeout(() => {
+        window.location.reload();
+      }, 3000);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to restart server');
+      setLoading(false);
+    }
+  };
+
   return (
-    <MainLayout>
+    <AppLayout>
       <Header title="Text to Image" description="Generate images from text prompts" />
       <div className="container mx-auto p-6">
         <div className="grid gap-6 lg:grid-cols-2">
@@ -179,9 +205,48 @@ export default function Text2ImgPage() {
                     onChange={(value) => setFormData({ ...formData, negative_prompt: value })}
                     placeholder="blurry, low quality, distorted..."
                     rows={2}
+                    loras={loraModels}
+                    embeddings={embeddings}
                   />
                 </div>
 
+                {/* Utility Buttons */}
+                <div className="flex gap-2">
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    onClick={handleClearPrompts}
+                    disabled={loading}
+                    title="Clear both prompts"
+                  >
+                    <Trash2 className="h-4 w-4 mr-1" />
+                    Clear Prompts
+                  </Button>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    onClick={handleResetToDefaults}
+                    disabled={loading}
+                    title="Reset all fields to defaults"
+                  >
+                    <RotateCcw className="h-4 w-4 mr-1" />
+                    Reset to Defaults
+                  </Button>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    onClick={handleServerRestart}
+                    disabled={loading}
+                    title="Restart the backend server"
+                  >
+                    <Power className="h-4 w-4 mr-1" />
+                    Restart Server
+                  </Button>
+                </div>
+
                 <div className="grid grid-cols-2 gap-4">
                   <div className="space-y-2">
                     <Label htmlFor="width">Width</Label>
@@ -397,6 +462,6 @@ export default function Text2ImgPage() {
           </Card>
         </div>
       </div>
-    </MainLayout>
+    </AppLayout>
   );
 }

+ 3 - 3
webui/app/upscaler/page.tsx

@@ -2,7 +2,7 @@
 
 import { useState, useRef, useEffect } from 'react';
 import { Header } from '@/components/header';
-import { MainLayout } from '@/components/main-layout';
+import { AppLayout } from '@/components/layout';
 import { Button } from '@/components/ui/button';
 import { Input } from '@/components/ui/input';
 import { Label } from '@/components/ui/label';
@@ -164,7 +164,7 @@ export default function UpscalerPage() {
   };
 
   return (
-    <MainLayout>
+    <AppLayout>
       <Header title="Upscaler" description="Enhance and upscale your images with AI" />
       <div className="container mx-auto p-6">
         <div className="grid gap-6 lg:grid-cols-2">
@@ -329,6 +329,6 @@ export default function UpscalerPage() {
           </Card>
         </div>
       </div>
-    </MainLayout>
+    </AppLayout>
   );
 }

+ 2 - 2
webui/components/main-layout.tsx

@@ -8,9 +8,9 @@ interface MainLayoutProps {
 
 export function MainLayout({ children }: MainLayoutProps) {
   return (
-    <div className="flex min-h-screen">
+    <div className="min-h-screen">
       <Sidebar />
-      <main className="flex-1 pl-64 pb-12">
+      <main className="ml-64 pb-12 overflow-x-hidden" style={{ width: 'calc(100% - 16rem)', pointerEvents: 'auto' }}>
         {children}
       </main>
       <ModelStatusBar />

+ 46 - 11
webui/components/prompt-textarea.tsx

@@ -35,17 +35,27 @@ export function PromptTextarea({
   const [selectedIndex, setSelectedIndex] = useState(0);
   const [cursorPosition, setCursorPosition] = useState(0);
   const suggestionsRef = useRef<HTMLDivElement>(null);
+  const justInsertedRef = useRef(false);
 
   useEffect(() => {
     highlightSyntax(value);
+
+    // Skip suggestions if we just inserted one
+    if (justInsertedRef.current) {
+      justInsertedRef.current = false;
+      return;
+    }
+
     updateSuggestions(value, cursorPosition);
   }, [value, loras, embeddings, cursorPosition]);
 
   const updateSuggestions = (text: string, position: number) => {
     // Get text before cursor
     const textBeforeCursor = text.substring(0, position);
+    const textAfterCursor = text.substring(position);
 
-    // Check if we're typing a LoRA
+    // Check if we're typing a LoRA name (but not in the weight part)
+    // Match: <lora:name| but NOT <lora:name:weight|
     const loraMatch = textBeforeCursor.match(/<lora:([^:>]*)$/);
     if (loraMatch) {
       const searchTerm = loraMatch[1].toLowerCase();
@@ -105,7 +115,7 @@ export function PromptTextarea({
 
     const position = textareaRef.current.selectionStart;
     const textBefore = value.substring(0, position);
-    const textAfter = value.substring(position);
+    let textAfter = value.substring(position);
 
     let newText = '';
     let newPosition = position;
@@ -115,6 +125,15 @@ export function PromptTextarea({
       const loraMatch = textBefore.match(/<lora:([^:>]*)$/);
       if (loraMatch) {
         const beforeLora = textBefore.substring(0, textBefore.length - loraMatch[0].length);
+
+        // Check if we're editing an existing tag
+        // Remove everything until the closing > (rest of name, weight, closing bracket)
+        const afterLoraMatch = textAfter.match(/^[^<>]*>/);
+        if (afterLoraMatch) {
+          // Remove the old tag remainder
+          textAfter = textAfter.substring(afterLoraMatch[0].length);
+        }
+
         const loraText = `<lora:${suggestion.displayText}:0.8>`;
         newText = beforeLora + loraText + textAfter;
         newPosition = beforeLora.length + loraText.length;
@@ -128,6 +147,9 @@ export function PromptTextarea({
       newPosition = beforeWord.length + suggestion.displayText.length;
     }
 
+    // Mark that we just inserted a suggestion to prevent retriggering
+    justInsertedRef.current = true;
+
     onChange(newText);
     setShowSuggestions(false);
 
@@ -254,7 +276,17 @@ export function PromptTextarea({
   };
 
   return (
-    <div className="relative w-full pointer-events-none">
+    <div className="relative w-full">
+      <div
+        className="absolute inset-0 pointer-events-none px-3 py-2 text-sm font-mono whitespace-pre-wrap break-words overflow-hidden rounded-md text-foreground"
+        style={{
+          zIndex: 1,
+          WebkitFontSmoothing: 'antialiased',
+          MozOsxFontSmoothing: 'grayscale'
+        }}
+      >
+        {highlighted.length > 0 ? highlighted : value}
+      </div>
       <textarea
         ref={textareaRef}
         value={value}
@@ -267,17 +299,20 @@ export function PromptTextarea({
         placeholder={placeholder}
         rows={rows}
         className={cn(
-          'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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 font-mono resize-none relative pointer-events-auto',
+          'prompt-textarea-input flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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 font-mono resize-none relative',
           className
         )}
-        style={{ background: 'transparent' }}
+        style={{
+          background: 'transparent',
+          color: 'transparent',
+          WebkitTextFillColor: 'transparent',
+          caretColor: 'hsl(var(--foreground))',
+          zIndex: 2,
+          textShadow: 'none'
+        } as React.CSSProperties & {
+          caretColor: string;
+        }}
       />
-      <div
-        className="absolute inset-0 pointer-events-none px-3 py-2 text-sm font-mono whitespace-pre-wrap break-words overflow-hidden rounded-md -z-10"
-        style={{ color: 'transparent' }}
-      >
-        {highlighted.length > 0 ? highlighted : value}
-      </div>
 
       {/* Autocomplete Suggestions */}
       {showSuggestions && suggestions.length > 0 && (

+ 54 - 40
webui/components/sidebar.tsx

@@ -5,7 +5,23 @@ import { usePathname } from 'next/navigation';
 import { Image, ImagePlus, Sparkles, Settings, Activity } from 'lucide-react';
 import { cn } from '@/lib/utils';
 
-const navigation = [
+/**
+ * Sidebar - Navigation component
+ *
+ * Modern architecture:
+ * - Simple, focused component with single responsibility
+ * - No complex positioning logic - handled by parent grid layout
+ * - TypeScript interfaces for navigation items
+ * - Proper semantic HTML
+ */
+
+interface NavigationItem {
+  name: string;
+  href: string;
+  icon: React.ComponentType<{ className?: string }>;
+}
+
+const navigation: NavigationItem[] = [
   { name: 'Text to Image', href: '/text2img', icon: ImagePlus },
   { name: 'Image to Image', href: '/img2img', icon: Image },
   { name: 'Upscaler', href: '/upscaler', icon: Sparkles },
@@ -17,45 +33,43 @@ export function Sidebar() {
   const pathname = usePathname();
 
   return (
-    <aside className="fixed left-0 top-0 z-50 h-screen w-64 border-r border-border bg-card">
-      <div className="flex h-full flex-col gap-2">
-        {/* Logo */}
-        <div className="flex h-16 items-center border-b border-border px-6">
-          <Link href="/" className="flex items-center gap-2 font-semibold">
-            <ImagePlus className="h-6 w-6" />
-            <span>SD REST UI</span>
-          </Link>
-        </div>
-
-        {/* Navigation */}
-        <nav className="flex-1 space-y-1 px-3 py-4">
-          {navigation.map((item) => {
-            const isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
-            return (
-              <Link
-                key={item.name}
-                href={item.href}
-                className={cn(
-                  'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
-                  isActive
-                    ? 'bg-primary text-primary-foreground'
-                    : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
-                )}
-              >
-                <item.icon className="h-5 w-5" />
-                {item.name}
-              </Link>
-            );
-          })}
-        </nav>
-
-        {/* Footer */}
-        <div className="border-t border-border p-4">
-          <p className="text-xs text-muted-foreground text-center">
-            Stable Diffusion REST API
-          </p>
-        </div>
+    <div className="flex h-full flex-col gap-2">
+      {/* Logo */}
+      <div className="flex h-16 items-center border-b border-border px-6">
+        <Link href="/" className="flex items-center gap-2 font-semibold">
+          <ImagePlus className="h-6 w-6" />
+          <span>SD REST UI</span>
+        </Link>
+      </div>
+
+      {/* Navigation */}
+      <nav className="flex-1 space-y-1 px-3 py-4">
+        {navigation.map((item) => {
+          const isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
+          return (
+            <Link
+              key={item.name}
+              href={item.href}
+              className={cn(
+                'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
+                isActive
+                  ? 'bg-primary text-primary-foreground'
+                  : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
+              )}
+            >
+              <item.icon className="h-5 w-5" />
+              {item.name}
+            </Link>
+          );
+        })}
+      </nav>
+
+      {/* Footer */}
+      <div className="border-t border-border p-4">
+        <p className="text-xs text-muted-foreground text-center">
+          Stable Diffusion REST API
+        </p>
       </div>
-    </aside>
+    </div>
   );
 }

+ 7 - 0
webui/lib/api.ts

@@ -230,6 +230,13 @@ class ApiClient {
     return this.request<any>('/system');
   }
 
+  async restartServer(): Promise<{ message: string }> {
+    return this.request<{ message: string }>('/system/restart', {
+      method: 'POST',
+      body: JSON.stringify({}),
+    });
+  }
+
   // Configuration endpoints
   async getSamplers(): Promise<Array<{ name: string; description: string; recommended_steps: number }>> {
     const response = await this.request<{ samplers: Array<{ name: string; description: string; recommended_steps: number }> }>('/samplers');