Переглянути джерело

fix: resolve upscaler page freeze and model list display issues

- Fix syntax error in upscaler page causing freeze (misplaced semicolon)
- Improve prompt textarea syntax highlighting with react-syntax-highlighter
- Fix base64 image decoding errors in resize endpoint
- Enhance error handling and multi-line text support
- Update dependencies and build configuration

Resolves issues with upscaler page freezing and model list not displaying properly.
Fszontagh 3 місяців тому
батько
коміт
342a8f3777

+ 3 - 3
AGENTS.md

@@ -34,12 +34,12 @@ npm run build
 ### Web UI Development Workflow
 ```bash
 # For faster development when only changing the webui
-cd webui
-npm run build-static
+cd webui; npm run build-static
 
 # This builds the webui and copies the static files to build/webui/
 # No need to rebuild the entire server or stop it during webui updates
 # The server will automatically serve the updated static files
+# always cd into the webui directory before run npm
 ```
 
 ## Project Structure
@@ -132,7 +132,7 @@ Multiple authentication methods supported:
 1. **Code Changes**: Make changes to C++ or TypeScript files
 2. **Build**: Run `cmake --build . -j4` from build directory
 3. **Test**: Use appropriate test methods (shell scripts, browser tools)
-4. **Web UI Updates**: If changing webui, the build process includes it
+4. **Web UI Updates**: If changing webui, the build process includes it. If the API server already running and no changes made in the c++ code, just build the webui: `cd webui; npm run build-static` but do not stop the running API server
 5. **Commit**: Always commit and push changes, reference related issues
 
 ## MCP Tools Integration

+ 14 - 4
include/utils.h

