mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2025-06-29 12:35:16 +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