Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| class AutocompleteTextbox(gr.HTML): | |
| """A textbox with AI-powered autocomplete suggestions, similar to Gmail smart compose.""" | |
| def __init__( | |
| self, | |
| value="", | |
| label="Autocomplete Textbox", | |
| placeholder="Start typing...", | |
| **kwargs, | |
| ): | |
| html_template = """ | |
| <div class="act-container"> | |
| <div class="act-settings"> | |
| <div class="act-settings-row"> | |
| <div class="act-field act-field-token"> | |
| <label class="act-label">HF Token</label> | |
| <input type="password" class="act-token" placeholder="hf_..." /> | |
| </div> | |
| <div class="act-field act-field-model"> | |
| <label class="act-label">Model</label> | |
| <select class="act-model"> | |
| <option value="Qwen/Qwen2.5-7B-Instruct">Qwen 2.5 7B</option> | |
| <option value="Qwen/Qwen2.5-Coder-7B-Instruct">Qwen 2.5 Coder 7B</option> | |
| <option value="meta-llama/Llama-3.1-8B-Instruct">Llama 3.1 8B</option> | |
| <option value="mistralai/Mistral-7B-Instruct-v0.3">Mistral 7B</option> | |
| </select> | |
| </div> | |
| <div class="act-status-wrap"> | |
| <span class="act-status"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="act-editor-wrap"> | |
| <div class="act-backdrop"><span class="act-mirror"></span><span class="act-ghost"></span></div> | |
| <textarea class="act-textarea" rows="12" spellcheck="true"></textarea> | |
| </div> | |
| <div class="act-footer"> | |
| <span class="act-hint">Press <kbd>Tab</kbd> to accept · <kbd>Esc</kbd> to dismiss</span> | |
| <span class="act-charcount"></span> | |
| </div> | |
| </div> | |
| """ | |
| css_template = """ | |
| .act-container { | |
| border: 1px solid var(--border-color-primary, #d1d5db); | |
| border-radius: 8px; | |
| overflow: hidden; | |
| font-family: var(--font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); | |
| background: var(--background-fill-primary, #ffffff); | |
| } | |
| /* Settings bar */ | |
| .act-settings { | |
| padding: 8px 12px; | |
| background: var(--background-fill-secondary, #f9fafb); | |
| border-bottom: 1px solid var(--border-color-primary, #d1d5db); | |
| } | |
| .act-settings-row { | |
| display: flex; | |
| align-items: flex-end; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .act-field { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 3px; | |
| } | |
| .act-field-token { flex: 1; min-width: 180px; } | |
| .act-field-model { min-width: 160px; } | |
| .act-label { | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: var(--body-text-color-subdued, #6b7280); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .act-token, .act-model { | |
| padding: 6px 10px; | |
| border: 1px solid var(--border-color-primary, #d1d5db); | |
| border-radius: 6px; | |
| font-size: 13px; | |
| background: var(--background-fill-primary, #fff); | |
| color: var(--body-text-color, #1f2937); | |
| outline: none; | |
| transition: border-color 0.15s; | |
| } | |
| .act-token:focus, .act-model:focus { | |
| border-color: #3b82f6; | |
| box-shadow: 0 0 0 2px rgba(59,130,246,0.12); | |
| } | |
| .act-status-wrap { | |
| display: flex; | |
| align-items: center; | |
| padding-bottom: 4px; | |
| } | |
| .act-status { | |
| font-size: 12px; | |
| color: var(--body-text-color-subdued, #9ca3af); | |
| white-space: nowrap; | |
| } | |
| .act-status.loading { color: #f59e0b; } | |
| .act-status.ready { color: #10b981; } | |
| .act-status.error { color: #ef4444; } | |
| /* Editor area */ | |
| .act-editor-wrap { | |
| position: relative; | |
| margin: 0; | |
| } | |
| .act-backdrop { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| padding: 12px 16px; | |
| font-family: var(--font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); | |
| font-size: 15px; | |
| line-height: 1.7; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| overflow: hidden; | |
| pointer-events: none; | |
| z-index: 0; | |
| box-sizing: border-box; | |
| } | |
| .act-mirror { | |
| color: transparent; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| } | |
| .act-ghost { | |
| color: #9ca3af; | |
| pointer-events: none; | |
| } | |
| .act-textarea { | |
| display: block; | |
| width: 100%; | |
| min-height: 250px; | |
| padding: 12px 16px; | |
| font-family: var(--font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); | |
| font-size: 15px; | |
| line-height: 1.7; | |
| color: var(--body-text-color, #1f2937); | |
| background: transparent; | |
| border: none; | |
| outline: none; | |
| resize: vertical; | |
| position: relative; | |
| z-index: 1; | |
| caret-color: var(--body-text-color, #1f2937); | |
| box-sizing: border-box; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| overflow-wrap: break-word; | |
| } | |
| .act-textarea::placeholder { | |
| color: var(--body-text-color-subdued, #9ca3af); | |
| } | |
| /* Footer */ | |
| .act-footer { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 6px 12px; | |
| background: var(--background-fill-secondary, #f9fafb); | |
| border-top: 1px solid var(--border-color-primary, #d1d5db); | |
| } | |
| .act-hint { | |
| font-size: 12px; | |
| color: var(--body-text-color-subdued, #9ca3af); | |
| } | |
| .act-hint kbd { | |
| display: inline-block; | |
| padding: 1px 5px; | |
| font-size: 11px; | |
| font-family: var(--font-mono, monospace); | |
| background: var(--background-fill-primary, #fff); | |
| border: 1px solid var(--border-color-primary, #d1d5db); | |
| border-radius: 3px; | |
| box-shadow: 0 1px 0 var(--border-color-primary, #d1d5db); | |
| } | |
| .act-charcount { | |
| font-size: 12px; | |
| color: var(--body-text-color-subdued, #9ca3af); | |
| } | |
| """ | |
| js_on_load = """ | |
| (function() { | |
| var textarea = element.querySelector('.act-textarea'); | |
| var backdrop = element.querySelector('.act-backdrop'); | |
| var mirror = element.querySelector('.act-mirror'); | |
| var ghost = element.querySelector('.act-ghost'); | |
| var tokenInput = element.querySelector('.act-token'); | |
| var modelSelect = element.querySelector('.act-model'); | |
| var status = element.querySelector('.act-status'); | |
| var charcount = element.querySelector('.act-charcount'); | |
| var currentSuggestion = ''; | |
| var debounceTimer = null; | |
| var abortController = null; | |
| var DEBOUNCE_MS = 600; | |
| /* Placeholder */ | |
| if (props.placeholder) { | |
| textarea.setAttribute('placeholder', props.placeholder); | |
| } | |
| /* Initial content */ | |
| if (props.value) { | |
| textarea.value = props.value; | |
| updateMirror(); | |
| } | |
| /* --- Mirror: keep backdrop text in sync --- */ | |
| function updateMirror() { | |
| /* Escape HTML entities in the textarea value */ | |
| var text = textarea.value; | |
| var escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | |
| mirror.innerHTML = escaped; | |
| charcount.textContent = text.length + ' chars'; | |
| } | |
| /* --- Suggestion display --- */ | |
| function showSuggestion(text) { | |
| currentSuggestion = text; | |
| ghost.textContent = text; | |
| } | |
| function clearSuggestion() { | |
| currentSuggestion = ''; | |
| ghost.textContent = ''; | |
| } | |
| /* --- Status display --- */ | |
| function setStatus(msg, cls) { | |
| status.textContent = msg; | |
| status.className = 'act-status' + (cls ? ' ' + cls : ''); | |
| } | |
| /* --- Sync value to Gradio --- */ | |
| function syncValue() { | |
| var text = textarea.value; | |
| if (text !== props.value) { | |
| props.value = text; | |
| } | |
| } | |
| /* --- Scroll sync: keep backdrop scrolled with textarea --- */ | |
| textarea.addEventListener('scroll', function() { | |
| backdrop.scrollTop = textarea.scrollTop; | |
| backdrop.scrollLeft = textarea.scrollLeft; | |
| }); | |
| /* --- API call --- */ | |
| function fetchSuggestion() { | |
| var text = textarea.value; | |
| var token = tokenInput.value.trim(); | |
| var model = modelSelect.value; | |
| if (!token) { | |
| setStatus('Enter HF token', ''); | |
| return; | |
| } | |
| if (!text || text.length < 5) { | |
| clearSuggestion(); | |
| return; | |
| } | |
| /* Cancel previous request */ | |
| if (abortController) { | |
| abortController.abort(); | |
| } | |
| abortController = new AbortController(); | |
| setStatus('Thinking...', 'loading'); | |
| var lastChunk = text; | |
| if (text.length > 1000) { | |
| lastChunk = text.slice(-1000); | |
| } | |
| var body = JSON.stringify({ | |
| model: model, | |
| messages: [ | |
| { | |
| role: 'system', | |
| content: 'You are an autocomplete assistant. The user is writing text and you must predict what comes next. Output ONLY the continuation text (1-2 short sentences max). Do not repeat any of the existing text. Do not add quotes or explanations.' | |
| }, | |
| { | |
| role: 'user', | |
| content: lastChunk | |
| } | |
| ], | |
| max_tokens: 60, | |
| temperature: 0.3, | |
| stream: false | |
| }); | |
| fetch('https://router.huggingface.co/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': 'Bearer ' + token, | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: body, | |
| signal: abortController.signal | |
| }) | |
| .then(function(resp) { | |
| if (!resp.ok) { | |
| return resp.json().then(function(data) { | |
| throw new Error(data.error || 'HTTP ' + resp.status); | |
| }); | |
| } | |
| return resp.json(); | |
| }) | |
| .then(function(data) { | |
| var content = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; | |
| if (content) { | |
| /* Clean up: remove leading whitespace only if the text doesn't end with space */ | |
| var trimmed = content; | |
| if (text.endsWith(' ') || text.endsWith('\\n')) { | |
| trimmed = content.replace(/^\\s+/, ''); | |
| } else if (!content.startsWith(' ')) { | |
| trimmed = ' ' + content; | |
| } | |
| /* Remove any repeated text from the end of input */ | |
| var overlap = findOverlap(text, trimmed); | |
| if (overlap > 0) { | |
| trimmed = trimmed.slice(overlap); | |
| } | |
| if (trimmed && textarea.value === text) { | |
| showSuggestion(trimmed); | |
| setStatus('Suggestion ready', 'ready'); | |
| } | |
| } else { | |
| setStatus('No suggestion', ''); | |
| } | |
| }) | |
| .catch(function(err) { | |
| if (err.name !== 'AbortError') { | |
| setStatus('Error: ' + err.message.slice(0, 50), 'error'); | |
| console.error('Autocomplete error:', err); | |
| } | |
| }); | |
| } | |
| /* Find overlap between end of text and start of suggestion */ | |
| function findOverlap(text, suggestion) { | |
| var maxCheck = Math.min(text.length, suggestion.length, 50); | |
| for (var i = maxCheck; i > 0; i--) { | |
| if (text.slice(-i) === suggestion.slice(0, i)) { | |
| return i; | |
| } | |
| } | |
| return 0; | |
| } | |
| /* --- Input handler --- */ | |
| textarea.addEventListener('input', function() { | |
| updateMirror(); | |
| clearSuggestion(); | |
| syncValue(); | |
| /* Debounce API call */ | |
| if (debounceTimer) clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(fetchSuggestion, DEBOUNCE_MS); | |
| }); | |
| /* --- Keydown: Tab to accept, Esc to dismiss --- */ | |
| textarea.addEventListener('keydown', function(e) { | |
| if (e.key === 'Tab' && currentSuggestion) { | |
| e.preventDefault(); | |
| textarea.value += currentSuggestion; | |
| clearSuggestion(); | |
| updateMirror(); | |
| syncValue(); | |
| setStatus('Accepted', 'ready'); | |
| /* Move cursor to end */ | |
| textarea.selectionStart = textarea.value.length; | |
| textarea.selectionEnd = textarea.value.length; | |
| return; | |
| } | |
| if (e.key === 'Escape' && currentSuggestion) { | |
| e.preventDefault(); | |
| clearSuggestion(); | |
| setStatus('Dismissed', ''); | |
| return; | |
| } | |
| }); | |
| /* --- Focus/blur --- */ | |
| textarea.addEventListener('focus', function() { | |
| if (textarea.value.length >= 5 && !currentSuggestion && tokenInput.value.trim()) { | |
| if (debounceTimer) clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(fetchSuggestion, DEBOUNCE_MS); | |
| } | |
| }); | |
| /* --- Token input: show status --- */ | |
| tokenInput.addEventListener('input', function() { | |
| if (tokenInput.value.trim()) { | |
| setStatus('Token set', 'ready'); | |
| } else { | |
| setStatus('Enter HF token', ''); | |
| } | |
| clearSuggestion(); | |
| }); | |
| /* --- Initial mirror update --- */ | |
| updateMirror(); | |
| setStatus('Enter HF token to enable', ''); | |
| })(); | |
| """ | |
| super().__init__( | |
| value=value, | |
| label=label, | |
| html_template=html_template, | |
| css_template=css_template, | |
| js_on_load=js_on_load, | |
| apply_default_css=False, | |
| placeholder=placeholder, | |
| container=False, | |
| padding=False, | |
| **kwargs, | |
| ) | |
| def api_info(self): | |
| return {"type": "string", "description": "Text content of the autocomplete textbox"} | |
| if __name__ == "__main__": | |
| with gr.Blocks(title="Autocomplete Textbox Demo") as demo: | |
| gr.Markdown("# AI Autocomplete Textbox") | |
| gr.Markdown( | |
| "A textbox with AI-powered autocomplete suggestions (like Gmail Smart Compose). " | |
| "Enter your HF token, pick a model, and start typing. " | |
| "Press **Tab** to accept a suggestion, **Esc** to dismiss." | |
| ) | |
| editor = AutocompleteTextbox( | |
| value="", | |
| label="", | |
| placeholder="Start typing here... suggestions will appear in gray after you pause.", | |
| ) | |
| with gr.Row(): | |
| get_text_btn = gr.Button("Get Text", variant="primary") | |
| clear_btn = gr.Button("Clear", variant="secondary") | |
| text_output = gr.Textbox(label="Output", lines=5, interactive=False) | |
| get_text_btn.click(fn=lambda x: x, inputs=editor, outputs=text_output) | |
| clear_btn.click(fn=lambda: "", outputs=editor) | |
| editor.change(fn=lambda x: x, inputs=editor, outputs=text_output) | |
| demo.launch() | |