mirror of
				https://github.com/ggml-org/llama.cpp.git
				synced 2025-10-31 08:51:55 +00:00 
			
		
		
		
	server : webui : Improve Chat Input with Auto-Sizing Textarea (#12785)
* Update ChatScreen.tsx * useAutosizeTextarea.ts useAutosizeTextarea to encapsulate the logic. * Implement responsive auto-sizing chat textarea Replaces the manual textarea resizing with an automatic height adjustment based on content. - `useChatTextarea` hook to manage textarea state and auto-sizing logic via refs, preserving the optimization - Textarea now grows vertically up to a maximum height (`lg:max-h-48`) on large screens (lg breakpoint and up). - Disables auto-sizing and enables manual vertical resizing (`resize-vertical`) on smaller screens for better mobile usability. - Aligns the "Send" button to the bottom of the textarea (`items-end`) for consistent positioning during resize. * -update compressed index.html.gz after npm run build -refactor: replace OptimizedTextareaValue with AutosizeTextareaApi in VSCode context hook * chore: normalize line endings to LF refactor: AutosizeTextareaApi -> chatTextareaApi * refactor: Rename interface to PascalCase --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
		
										
											Binary file not shown.
										
									
								
							| @@ -1,4 +1,4 @@ | |||||||
| import { useEffect, useMemo, useRef, useState } from 'react'; | import { useEffect, useMemo, useState } from 'react'; | ||||||
| import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context'; | import { CallbackGeneratedChunk, useAppContext } from '../utils/app.context'; | ||||||
| import ChatMessage from './ChatMessage'; | import ChatMessage from './ChatMessage'; | ||||||
| import { CanvasType, Message, PendingMessage } from '../utils/types'; | import { CanvasType, Message, PendingMessage } from '../utils/types'; | ||||||
| @@ -6,6 +6,7 @@ import { classNames, cleanCurrentUrl, throttle } from '../utils/misc'; | |||||||
| import CanvasPyInterpreter from './CanvasPyInterpreter'; | import CanvasPyInterpreter from './CanvasPyInterpreter'; | ||||||
| import StorageUtils from '../utils/storage'; | import StorageUtils from '../utils/storage'; | ||||||
| import { useVSCodeContext } from '../utils/llama-vscode'; | import { useVSCodeContext } from '../utils/llama-vscode'; | ||||||
|  | import { useChatTextarea, ChatTextareaApi } from './useChatTextarea.ts'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * A message display is a message node with additional information for rendering. |  * A message display is a message node with additional information for rendering. | ||||||
| @@ -99,7 +100,8 @@ export default function ChatScreen() { | |||||||
|     canvasData, |     canvasData, | ||||||
|     replaceMessageAndGenerate, |     replaceMessageAndGenerate, | ||||||
|   } = useAppContext(); |   } = useAppContext(); | ||||||
|   const textarea = useOptimizedTextarea(prefilledMsg.content()); |  | ||||||
|  |   const textarea: ChatTextareaApi = useChatTextarea(prefilledMsg.content()); | ||||||
|  |  | ||||||
|   const { extraContext, clearExtraContext } = useVSCodeContext(textarea); |   const { extraContext, clearExtraContext } = useVSCodeContext(textarea); | ||||||
|   // TODO: improve this when we have "upload file" feature |   // TODO: improve this when we have "upload file" feature | ||||||
| @@ -248,14 +250,16 @@ export default function ChatScreen() { | |||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         {/* chat input */} |         {/* chat input */} | ||||||
|         <div className="flex flex-row items-center pt-8 pb-6 sticky bottom-0 bg-base-100"> |         <div className="flex flex-row items-end pt-8 pb-6 sticky bottom-0 bg-base-100"> | ||||||
|           <textarea |           <textarea | ||||||
|             className="textarea textarea-bordered w-full" |             // Default (mobile): Enable vertical resize, overflow auto for scrolling if needed | ||||||
|  |             // Large screens (lg:): Disable manual resize, apply max-height for autosize limit | ||||||
|  |             className="textarea textarea-bordered w-full resize-vertical lg:resize-none lg:max-h-48 lg:overflow-y-auto" // Adjust lg:max-h-48 as needed (e.g., lg:max-h-60) | ||||||
|             placeholder="Type a message (Shift+Enter to add a new line)" |             placeholder="Type a message (Shift+Enter to add a new line)" | ||||||
|             ref={textarea.ref} |             ref={textarea.ref} | ||||||
|  |             onInput={textarea.onInput} // Hook's input handler (will only resize height on lg+ screens) | ||||||
|             onKeyDown={(e) => { |             onKeyDown={(e) => { | ||||||
|               if (e.nativeEvent.isComposing || e.keyCode === 229) return; |               if (e.nativeEvent.isComposing || e.keyCode === 229) return; | ||||||
|               if (e.key === 'Enter' && e.shiftKey) return; |  | ||||||
|               if (e.key === 'Enter' && !e.shiftKey) { |               if (e.key === 'Enter' && !e.shiftKey) { | ||||||
|                 e.preventDefault(); |                 e.preventDefault(); | ||||||
|                 sendNewMessage(); |                 sendNewMessage(); | ||||||
| @@ -263,7 +267,11 @@ export default function ChatScreen() { | |||||||
|             }} |             }} | ||||||
|             id="msg-input" |             id="msg-input" | ||||||
|             dir="auto" |             dir="auto" | ||||||
|  |             // Set a base height of 2 rows for mobile views | ||||||
|  |             // On lg+ screens, the hook will calculate and set the initial height anyway | ||||||
|  |             rows={2} | ||||||
|           ></textarea> |           ></textarea> | ||||||
|  |  | ||||||
|           {isGenerating(currConvId ?? '') ? ( |           {isGenerating(currConvId ?? '') ? ( | ||||||
|             <button |             <button | ||||||
|               className="btn btn-neutral ml-2" |               className="btn btn-neutral ml-2" | ||||||
| @@ -286,43 +294,3 @@ export default function ChatScreen() { | |||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface OptimizedTextareaValue { |  | ||||||
|   value: () => string; |  | ||||||
|   setValue: (value: string) => void; |  | ||||||
|   focus: () => void; |  | ||||||
|   ref: React.RefObject<HTMLTextAreaElement>; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // This is a workaround to prevent the textarea from re-rendering when the inner content changes |  | ||||||
| // See https://github.com/ggml-org/llama.cpp/pull/12299 |  | ||||||
| function useOptimizedTextarea(initValue: string): OptimizedTextareaValue { |  | ||||||
|   const [savedInitValue, setSavedInitValue] = useState<string>(initValue); |  | ||||||
|   const textareaRef = useRef<HTMLTextAreaElement>(null); |  | ||||||
|  |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (textareaRef.current && savedInitValue) { |  | ||||||
|       textareaRef.current.value = savedInitValue; |  | ||||||
|       setSavedInitValue(''); |  | ||||||
|     } |  | ||||||
|   }, [textareaRef, savedInitValue, setSavedInitValue]); |  | ||||||
|  |  | ||||||
|   return { |  | ||||||
|     value: () => { |  | ||||||
|       return textareaRef.current?.value ?? savedInitValue; |  | ||||||
|     }, |  | ||||||
|     setValue: (value: string) => { |  | ||||||
|       if (textareaRef.current) { |  | ||||||
|         textareaRef.current.value = value; |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     focus: () => { |  | ||||||
|       if (textareaRef.current) { |  | ||||||
|         // focus and move the cursor to the end |  | ||||||
|         textareaRef.current.focus(); |  | ||||||
|         textareaRef.current.selectionStart = textareaRef.current.value.length; |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|     ref: textareaRef, |  | ||||||
|   }; |  | ||||||
| } |  | ||||||
|   | |||||||
							
								
								
									
										96
									
								
								examples/server/webui/src/components/useChatTextarea.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								examples/server/webui/src/components/useChatTextarea.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | |||||||
|  | import { useEffect, useRef, useState, useCallback } from 'react'; | ||||||
|  |  | ||||||
|  | // Media Query for detecting "large" screens (matching Tailwind's lg: breakpoint) | ||||||
|  | const LARGE_SCREEN_MQ = '(min-width: 1024px)'; | ||||||
|  |  | ||||||
|  | // Calculates and sets the textarea height based on its scrollHeight | ||||||
|  | const adjustTextareaHeight = (textarea: HTMLTextAreaElement | null) => { | ||||||
|  |   if (!textarea) return; | ||||||
|  |  | ||||||
|  |   // Only perform auto-sizing on large screens | ||||||
|  |   if (!window.matchMedia(LARGE_SCREEN_MQ).matches) { | ||||||
|  |     // On small screens, reset inline height and max-height styles. | ||||||
|  |     // This allows CSS (e.g., `rows` attribute or classes) to control the height, | ||||||
|  |     // and enables manual resizing if `resize-vertical` is set. | ||||||
|  |     textarea.style.height = ''; // Use 'auto' or '' to reset | ||||||
|  |     textarea.style.maxHeight = ''; | ||||||
|  |     return; // Do not adjust height programmatically on small screens | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const computedStyle = window.getComputedStyle(textarea); | ||||||
|  |   // Get the max-height specified by CSS (e.g., from `lg:max-h-48`) | ||||||
|  |   const currentMaxHeight = computedStyle.maxHeight; | ||||||
|  |  | ||||||
|  |   // Temporarily remove max-height to allow scrollHeight to be calculated correctly | ||||||
|  |   textarea.style.maxHeight = 'none'; | ||||||
|  |   // Reset height to 'auto' to measure the actual scrollHeight needed | ||||||
|  |   textarea.style.height = 'auto'; | ||||||
|  |   // Set the height to the calculated scrollHeight | ||||||
|  |   textarea.style.height = `${textarea.scrollHeight}px`; | ||||||
|  |   // Re-apply the original max-height from CSS to enforce the limit | ||||||
|  |   textarea.style.maxHeight = currentMaxHeight; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Interface describing the API returned by the hook | ||||||
|  | export interface ChatTextareaApi { | ||||||
|  |   value: () => string; | ||||||
|  |   setValue: (value: string) => void; | ||||||
|  |   focus: () => void; | ||||||
|  |   ref: React.RefObject<HTMLTextAreaElement>; | ||||||
|  |   onInput: (event: React.FormEvent<HTMLTextAreaElement>) => void; // Input handler | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // This is a workaround to prevent the textarea from re-rendering when the inner content changes | ||||||
|  | // See https://github.com/ggml-org/llama.cpp/pull/12299 | ||||||
|  | // combined now with auto-sizing logic. | ||||||
|  | export function useChatTextarea(initValue: string): ChatTextareaApi { | ||||||
|  |   const [savedInitValue, setSavedInitValue] = useState<string>(initValue); | ||||||
|  |   const textareaRef = useRef<HTMLTextAreaElement>(null); | ||||||
|  |  | ||||||
|  |   // Effect to set initial value and height on mount or when initValue changes | ||||||
|  |   useEffect(() => { | ||||||
|  |     const textarea = textareaRef.current; | ||||||
|  |     if (textarea) { | ||||||
|  |       if (typeof savedInitValue === 'string' && savedInitValue.length > 0) { | ||||||
|  |         textarea.value = savedInitValue; | ||||||
|  |         // Call adjustTextareaHeight - it will check screen size internally | ||||||
|  |         setTimeout(() => adjustTextareaHeight(textarea), 0); | ||||||
|  |         setSavedInitValue(''); // Reset after applying | ||||||
|  |       } else { | ||||||
|  |         // Adjust height even if there's no initial value (for initial render) | ||||||
|  |         setTimeout(() => adjustTextareaHeight(textarea), 0); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, [textareaRef, savedInitValue]); // Depend on ref and savedInitValue | ||||||
|  |  | ||||||
|  |   const handleInput = useCallback( | ||||||
|  |     (event: React.FormEvent<HTMLTextAreaElement>) => { | ||||||
|  |       // Call adjustTextareaHeight on every input - it will decide whether to act | ||||||
|  |       adjustTextareaHeight(event.currentTarget); | ||||||
|  |     }, | ||||||
|  |     [] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     // Method to get the current value directly from the textarea | ||||||
|  |     value: () => { | ||||||
|  |       return textareaRef.current?.value ?? ''; | ||||||
|  |     }, | ||||||
|  |     // Method to programmatically set the value and trigger height adjustment | ||||||
|  |     setValue: (value: string) => { | ||||||
|  |       const textarea = textareaRef.current; | ||||||
|  |       if (textarea) { | ||||||
|  |         textarea.value = value; | ||||||
|  |         // Call adjustTextareaHeight - it will check screen size internally | ||||||
|  |         setTimeout(() => adjustTextareaHeight(textarea), 0); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     focus: () => { | ||||||
|  |       if (textareaRef.current) { | ||||||
|  |         textareaRef.current.focus(); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     ref: textareaRef, | ||||||
|  |     onInput: handleInput, | ||||||
|  |   }; | ||||||
|  | } | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| import { useEffect, useState } from 'react'; | import { useEffect, useState } from 'react'; | ||||||
| import { MessageExtraContext } from './types'; | import { MessageExtraContext } from './types'; | ||||||
| import { OptimizedTextareaValue } from '../components/ChatScreen'; | import { ChatTextareaApi } from '../components/useChatTextarea.ts'; | ||||||
|  |  | ||||||
| // Extra context when using llama.cpp WebUI from llama-vscode, inside an iframe | // Extra context when using llama.cpp WebUI from llama-vscode, inside an iframe | ||||||
| // Ref: https://github.com/ggml-org/llama.cpp/pull/11940 | // Ref: https://github.com/ggml-org/llama.cpp/pull/11940 | ||||||
| @@ -15,7 +15,7 @@ interface SetTextEvData { | |||||||
|  * window.postMessage({ command: 'setText', text: 'Spot the syntax error', context: 'def test()\n  return 123' }, '*'); |  * window.postMessage({ command: 'setText', text: 'Spot the syntax error', context: 'def test()\n  return 123' }, '*'); | ||||||
|  */ |  */ | ||||||
|  |  | ||||||
| export const useVSCodeContext = (textarea: OptimizedTextareaValue) => { | export const useVSCodeContext = (textarea: ChatTextareaApi) => { | ||||||
|   const [extraContext, setExtraContext] = useState<MessageExtraContext | null>( |   const [extraContext, setExtraContext] = useState<MessageExtraContext | null>( | ||||||
|     null |     null | ||||||
|   ); |   ); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 characharm
					characharm