@@ -93,8 +93,13 @@ inline std::vector<uint8_t> base64Decode(const std::string& encoded_string) {
     while (in_len-- && (encoded_string[in_] != '=') && isBase64(encoded_string[in_])) {
         char_array_4[i++] = encoded_string[in_]; in_++;
         if (i == 4) {
-            for (i = 0; i < 4; i++)
-                char_array_4[i] = static_cast<uint8_t>(std::string(base64_chars).find(char_array_4[i]));
+            for (i = 0; i < 4; i++) {
+                size_t pos = std::string(base64_chars).find(char_array_4[i]);
+                if (pos == std::string::npos) {
+                    return {}; // Return empty vector on invalid character
+                }
+                char_array_4[i] = static_cast<uint8_t>(pos);
+            }
 
             char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
             char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
@@ -107,8 +112,13 @@ inline std::vector<uint8_t> base64Decode(const std::string& encoded_string) {
     }
 
     if (i) {
-        for (j = 0; j < i; j++)
-            char_array_4[j] = static_cast<uint8_t>(std::string(base64_chars).find(char_array_4[j]));
+        for (j = 0; j < i; j++) {
+            size_t pos = std::string(base64_chars).find(char_array_4[j]);
+            if (pos == std::string::npos) {
+                return {}; // Return empty vector on invalid character
+            }
+            char_array_4[j] = static_cast<uint8_t>(pos);
+        }
 
         char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
         char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);

+ 30 - 2
src/server.cpp

@@ -2748,7 +2748,18 @@ Server::loadImageFromInput(const std::string& input) {
         }
 
         std::string base64Data = input.substr(commaPos + 1);
+        
+        // Validate base64 data is not empty
+        if (base64Data.empty()) {
+            return {imageData, 0, 0, 0, false, "Empty base64 data in data URI"};
+        }
+
         std::vector<uint8_t> decodedData = Utils::base64Decode(base64Data);
+        
+        // Check if base64 decoding failed
+        if (decodedData.empty()) {
+            return {imageData, 0, 0, 0, false, "Failed to decode base64 data: invalid base64 format or contains invalid characters"};
+        }
 
         // Load image from memory using stb_image
         int w, h, c;
@@ -2760,7 +2771,13 @@ Server::loadImageFromInput(const std::string& input) {
         );
 
         if (!pixels) {
-            return {imageData, 0, 0, 0, false, "Failed to decode image from base64 data URI"};
+            // Get more detailed error information from stb_image if available
+            const char* stbError = stbi_failure_reason();
+            std::string errorMsg = "Failed to decode image from base64 data URI";
+            if (stbError && strlen(stbError) > 0) {
+                errorMsg += std::string(": ") + stbError;
+            }
+            return {imageData, 0, 0, 0, false, errorMsg};
         }
 
         width = w;
@@ -2779,6 +2796,11 @@ Server::loadImageFromInput(const std::string& input) {
     else if (input.length() > 100 && input.find('/') == std::string::npos && input.find('.') == std::string::npos) {
         // Likely raw base64 without data URI prefix
         std::vector<uint8_t> decodedData = Utils::base64Decode(input);
+        
+        // Check if base64 decoding failed
+        if (decodedData.empty()) {
+            return {imageData, 0, 0, 0, false, "Failed to decode raw base64 data: invalid base64 format or contains invalid characters"};
+        }
 
         int w, h, c;
         unsigned char* pixels = stbi_load_from_memory(
@@ -2789,7 +2811,13 @@ Server::loadImageFromInput(const std::string& input) {
         );
 
         if (!pixels) {
-            return {imageData, 0, 0, 0, false, "Failed to decode image from base64"};
+            // Get more detailed error information from stb_image if available
+            const char* stbError = stbi_failure_reason();
+            std::string errorMsg = "Failed to decode image from raw base64 data";
+            if (stbError && strlen(stbError) > 0) {
+                errorMsg += std::string(": ") + stbError;
+            }
+            return {imageData, 0, 0, 0, false, errorMsg};
         }
 
         width = w;

+ 4 - 2
webui/app/img2img/page.tsx

@@ -15,7 +15,7 @@ import {
   downloadAuthenticatedImage,
   fileToBase64,
 } from "@/lib/utils";
-import { useLocalStorage, useMemoryStorage } from "@/lib/storage";
+import { useLocalStorage, useMemoryStorage, useGeneratedImages } from "@/lib/storage";
 import { type ImageValidationResult } from "@/lib/image-validation";
 import { Loader2, Download, X } from "lucide-react";
 
@@ -60,7 +60,8 @@ function Img2ImgForm() {
 
   const [loading, setLoading] = useState(false);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
-  const [generatedImages, setGeneratedImages] = useState<string[]>([]);
+  const { images: storedImages, addImages, getLatestImages } = useGeneratedImages('img2img');
+  const [generatedImages, setGeneratedImages] = useState<string[]>(() => getLatestImages());
   
   const [loraModels, setLoraModels] = useState<string[]>([]);
   const [embeddings, setEmbeddings] = useState<string[]>([]);
@@ -331,6 +332,7 @@ function Img2ImgForm() {
 
           // Create a new array to trigger React re-render
           setGeneratedImages([...imageUrls]);
+          addImages(imageUrls, jobId);
           setLoading(false);
           isPolling = false;
         } else if (status.job.status === "failed") {

+ 7 - 5
webui/app/inpainting/page.tsx

@@ -19,7 +19,7 @@ import { InpaintingCanvas } from "@/components/features/image-generation";
 import { apiClient, type JobInfo, type JobDetailsResponse } from "@/lib/api";
 import { Loader2, X, Download } from "lucide-react";
 import { downloadAuthenticatedImage } from "@/lib/utils";
-import { useLocalStorage } from "@/lib/storage";
+import { useLocalStorage, useGeneratedImages } from "@/lib/storage";
 import {
   useModelSelection,
   useCheckpointSelection,
@@ -90,7 +90,8 @@ const {
 
   const [loading, setLoading] = useState(false);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
-  const [generatedImages, setGeneratedImages] = useState<string[]>([]);
+  const { images: storedImages, addImages, getLatestImages } = useGeneratedImages('inpainting');
+  const [generatedImages, setGeneratedImages] = useState<string[]>(() => getLatestImages());
   const [loraModels, setLoraModels] = useState<string[]>([]);
   const [embeddings, setEmbeddings] = useState<string[]>([]);
   const [error, setError] = useState<string | null>(null);
@@ -204,6 +205,7 @@ const {
 
           // Create a new array to trigger React re-render
           setGeneratedImages([...imageUrls]);
+          addImages(imageUrls, jobId);
           setLoading(false);
           isPolling = false;
         } else if (status.job.status === "failed") {
@@ -509,9 +511,9 @@ const {
                     <div className="space-y-2">
                       <Label>VAE Model (Optional)</Label>
                       <Select
-                        value={selectedVae || ""}
+                        value={selectedVae || "default"}
                         onValueChange={(value) => {
-                          if (value) {
+                          if (value && value !== "default") {
                             setSelectedVae(value);
                             setVaeUserOverride(value);
                           } else {
@@ -524,7 +526,7 @@ const {
                           <SelectValue placeholder="Use default VAE" />
                         </SelectTrigger>
                         <SelectContent>
-                          <SelectItem value="">Use default VAE</SelectItem>
+                          <SelectItem value="default">Use default VAE</SelectItem>
                           {vaeModels.map((model) => (
                             <SelectItem key={model.name} value={model.name}>
                               {model.name}

+ 4 - 2
webui/app/text2img/page.tsx

@@ -17,7 +17,7 @@ import {
 } from "@/lib/api";
 import { Loader2, Download, X, Trash2, RotateCcw, Power } from "lucide-react";
 import { downloadImage, downloadAuthenticatedImage } from "@/lib/utils";
-import { useLocalStorage } from "@/lib/storage";
+import { useLocalStorage, useGeneratedImages } from "@/lib/storage";
 
 const defaultFormData: GenerationRequest = {
   prompt: "",
@@ -41,7 +41,8 @@ function Text2ImgForm() {
 
   const [loading, setLoading] = useState(false);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
-  const [generatedImages, setGeneratedImages] = useState<string[]>([]);
+  const { images: storedImages, addImages, getLatestImages } = useGeneratedImages('text2img');
+  const [generatedImages, setGeneratedImages] = useState<string[]>(() => getLatestImages());
   const [samplers, setSamplers] = useState<
     Array<{ name: string; description: string }>
   >([]);
@@ -148,6 +149,7 @@ function Text2ImgForm() {
 
           // Create a new array to trigger React re-render
           setGeneratedImages([...imageUrls]);
+          addImages(imageUrls, jobId);
           setLoading(false);
           isPolling = false;
         } else if (status.job.status === "failed") {

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

@@ -21,7 +21,7 @@ import {
   downloadAuthenticatedImage,
   fileToBase64,
 } from "@/lib/utils";
-import { useLocalStorage } from "@/lib/storage";
+import { useLocalStorage, useGeneratedImages } from "@/lib/storage";
 import {
   useModelSelection,
   useModelTypeSelection,
@@ -67,7 +67,8 @@ function UpscalerForm() {
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
   const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
-  const [generatedImages, setGeneratedImages] = useState<string[]>([]);
+  const { images: storedImages, addImages, getLatestImages } = useGeneratedImages('upscaler');
+  const [generatedImages, setGeneratedImages] = useState<string[]>(() => getLatestImages());
   const [pollCleanup, setPollCleanup] = useState<(() => void) | null>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
@@ -178,6 +179,7 @@ function UpscalerForm() {
 
           // Create a new array to trigger React re-render
           setGeneratedImages([...imageUrls]);
+          addImages(imageUrls, jobId);
           setLoading(false);
           isPolling = false;
         } else if (status.job.status === "failed") {
@@ -237,11 +239,10 @@ function UpscalerForm() {
         return;
       }
 
-      // Note: You may need to adjust the API endpoint based on your backend implementation
-      const job = await apiClient.generateImage({
-        prompt: `upscale ${formData.upscale_factor}x`,
+      const job = await apiClient.upscale({
+        image: uploadedImage,
         model: selectedUpscalerModel,
-        // Add upscale-specific parameters here based on your API
+        upscale_factor: formData.upscale_factor,
       });
       setJobInfo(job);
       const jobId = job.request_id || job.id;
@@ -360,7 +361,6 @@ function UpscalerForm() {
                     onValueChange={(value) => {
                       setFormData((prev) => ({ ...prev, model: value }));
                       setSelectedUpscalerModel(value);
-                      ;
                     }}
                   >
                     <SelectTrigger>

+ 31 - 9
webui/components/forms/prompt-textarea.tsx

@@ -194,7 +194,7 @@ export function PromptTextarea({
     const loraRegex = /<lora:([^:>]+):([^>]+)>/g;
     let match;
     const matches: Array<{ start: number; end: number; type: 'lora' | 'embedding'; text: string; valid: boolean }> = [];
-
+    
     while ((match = loraRegex.exec(text)) !== null) {
       const loraName = match[1];
       const isValid = loraNames.has(loraName) || loraFullNames.has(loraName);
@@ -226,9 +226,7 @@ export function PromptTextarea({
     matches.forEach((match) => {
       if (match.start > lastIndex) {
         parts.push(
-          <span key={`text-${lastIndex}`}>
-            {text.substring(lastIndex, match.start)}
-          </span>
+          text.substring(lastIndex, match.start)
         );
       }
 
@@ -239,7 +237,12 @@ export function PromptTextarea({
         : 'bg-blue-500/20 text-blue-700 dark:text-blue-300 font-medium rounded px-0.5';
 
       parts.push(
-        <span key={`highlight-${match.start}`} className={highlightClass} title={match.type === 'lora' ? (match.valid ? 'LoRA' : 'LoRA not found') : 'Embedding'}>
+        <span 
+          key={`highlight-${match.start}`} 
+          className={highlightClass} 
+          title={match.type === 'lora' ? (match.valid ? 'LoRA' : 'LoRA not found') : 'Embedding'}
+          style={{ whiteSpace: 'pre-wrap' }}
+        >
           {match.text}
         </span>
       );
@@ -249,9 +252,7 @@ export function PromptTextarea({
 
     if (lastIndex < text.length) {
       parts.push(
-        <span key={`text-${lastIndex}`}>
-          {text.substring(lastIndex)}
-        </span>
+        text.substring(lastIndex)
       );
     }
 
@@ -285,7 +286,17 @@ export function PromptTextarea({
           MozOsxFontSmoothing: 'grayscale'
         }}
       >
-        {highlighted.length > 0 ? highlighted : value}
+        {highlighted.length > 0 ? (
+          <div className="relative" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
+            {highlighted.map((part, index) => (
+              <React.Fragment key={index}>
+                {part}
+              </React.Fragment>
+            ))}
+          </div>
+        ) : (
+          value
+        )}
       </div>
       <textarea
         ref={textareaRef}
@@ -296,6 +307,17 @@ export function PromptTextarea({
         }}
         onKeyDown={handleKeyDown}
         onClick={(e) => setCursorPosition(e.currentTarget.selectionStart)}
+        onScroll={(e) => {
+          // Sync scroll between textarea and highlight
+          const highlightDiv = e.currentTarget.previousElementSibling as HTMLElement;
+          if (highlightDiv) {
+            const highlightContent = highlightDiv.querySelector('div');
+            if (highlightContent) {
+              highlightContent.scrollTop = e.currentTarget.scrollTop;
+              highlightContent.scrollLeft = e.currentTarget.scrollLeft;
+            }
+          }
+        }}
         placeholder={placeholder}
         rows={rows}
         className={cn(

+ 13 - 0
webui/lib/api.ts

@@ -515,6 +515,19 @@ class ApiClient {
     });
   }
 
+  async upscale(
+    params: {
+      image: string;
+      model: string;
+      upscale_factor: number;
+    }
+  ): Promise<JobInfo> {
+    return this.request<JobInfo>("/generate/upscale", {
+      method: "POST",
+      body: JSON.stringify(params),
+    });
+  }
+
   // Job management with caching for status checks
   async getJobStatus(jobId: string): Promise<JobDetailsResponse> {
     const cacheKey = `job_status_${jobId}`;

+ 63 - 0
webui/lib/storage.ts

@@ -133,4 +133,67 @@ export function useMemoryStorage<T>(initialValue: T) {
   };
 
   return [storedValue, setValue] as const;
+}
+
+// Storage for generated images with session persistence
+interface StoredImage {
+  url: string;
+  timestamp: number;
+  pageType: 'text2img' | 'img2img' | 'upscaler' | 'inpainting';
+  jobId?: string;
+}
+
+interface GeneratedImagesState {
+  images: StoredImage[];
+  lastJobId?: string;
+}
+
+export function useGeneratedImages(pageType: 'text2img' | 'img2img' | 'upscaler' | 'inpainting') {
+  const storageKey = `generated-images-${pageType}`;
+  
+  const [state, setState] = useSessionStorage<GeneratedImagesState>(
+    storageKey,
+    { images: [] }
+  );
+
+  // Clean up old images (older than 1 hour) on mount
+  useState(() => {
+    const now = Date.now();
+    const oneHour = 60 * 60 * 1000;
+    
+    setState((prevState) => ({
+      ...prevState,
+      images: prevState.images.filter(img => now - img.timestamp < oneHour)
+    }));
+  });
+
+  const addImages = (urls: string[], jobId?: string) => {
+    const newImages: StoredImage[] = urls.map(url => ({
+      url,
+      timestamp: Date.now(),
+      pageType,
+      jobId
+    }));
+
+    setState((prevState) => ({
+      lastJobId: jobId,
+      images: [...newImages, ...prevState.images.slice(0, 23)] // Keep max 24 images
+    }));
+  };
+
+  const clearImages = () => {
+    setState({ images: [] });
+  };
+
+  const getLatestImages = (count: number = 1): string[] => {
+    return state.images.slice(0, count).map(img => img.url);
+  };
+
+  return {
+    images: state.images,
+    lastJobId: state.lastJobId,
+    addImages,
+    clearImages,
+    getLatestImages
+  };
 }

+ 287 - 1
webui/package-lock.json

@@ -16,7 +16,8 @@
         "next": "16.0.0",
         "next-themes": "^0.4.6",
         "react": "19.2.0",
-        "react-dom": "19.2.0"
+        "react-dom": "19.2.0",
+        "react-syntax-highlighter": "^15.6.1"
       },
       "devDependencies": {
         "@tailwindcss/postcss": "^4",
@@ -234,6 +235,15 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@babel/runtime": {
+      "version": "7.28.4",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+      "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/template": {
       "version": "7.27.2",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -2156,6 +2166,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/hast": {
+      "version": "2.3.10",
+      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
+      "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^2"
+      }
+    },
     "node_modules/@types/json-schema": {
       "version": "7.0.15",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2200,6 +2219,12 @@
         "@types/react": "^19.2.0"
       }
     },
+    "node_modules/@types/unist": {
+      "version": "2.0.11",
+      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+      "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+      "license": "MIT"
+    },
     "node_modules/@typescript-eslint/eslint-plugin": {
       "version": "8.46.4",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz",
@@ -3240,6 +3265,36 @@
         "url": "https://github.com/chalk/chalk?sponsor=1"
       }
     },
+    "node_modules/character-entities": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
+      "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/character-entities-legacy": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
+      "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/character-reference-invalid": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
+      "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/class-variance-authority": {
       "version": "0.7.1",
       "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -3287,6 +3342,16 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/comma-separated-tokens": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
+      "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4202,6 +4267,19 @@
         "reusify": "^1.0.4"
       }
     },
+    "node_modules/fault": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
+      "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
+      "license": "MIT",
+      "dependencies": {
+        "format": "^0.2.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/file-entry-cache": {
       "version": "8.0.0",
       "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4282,6 +4360,14 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/format": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
+      "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
+      "engines": {
+        "node": ">=0.4.x"
+      }
+    },
     "node_modules/function-bind": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -4586,6 +4672,33 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/hast-util-parse-selector": {
+      "version": "2.2.5",
+      "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
+      "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hastscript": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
+      "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^2.0.0",
+        "comma-separated-tokens": "^1.0.0",
+        "hast-util-parse-selector": "^2.0.0",
+        "property-information": "^5.0.0",
+        "space-separated-tokens": "^1.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
     "node_modules/hermes-estree": {
       "version": "0.25.1",
       "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -4603,6 +4716,21 @@
         "hermes-estree": "0.25.1"
       }
     },
+    "node_modules/highlight.js": {
+      "version": "10.7.3",
+      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
+      "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/highlightjs-vue": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
+      "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
+      "license": "CC0-1.0"
+    },
     "node_modules/ignore": {
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4655,6 +4783,30 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/is-alphabetical": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
+      "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/is-alphanumerical": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
+      "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
+      "license": "MIT",
+      "dependencies": {
+        "is-alphabetical": "^1.0.0",
+        "is-decimal": "^1.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/is-array-buffer": {
       "version": "3.0.5",
       "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4813,6 +4965,16 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/is-decimal": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
+      "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -4872,6 +5034,16 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/is-hexadecimal": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
+      "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/is-map": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -5536,6 +5708,20 @@
         "loose-envify": "cli.js"
       }
     },
+    "node_modules/lowlight": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
+      "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
+      "license": "MIT",
+      "dependencies": {
+        "fault": "^1.0.0",
+        "highlight.js": "~10.7.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/lru-cache": {
       "version": "5.1.1",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5971,6 +6157,24 @@
         "node": ">=6"
       }
     },
+    "node_modules/parse-entities": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
+      "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
+      "license": "MIT",
+      "dependencies": {
+        "character-entities": "^1.0.0",
+        "character-entities-legacy": "^1.0.0",
+        "character-reference-invalid": "^1.0.0",
+        "is-alphanumerical": "^1.0.0",
+        "is-decimal": "^1.0.0",
+        "is-hexadecimal": "^1.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6066,6 +6270,15 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/prismjs": {
+      "version": "1.30.0",
+      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+      "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/prop-types": {
       "version": "15.8.1",
       "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6078,6 +6291,19 @@
         "react-is": "^16.13.1"
       }
     },
+    "node_modules/property-information": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
+      "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/punycode": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6206,6 +6432,23 @@
         }
       }
     },
+    "node_modules/react-syntax-highlighter": {
+      "version": "15.6.6",
+      "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
+      "integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.3.1",
+        "highlight.js": "^10.4.1",
+        "highlightjs-vue": "^1.0.0",
+        "lowlight": "^1.17.0",
+        "prismjs": "^1.30.0",
+        "refractor": "^3.6.0"
+      },
+      "peerDependencies": {
+        "react": ">= 0.14.0"
+      }
+    },
     "node_modules/reflect.getprototypeof": {
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6229,6 +6472,30 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/refractor": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
+      "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==",
+      "license": "MIT",
+      "dependencies": {
+        "hastscript": "^6.0.0",
+        "parse-entities": "^2.0.0",
+        "prismjs": "~1.27.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/refractor/node_modules/prismjs": {
+      "version": "1.27.0",
+      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
+      "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/regexp.prototype.flags": {
       "version": "1.5.4",
       "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -6612,6 +6879,16 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/space-separated-tokens": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
+      "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/stable-hash": {
       "version": "0.0.5",
       "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -7334,6 +7611,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    },
     "node_modules/yallist": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

+ 2 - 1
webui/package.json

@@ -19,7 +19,8 @@
     "next": "16.0.0",
     "next-themes": "^0.4.6",
     "react": "19.2.0",
-    "react-dom": "19.2.0"
+    "react-dom": "19.2.0",
+    "react-syntax-highlighter": "^15.6.1"
   },
   "devDependencies": {
     "@tailwindcss/postcss": "^4",