File size: 11,414 Bytes
c275a0e
 
 
 
 
 
 
 
 
 
 
79bbe79
c275a0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c6f0c1
c275a0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58bad40
c275a0e
 
 
 
58bad40
 
 
 
 
 
c275a0e
 
 
 
 
 
 
 
 
3c6f0c1
c275a0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58bad40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c275a0e
 
58bad40
 
 
 
 
 
c275a0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58bad40
 
 
 
 
c275a0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79bbe79
 
c275a0e
 
58bad40
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
"""
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()