""" Creative Help - A word processor interface for eliciting text completions from the creative-help model. Users write freely, then type \\help\\ to trigger generation automatically. Deploy to Hugging Face Spaces: https://huggingface.co/spaces """ from __future__ import annotations import logging import os import re import gradio as gr import requests logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # --- Configuration --- MAX_NEW_TOKENS = 50 TEMPERATURE = 1.0 def get_api_config(): """Get API endpoint and token from environment (Space secrets/variables).""" endpoint_url = os.getenv("HF_ENDPOINT_URL", "").strip() token = os.getenv("HF_TOKEN", "").strip() return endpoint_url, token def call_creative_help(prompt: str, token: str, url: str) -> dict: """Call the creative-help model API and return the result.""" headers = {"Authorization": f"Bearer {token}"} if token else {} payload = { "inputs": prompt, "parameters": { "max_new_tokens": MAX_NEW_TOKENS, "temperature": TEMPERATURE, "do_sample": True, }, } logger.info("API request: url=%s prompt=%r parameters=%s", url, prompt, payload["parameters"]) response = requests.post(url, headers=headers, json=payload, timeout=60) response.raise_for_status() result = response.json() logger.info("API response: %s", result) return result def extract_prompt_and_trigger(text: str) -> tuple[str, bool]: """ If text ends with \\help\\ or /help/, return (text_without_trigger, True). Otherwise return (text, False). """ if not text or not text.strip(): return text, False # Match \help\ or /help/ at end (with optional trailing whitespace) # Use greedy (.*) to preserve whitespace (e.g. paragraph breaks) before trigger for pattern in (r"(.*)\\help\\\s*$", r"(.*)/help/\s*$"): match = re.search(pattern, text, re.DOTALL) if match: return match.group(1), True return text, False def generate_completion(prompt: str) -> tuple[str | None, str]: """ Call the API and append generated text to the prompt. prompt is the text before \\help\\ (trigger already extracted). Returns (updated_text, status_message). Returns (None, error_msg) on API/config errors. """ url, token = get_api_config() if not url: return None, "⚠️ Configure HF_ENDPOINT_URL or HF_MODEL_ID in Space Settings → Variables." if not token: return None, "⚠️ Configure HF_TOKEN in Space Settings → Secrets." if not prompt.strip(): return None, "⚠️ Enter some text first to use as a prompt." try: result = call_creative_help(prompt, token, url) except requests.exceptions.RequestException as e: logger.exception("API request failed: %s", e) return None, f"❌ API error: {e}" # Parse response - handler returns [{"generated_text": "..."}] if isinstance(result, list) and result and isinstance(result[0], dict): generated = result[0].get("generated_text", "") elif isinstance(result, dict): if "error" in result: return None, f"❌ {result['error']}" generated = result.get("generated_text", "") else: return None, "❌ Unexpected API response format." if not generated or not isinstance(generated, str): return None, "❌ No generated text in response." # Add single space only when prefix doesn't end with newline (avoids " ") if prompt and prompt[-1].isspace(): new_text = prompt + generated.lstrip() else: new_text = prompt + generated return new_text, f"✨ Generated: {generated[:80]}{'...' if len(generated) > 80 else ''}" CURSOR_JS = """ (function() { function findTextarea() { const app = document.querySelector('gradio-app'); if (app && app.shadowRoot) { return app.shadowRoot.querySelector('#story-input textarea'); } return document.querySelector('#story-input textarea'); } function moveCursorToEnd() { const ta = findTextarea(); if (ta && !ta.disabled) { ta.focus(); const len = ta.value.length; ta.setSelectionRange(len, len); } } function observeTextarea() { const ta = findTextarea(); if (!ta) return false; const observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { if (m.attributeName === 'disabled' && !ta.disabled) { setTimeout(moveCursorToEnd, 0); } }); }); observer.observe(ta, { attributes: true, attributeFilter: ['disabled'] }); return true; } var attempts = 0; var id = setInterval(function() { if (observeTextarea() || ++attempts > 50) clearInterval(id); }, 100); })(); """ def get_model_repo_url() -> str: return "https://huggingface.co/roemmele/creative-help" def get_paper_url() -> str: """Get the research paper URL for the footer link.""" url = os.getenv("PAPER_URL", "").strip() if url: return url return "https://roemmele.github.io/publications/creative-help-demo.pdf" APP_EXPLANATION = ( "Creative Help is a legacy app for AI-based writing assistance. It was developed in 2016 as one of the first demonstrations of the use of a language model for helping people write stories." ) CUSTOM_CSS = """ .header-row { display: flex !important; align-items: center !important; gap: 0.75rem !important; margin-bottom: 0.25rem !important; } .header-icons { display: flex !important; gap: 0.5rem !important; } .header-icon { color: #718096 !important; cursor: pointer !important; transition: color 0.2s !important; } .header-icon:hover { color: #c53030 !important; } .header-icon svg { display: block !important; } .header-icon-help { position: relative !important; } .header-icon-help .help-tooltip { visibility: hidden; opacity: 0; position: absolute; left: 50%; transform: translateX(-50%); top: 100%; margin-top: 8px; padding: 10px 14px; background: #2d3748; color: #fff; font-size: 0.85rem; line-height: 1.4; border-radius: 8px; width: 280px; text-align: left; box-shadow: 0 4px 12px rgba(0,0,0,0.2); z-index: 100; transition: opacity 0.2s, visibility 0.2s; } .header-icon-help:hover .help-tooltip { visibility: visible; opacity: 1; } .creative-help-title { font-size: 2rem !important; font-weight: 700 !important; color: #c53030 !important; margin: 0 !important; } .footer { margin-top: 1rem !important; padding-top: 0.75rem !important; border-top: 1px solid #e2e8f0 !important; } .footer { display: flex !important; flex-wrap: wrap !important; gap: 1rem !important; } .footer-link { color: #718096 !important; font-size: 0.875rem !important; text-decoration: none !important; display: inline-flex !important; align-items: center !important; gap: 0.35rem !important; } .footer-link:hover { color: #c53030 !important; } .instructions { color: #4a5568; font-size: 0.95rem; margin-bottom: 1rem; } /* Dim textbox while processing - remove border/outline from textarea and container */ #story-input textarea:disabled { opacity: 0.25 !important; background-color: #e2e8f0 !important; filter: blur(0.5px); border: none !important; box-shadow: none !important; outline: none !important; } #story-input:has(textarea:disabled), #story-input:has(textarea:disabled) * { border: none !important; box-shadow: none !important; outline: none !important; } """ def create_ui(): """Build the Creative Help Gradio interface.""" with gr.Blocks( title="Creative Help", js=CURSOR_JS, theme=gr.themes.Soft(primary_hue="red", secondary_hue="slate"), css=CUSTOM_CSS, ) as demo: model_url = get_model_repo_url() paper_url = get_paper_url() header_icons = ( '' '' '' '' '' + APP_EXPLANATION.replace("<", "<").replace( ">", ">") + '' ) gr.HTML( '
' '

Creative Help

' '
' + header_icons + '
' '
' '

Type \\help\\ when you want to generate a suggestion.

' ) with gr.Row(): textbox = gr.Textbox( placeholder="Write your story here... Type \\help\\ when you want a suggestion.", lines=12, max_lines=20, elem_id="story-input", show_label=False, ) def on_text_input(text: str): prompt, had_trigger = extract_prompt_and_trigger(text or "") if not had_trigger: # Use gr.skip() if available (Gradio 5+), else no-op update for Gradio 4 if hasattr(gr, "skip"): yield gr.skip() else: yield gr.update(value=text) return # Preserve text (dimmed) while waiting, then replace with result yield gr.update(value=text, interactive=False) new_text, _ = generate_completion(prompt) yield gr.update(value=new_text if new_text is not None else text, interactive=True) # Use trigger_mode="always_last" so rapid typing only processes the final value. textbox.input( fn=on_text_input, inputs=[textbox], outputs=[textbox], trigger_mode="always_last", ) # Footer with model repo and paper links gr.HTML( f'' ) return demo if __name__ == "__main__": demo = create_ui() demo.launch()