prompt-textarea.tsx 11 KB

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