prompt-textarea.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. 'use client';
  2. import React, { useState, useRef, useEffect } from 'react';
  3. import { cn } from '@/lib/utils';
  4. interface PromptTextareaProps {
  5. value: string;
  6. onChange: (value: string) => void;
  7. placeholder?: string;
  8. className?: string;
  9. rows?: number;
  10. loras?: string[];
  11. embeddings?: string[];
  12. }
  13. interface Suggestion {
  14. text: string;
  15. type: 'lora' | 'embedding';
  16. displayText: string;
  17. }
  18. export function PromptTextarea({
  19. value,
  20. onChange,
  21. placeholder,
  22. className,
  23. rows = 3,
  24. loras = [],
  25. embeddings = [],
  26. }: PromptTextareaProps) {
  27. const textareaRef = useRef<HTMLTextAreaElement>(null);
  28. const [highlighted, setHighlighted] = useState<React.ReactNode[]>([]);
  29. const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
  30. const [showSuggestions, setShowSuggestions] = useState(false);
  31. const [selectedIndex, setSelectedIndex] = useState(0);
  32. const [cursorPosition, setCursorPosition] = useState(0);
  33. const suggestionsRef = useRef<HTMLDivElement>(null);
  34. useEffect(() => {
  35. highlightSyntax(value);
  36. updateSuggestions(value, cursorPosition);
  37. }, [value, loras, embeddings, cursorPosition]);
  38. const updateSuggestions = (text: string, position: number) => {
  39. // Get text before cursor
  40. const textBeforeCursor = text.substring(0, position);
  41. // Check if we're typing a LoRA
  42. const loraMatch = textBeforeCursor.match(/<lora:([^:>]*)$/);
  43. if (loraMatch) {
  44. const searchTerm = loraMatch[1].toLowerCase();
  45. const filtered = loras
  46. .filter(lora => {
  47. const loraBase = lora.replace(/\.(safetensors|ckpt|pt)$/i, '');
  48. return loraBase.toLowerCase().includes(searchTerm);
  49. })
  50. .slice(0, 10)
  51. .map(lora => ({
  52. text: lora,
  53. type: 'lora' as const,
  54. displayText: lora.replace(/\.(safetensors|ckpt|pt)$/i, ''),
  55. }));
  56. if (filtered.length > 0) {
  57. setSuggestions(filtered);
  58. setShowSuggestions(true);
  59. setSelectedIndex(0);
  60. return;
  61. }
  62. }
  63. // Check if we're typing an embedding (word boundary)
  64. const words = textBeforeCursor.split(/\s+/);
  65. const currentWord = words[words.length - 1];
  66. // Only show embedding suggestions if we've typed at least 2 characters
  67. // and we're not inside a lora tag
  68. if (currentWord.length >= 2 && !textBeforeCursor.match(/<lora:[^>]*$/)) {
  69. const searchTerm = currentWord.toLowerCase();
  70. const filtered = embeddings
  71. .filter(emb => {
  72. const embBase = emb.replace(/\.(safetensors|pt)$/i, '');
  73. return embBase.toLowerCase().startsWith(searchTerm);
  74. })
  75. .slice(0, 10)
  76. .map(emb => ({
  77. text: emb,
  78. type: 'embedding' as const,
  79. displayText: emb.replace(/\.(safetensors|pt)$/i, ''),
  80. }));
  81. if (filtered.length > 0) {
  82. setSuggestions(filtered);
  83. setShowSuggestions(true);
  84. setSelectedIndex(0);
  85. return;
  86. }
  87. }
  88. setShowSuggestions(false);
  89. };
  90. const insertSuggestion = (suggestion: Suggestion) => {
  91. if (!textareaRef.current) return;
  92. const position = textareaRef.current.selectionStart;
  93. const textBefore = value.substring(0, position);
  94. const textAfter = value.substring(position);
  95. let newText = '';
  96. let newPosition = position;
  97. if (suggestion.type === 'lora') {
  98. // Find the <lora: part
  99. const loraMatch = textBefore.match(/<lora:([^:>]*)$/);
  100. if (loraMatch) {
  101. const beforeLora = textBefore.substring(0, textBefore.length - loraMatch[0].length);
  102. const loraText = `<lora:${suggestion.displayText}:0.8>`;
  103. newText = beforeLora + loraText + textAfter;
  104. newPosition = beforeLora.length + loraText.length;
  105. }
  106. } else {
  107. // Embedding - replace current word
  108. const words = textBefore.split(/\s+/);
  109. const currentWord = words[words.length - 1];
  110. const beforeWord = textBefore.substring(0, textBefore.length - currentWord.length);
  111. newText = beforeWord + suggestion.displayText + textAfter;
  112. newPosition = beforeWord.length + suggestion.displayText.length;
  113. }
  114. onChange(newText);
  115. setShowSuggestions(false);
  116. // Restore cursor position
  117. setTimeout(() => {
  118. if (textareaRef.current) {
  119. textareaRef.current.selectionStart = newPosition;
  120. textareaRef.current.selectionEnd = newPosition;
  121. textareaRef.current.focus();
  122. }
  123. }, 0);
  124. };
  125. const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  126. if (!showSuggestions) return;
  127. if (e.key === 'ArrowDown') {
  128. e.preventDefault();
  129. setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
  130. } else if (e.key === 'ArrowUp') {
  131. e.preventDefault();
  132. setSelectedIndex(prev => Math.max(prev - 1, 0));
  133. } else if (e.key === 'Enter' || e.key === 'Tab') {
  134. if (suggestions.length > 0) {
  135. e.preventDefault();
  136. insertSuggestion(suggestions[selectedIndex]);
  137. }
  138. } else if (e.key === 'Escape') {
  139. setShowSuggestions(false);
  140. }
  141. };
  142. const highlightSyntax = (text: string) => {
  143. if (!text) {
  144. setHighlighted([]);
  145. return;
  146. }
  147. const parts: React.ReactNode[] = [];
  148. let lastIndex = 0;
  149. const loraNames = new Set(
  150. loras.map(name => name.replace(/\.(safetensors|ckpt|pt)$/i, ''))
  151. );
  152. const loraFullNames = new Set(loras);
  153. const embeddingNames = new Set(
  154. embeddings.map(name => name.replace(/\.(safetensors|pt)$/i, ''))
  155. );
  156. const loraRegex = /<lora:([^:>]+):([^>]+)>/g;
  157. let match;
  158. const matches: Array<{ start: number; end: number; type: 'lora' | 'embedding'; text: string; valid: boolean }> = [];
  159. while ((match = loraRegex.exec(text)) !== null) {
  160. const loraName = match[1];
  161. const isValid = loraNames.has(loraName) || loraFullNames.has(loraName);
  162. matches.push({
  163. start: match.index,
  164. end: match.index + match[0].length,
  165. type: 'lora',
  166. text: match[0],
  167. valid: isValid,
  168. });
  169. }
  170. embeddings.forEach(embedding => {
  171. const embeddingBase = embedding.replace(/\.(safetensors|pt)$/i, '');
  172. const embeddingRegex = new RegExp(`\\b${escapeRegex(embeddingBase)}\\b`, 'g');
  173. while ((match = embeddingRegex.exec(text)) !== null) {
  174. const isInsideLora = matches.some(
  175. m => m.type === 'lora' && match!.index >= m.start && match!.index < m.end
  176. );
  177. if (!isInsideLora) {
  178. matches.push({
  179. start: match.index,
  180. end: match.index + match[0].length,
  181. type: 'embedding',
  182. text: match[0],
  183. valid: true,
  184. });
  185. }
  186. }
  187. });
  188. matches.sort((a, b) => a.start - b.start);
  189. matches.forEach((match, index) => {
  190. if (match.start > lastIndex) {
  191. parts.push(
  192. <span key={`text-${lastIndex}`}>
  193. {text.substring(lastIndex, match.start)}
  194. </span>
  195. );
  196. }
  197. const highlightClass = match.type === 'lora'
  198. ? match.valid
  199. ? 'bg-purple-500/20 text-purple-700 dark:text-purple-300 font-medium rounded px-0.5'
  200. : 'bg-red-500/20 text-red-700 dark:text-red-300 font-medium rounded px-0.5'
  201. : 'bg-blue-500/20 text-blue-700 dark:text-blue-300 font-medium rounded px-0.5';
  202. parts.push(
  203. <span key={`highlight-${match.start}`} className={highlightClass} title={match.type === 'lora' ? (match.valid ? 'LoRA' : 'LoRA not found') : 'Embedding'}>
  204. {match.text}
  205. </span>
  206. );
  207. lastIndex = match.end;
  208. });
  209. if (lastIndex < text.length) {
  210. parts.push(
  211. <span key={`text-${lastIndex}`}>
  212. {text.substring(lastIndex)}
  213. </span>
  214. );
  215. }
  216. setHighlighted(parts);
  217. };
  218. const escapeRegex = (str: string) => {
  219. return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  220. };
  221. return (
  222. <div className="relative w-full">
  223. <textarea
  224. ref={textareaRef}
  225. value={value}
  226. onChange={(e) => {
  227. onChange(e.target.value);
  228. setCursorPosition(e.target.selectionStart);
  229. }}
  230. onKeyDown={handleKeyDown}
  231. onClick={(e) => setCursorPosition(e.currentTarget.selectionStart)}
  232. placeholder={placeholder}
  233. rows={rows}
  234. className={cn(
  235. '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',
  236. className
  237. )}
  238. style={{ background: 'transparent' }}
  239. />
  240. <div
  241. 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"
  242. style={{ color: 'transparent' }}
  243. >
  244. {highlighted.length > 0 ? highlighted : value}
  245. </div>
  246. {/* Autocomplete Suggestions */}
  247. {showSuggestions && suggestions.length > 0 && (
  248. <div
  249. ref={suggestionsRef}
  250. className="absolute mt-1 w-full max-h-60 overflow-auto rounded-md border border-border bg-popover shadow-lg z-30"
  251. >
  252. {suggestions.map((suggestion, index) => (
  253. <div
  254. key={`${suggestion.type}-${suggestion.text}`}
  255. className={cn(
  256. 'px-3 py-2 cursor-pointer text-sm flex items-center justify-between',
  257. index === selectedIndex
  258. ? 'bg-accent text-accent-foreground'
  259. : 'hover:bg-accent/50'
  260. )}
  261. onClick={() => insertSuggestion(suggestion)}
  262. onMouseEnter={() => setSelectedIndex(index)}
  263. >
  264. <span className="font-mono">{suggestion.displayText}</span>
  265. <span
  266. className={cn(
  267. 'text-xs px-2 py-0.5 rounded',
  268. suggestion.type === 'lora'
  269. ? 'bg-purple-500/20 text-purple-700 dark:text-purple-300'
  270. : 'bg-blue-500/20 text-blue-700 dark:text-blue-300'
  271. )}
  272. >
  273. {suggestion.type}
  274. </span>
  275. </div>
  276. ))}
  277. </div>
  278. )}
  279. </div>
  280. );
  281. }