| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- 'use client';
- import React, { useState, useRef, useEffect } from 'react';
- import { cn } from '@/lib/utils';
- interface PromptTextareaProps {
- value: string;
- onChange: (value: string) => void;
- placeholder?: string;
- className?: string;
- rows?: number;
- loras?: string[];
- embeddings?: string[];
- }
- interface Suggestion {
- text: string;
- type: 'lora' | 'embedding';
- displayText: string;
- }
- export function PromptTextarea({
- value,
- onChange,
- placeholder,
- className,
- rows = 3,
- loras = [],
- embeddings = [],
- }: PromptTextareaProps) {
- const textareaRef = useRef<HTMLTextAreaElement>(null);
- const [highlighted, setHighlighted] = useState<React.ReactNode[]>([]);
- const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
- const [showSuggestions, setShowSuggestions] = useState(false);
- const [selectedIndex, setSelectedIndex] = useState(0);
- const [cursorPosition, setCursorPosition] = useState(0);
- const suggestionsRef = useRef<HTMLDivElement>(null);
- useEffect(() => {
- highlightSyntax(value);
- updateSuggestions(value, cursorPosition);
- }, [value, loras, embeddings, cursorPosition]);
- const updateSuggestions = (text: string, position: number) => {
- // Get text before cursor
- const textBeforeCursor = text.substring(0, position);
- // Check if we're typing a LoRA
- const loraMatch = textBeforeCursor.match(/<lora:([^:>]*)$/);
- if (loraMatch) {
- const searchTerm = loraMatch[1].toLowerCase();
- const filtered = loras
- .filter(lora => {
- const loraBase = lora.replace(/\.(safetensors|ckpt|pt)$/i, '');
- return loraBase.toLowerCase().includes(searchTerm);
- })
- .slice(0, 10)
- .map(lora => ({
- text: lora,
- type: 'lora' as const,
- displayText: lora.replace(/\.(safetensors|ckpt|pt)$/i, ''),
- }));
- if (filtered.length > 0) {
- setSuggestions(filtered);
- setShowSuggestions(true);
- setSelectedIndex(0);
- return;
- }
- }
- // Check if we're typing an embedding (word boundary)
- const words = textBeforeCursor.split(/\s+/);
- const currentWord = words[words.length - 1];
- // Only show embedding suggestions if we've typed at least 2 characters
- // and we're not inside a lora tag
- if (currentWord.length >= 2 && !textBeforeCursor.match(/<lora:[^>]*$/)) {
- const searchTerm = currentWord.toLowerCase();
- const filtered = embeddings
- .filter(emb => {
- const embBase = emb.replace(/\.(safetensors|pt)$/i, '');
- return embBase.toLowerCase().startsWith(searchTerm);
- })
- .slice(0, 10)
- .map(emb => ({
- text: emb,
- type: 'embedding' as const,
- displayText: emb.replace(/\.(safetensors|pt)$/i, ''),
- }));
- if (filtered.length > 0) {
- setSuggestions(filtered);
- setShowSuggestions(true);
- setSelectedIndex(0);
- return;
- }
- }
- setShowSuggestions(false);
- };
- const insertSuggestion = (suggestion: Suggestion) => {
- if (!textareaRef.current) return;
- const position = textareaRef.current.selectionStart;
- const textBefore = value.substring(0, position);
- const textAfter = value.substring(position);
- let newText = '';
- let newPosition = position;
- if (suggestion.type === 'lora') {
- // Find the <lora: part
- const loraMatch = textBefore.match(/<lora:([^:>]*)$/);
- if (loraMatch) {
- const beforeLora = textBefore.substring(0, textBefore.length - loraMatch[0].length);
- const loraText = `<lora:${suggestion.displayText}:0.8>`;
- newText = beforeLora + loraText + textAfter;
- newPosition = beforeLora.length + loraText.length;
- }
- } else {
- // Embedding - replace current word
- const words = textBefore.split(/\s+/);
- const currentWord = words[words.length - 1];
- const beforeWord = textBefore.substring(0, textBefore.length - currentWord.length);
- newText = beforeWord + suggestion.displayText + textAfter;
- newPosition = beforeWord.length + suggestion.displayText.length;
- }
- onChange(newText);
- setShowSuggestions(false);
- // Restore cursor position
- setTimeout(() => {
- if (textareaRef.current) {
- textareaRef.current.selectionStart = newPosition;
- textareaRef.current.selectionEnd = newPosition;
- textareaRef.current.focus();
- }
- }, 0);
- };
- const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
- if (!showSuggestions) return;
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
- } else if (e.key === 'ArrowUp') {
- e.preventDefault();
- setSelectedIndex(prev => Math.max(prev - 1, 0));
- } else if (e.key === 'Enter' || e.key === 'Tab') {
- if (suggestions.length > 0) {
- e.preventDefault();
- insertSuggestion(suggestions[selectedIndex]);
- }
- } else if (e.key === 'Escape') {
- setShowSuggestions(false);
- }
- };
- const highlightSyntax = (text: string) => {
- if (!text) {
- setHighlighted([]);
- return;
- }
- const parts: React.ReactNode[] = [];
- let lastIndex = 0;
- const loraNames = new Set(
- loras.map(name => name.replace(/\.(safetensors|ckpt|pt)$/i, ''))
- );
- const loraFullNames = new Set(loras);
- const embeddingNames = new Set(
- embeddings.map(name => name.replace(/\.(safetensors|pt)$/i, ''))
- );
- 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);
- matches.push({
- start: match.index,
- end: match.index + match[0].length,
- type: 'lora',
- text: match[0],
- valid: isValid,
- });
- }
- embeddings.forEach(embedding => {
- const embeddingBase = embedding.replace(/\.(safetensors|pt)$/i, '');
- const embeddingRegex = new RegExp(`\\b${escapeRegex(embeddingBase)}\\b`, 'g');
- while ((match = embeddingRegex.exec(text)) !== null) {
- const isInsideLora = matches.some(
- m => m.type === 'lora' && match!.index >= m.start && match!.index < m.end
- );
- if (!isInsideLora) {
- matches.push({
- start: match.index,
- end: match.index + match[0].length,
- type: 'embedding',
- text: match[0],
- valid: true,
- });
- }
- }
- });
- matches.sort((a, b) => a.start - b.start);
- matches.forEach((match, index) => {
- if (match.start > lastIndex) {
- parts.push(
- <span key={`text-${lastIndex}`}>
- {text.substring(lastIndex, match.start)}
- </span>
- );
- }
- const highlightClass = match.type === 'lora'
- ? match.valid
- ? 'bg-purple-500/20 text-purple-700 dark:text-purple-300 font-medium rounded px-0.5'
- : 'bg-red-500/20 text-red-700 dark:text-red-300 font-medium rounded px-0.5'
- : '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'}>
- {match.text}
- </span>
- );
- lastIndex = match.end;
- });
- if (lastIndex < text.length) {
- parts.push(
- <span key={`text-${lastIndex}`}>
- {text.substring(lastIndex)}
- </span>
- );
- }
- setHighlighted(parts);
- };
- const escapeRegex = (str: string) => {
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- };
- return (
- <div className="relative w-full">
- <textarea
- ref={textareaRef}
- value={value}
- onChange={(e) => {
- onChange(e.target.value);
- setCursorPosition(e.target.selectionStart);
- }}
- onKeyDown={handleKeyDown}
- onClick={(e) => setCursorPosition(e.currentTarget.selectionStart)}
- 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',
- className
- )}
- style={{ background: 'transparent' }}
- />
- <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 && (
- <div
- ref={suggestionsRef}
- className="absolute mt-1 w-full max-h-60 overflow-auto rounded-md border border-border bg-popover shadow-lg z-50"
- >
- {suggestions.map((suggestion, index) => (
- <div
- key={`${suggestion.type}-${suggestion.text}`}
- className={cn(
- 'px-3 py-2 cursor-pointer text-sm flex items-center justify-between',
- index === selectedIndex
- ? 'bg-accent text-accent-foreground'
- : 'hover:bg-accent/50'
- )}
- onClick={() => insertSuggestion(suggestion)}
- onMouseEnter={() => setSelectedIndex(index)}
- >
- <span className="font-mono">{suggestion.displayText}</span>
- <span
- className={cn(
- 'text-xs px-2 py-0.5 rounded',
- suggestion.type === 'lora'
- ? 'bg-purple-500/20 text-purple-700 dark:text-purple-300'
- : 'bg-blue-500/20 text-blue-700 dark:text-blue-300'
- )}
- >
- {suggestion.type}
- </span>
- </div>
- ))}
- </div>
- )}
- </div>
- );
- }
|