creative-help / app.py
roemmele's picture
Updated app.py
58bad40
"""
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 = (
'<span class="header-icon header-icon-help">'
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
'<circle cx="12" cy="12" r="10"/>'
'<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>'
'<span class="help-tooltip">' +
APP_EXPLANATION.replace("<", "&lt;").replace(
">", "&gt;") + '</span></span>'
)
gr.HTML(
'<div class="header-row">'
'<h1 class="creative-help-title">Creative Help</h1>'
'<div class="header-icons">' + header_icons + '</div>'
'</div>'
'<p class="instructions">Type <code>\\help\\</code> when you want to generate a suggestion.</p>'
)
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'<div class="footer">'
f'<a href="{model_url}" target="_blank" rel="noopener" class="footer-link">'
f'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" '
f'fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>'
f'<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>'
f' Model on Hugging Face</a>'
f'<a href="{paper_url}" target="_blank" rel="noopener" class="footer-link">'
f'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" '
f'fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>'
f'<polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>'
f' Research paper</a></div>'
)
return demo
if __name__ == "__main__":
demo = create_ui()
demo.launch()