TheHickman commited on
Commit
4bfa0a6
·
verified ·
1 Parent(s): c8f7070

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +375 -215
app.py CHANGED
@@ -1,216 +1,376 @@
1
- import gradio as gr
2
- from openai import OpenAI
3
- import os
4
-
5
- client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
6
-
7
- # ---- GPT explanation backend ----
8
- def explain_text(selected_text):
9
- if selected_text is None:
10
- return ""
11
- selected_text = selected_text.strip()
12
- if not selected_text:
13
- return "Please select or enter some text first."
14
-
15
- try:
16
- response = client.chat.completions.create(
17
- model="gpt-4o",
18
- messages=[
19
- {"role": "system", "content": "You are an expert machine learning instructor. Explain concepts clearly and intuitively for learners with basic ML knowledge. Keep explanations concise and educational."},
20
- {"role": "user", "content": f"Explain this text from a learning resource:\n\n\"\"\"\n{selected_text}\n\"\"\""}
21
- ],
22
- temperature=0.7,
23
- max_tokens=500
24
- )
25
- return response.choices[0].message.content
26
- except Exception as e:
27
- return f"Error: {str(e)}"
28
-
29
- # ---- Your work content ----
30
- YOUR_WORK_HTML = """
31
- <div id="content" style="max-width: 800px; margin: auto; font-size: 16px; line-height: 1.6;">
32
- <h1>Text Generation</h1>
33
- <p>
34
- Text generation is the task of producing natural language text given an input prompt.
35
- It is commonly used for chatbots, creative writing, summarization, and code generation.
36
- </p>
37
- <p>
38
- Most modern text generation models are based on the transformer architecture and are
39
- trained using next-token prediction.
40
- </p>
41
- <p>
42
- During inference, the model repeatedly samples the most likely next token until a
43
- stopping condition is reached.
44
- </p>
45
- </div>
46
- """
47
-
48
- # ---- Hugging Face reference content ----
49
- HF_REFERENCE_HTML = """
50
- <div id="hf-content" style="max-width: 800px; margin: auto; font-size: 16px; line-height: 1.6;">
51
- <h1>Text Generation (Hugging Face Reference)</h1>
52
-
53
- <h1>About Text Generation</h1>
54
- <p>
55
- This task covers guides on both <a href="https://huggingface.co/models?pipeline_tag=text-generation&sort=downloads">text-generation</a> and <a href="https://huggingface.co/models?other=text2text-generation&sort=downloads">text-to-text generation</a> models.
56
- Popular large language models that are used for chats or following instructions are also covered in this task.
57
- You can find the list of selected open-source large language models <a href="https://huggingface.co/spaces/open-llm-leaderboard/open_llm_leaderboard">here</a>, ranked by their performance scores.
58
- </p>
59
-
60
- <h2>Use Cases</h2>
61
-
62
- <h3>Instruction Models</h3>
63
- <p>
64
- A model trained for text generation can be later adapted to follow instructions.
65
- You can try some of the most powerful instruction-tuned open-access models like Mixtral 8x7B, Cohere Command R+, and Meta Llama3 70B at <a href="https://huggingface.co/chat">Hugging Chat</a>.
66
- </p>
67
-
68
- <h3>Code Generation</h3>
69
- <p>
70
- A Text Generation model, also known as a causal language model, can be trained on code from scratch to help the programmers in their repetitive coding tasks.
71
- One of the most popular open-source models for code generation is StarCoder, which can generate code in 80+ languages. You can try it <a href="https://huggingface.co/spaces/bigcode/bigcode-playground">here</a>.
72
- </p>
73
-
74
- <h3>Stories Generation</h3>
75
- <p>
76
- A story generation model can receive an input like "Once upon a time" and proceed to create a story-like text based on those first words.
77
- You can try <a href="https://huggingface.co/spaces/mosaicml/mpt-7b-storywriter">this application</a> which contains a model trained on story generation, by MosaicML.
78
-
79
- If your generative model training data is different than your use case, you can train a causal language model from scratch.
80
- Learn how to do it in the free transformers <a href="https://huggingface.co/course/chapter7/6?fw=pt">course</a>!
81
- </p>
82
-
83
- <h2>Task Variants</h2>
84
-
85
- <h3>Completion Generation Models</h3>
86
- <p>
87
- A popular variant of Text Generation models predicts the next word given a bunch of words.
88
- Word by word a longer text is formed that results in for example:
89
-
90
- <ul>
91
- <li>Given an incomplete sentence, complete it.
92
- <li>Continue a story given the first sentences.
93
- <li>Provided a code description, generate the code.
94
- </ul>
95
-
96
- The most popular models for this task are GPT-based models, Mistral or Llama series.
97
- These models are trained on data that has no labels, so you just need plain text to train your own model.
98
- You can train text generation models to generate a wide variety of documents, from code to stories.
99
- </p>
100
-
101
- <h3>Text-to-Text Generation Models</h3>
102
- <p>
103
- These models are trained to learn the mapping between a pair of texts (e.g. translation from one language to another).
104
- The most popular variants of these models are NLLB, FLAN-T5, and BART.
105
- Text-to-Text models are trained with multi-tasking capabilities, they can accomplish a wide range of tasks, including summarization, translation, and text classification.
106
- </p>
107
-
108
- <h3>Language Model Variants</h3>
109
- When it comes to text generation, the underlying language model can come in several types:
110
-
111
- <ul>
112
- <li>Base models: refers to plain language models like Mistral 7B and Meta Llama-3-70b. These models are good for fine-tuning and few-shot prompting.
113
-
114
- <li>Instruction-trained models: these models are trained in a multi-task manner to follow a broad range of instructions like "Write me a recipe for chocolate cake". Models like Qwen 2 7B, Yi 1.5 34B Chat, and Meta Llama 70B Instruct are examples of instruction-trained models. In general, instruction-trained models will produce better responses to instructions than base models.
115
-
116
- <li>Human feedback models: these models extend base and instruction-trained models by incorporating human feedback that rates the quality of the generated text according to criteria like helpfulness, honesty, and harmlessness. The human feedback is then combined with an optimization technique like reinforcement learning to align the original model to be closer with human preferences. The overall methodology is often called Reinforcement Learning from Human Feedback, or RLHF for short. Zephyr ORPO 141B A35B is an open-source model aligned through human feedback.
117
- </ul>
118
-
119
- <h2>Text Generation from Image and Text</h2>
120
-
121
- <p>
122
- There are language models that can input both text and image and output text, called vision language models.
123
- IDEFICS 2 and MiniCPM Llama3 V are good examples.
124
- They accept the same generation parameters as other language models.
125
- However, since they also take images as input, you have to use them with the image-to-text pipeline.
126
- You can find more information about this in the image-to-text task page.
127
- </p>
128
-
129
- <h2>Inference</h2>
130
- <p>
131
- You can use the 🤗 Transformers library <code>text-generation</code> pipeline to do inference
132
- with text generation models. It takes an input text and generates a continuation of that text.
133
- </p>
134
-
135
- <pre style="background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto;">
136
- from transformers import pipeline
137
-
138
- generator = pipeline('text-generation', model='gpt2')
139
- generator("Hello, I'm a language model,", max_length=30, num_return_sequences=3)
140
- </pre>
141
-
142
- <h2>Text Generation Inference</h2>
143
- <p>
144
- Text Generation Inference (TGI) is an open-source toolkit for serving LLMs tackling challenges such as response time.
145
- TGI powers inference solutions like Inference Endpoints and Hugging Chat, as well as multiple community projects.
146
- You can use it to deploy any supported open-source large language model of your choice.
147
- </p>
148
-
149
- <h2>ChatUI Spaces</h2>
150
- <p>
151
- Hugging Face Spaces includes templates to easily deploy your own instance of a specific application.
152
- ChatUI is an open-source interface that enables serving conversational interface for large language models and can be deployed with few clicks at Spaces.
153
- TGI powers these Spaces under the hood for faster inference.
154
- Thanks to the template, you can deploy your own instance based on a large language model with only a few clicks and customize it. Learn more about it here and create your large language model instance here.
155
- </div>
156
- """
157
-
158
- def switch_content(choice):
159
- if choice == "My Work":
160
- return YOUR_WORK_HTML
161
- else:
162
- return HF_REFERENCE_HTML
163
-
164
- with gr.Blocks(head="""
165
- <script>
166
- document.addEventListener("mouseup", () => {
167
- const selection = window.getSelection().toString().trim();
168
- if (selection.length > 0) {
169
- const textbox = document.querySelector('textarea[data-testid="textbox"]');
170
- if (textbox) {
171
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
172
- window.HTMLTextAreaElement.prototype,
173
- "value"
174
- ).set;
175
- nativeInputValueSetter.call(textbox, selection);
176
- textbox.dispatchEvent(new Event("input", { bubbles: true }));
177
- }
178
  }
179
- });
180
- </script>
181
- """) as demo:
182
- gr.Markdown("### 📘 Highlight text and ask GPT for help")
183
-
184
- # Toggle between your work and HF reference
185
- view_toggle = gr.Radio(
186
- choices=["My Work", "HF Reference"],
187
- value="My Work",
188
- label="View",
189
- interactive=True
190
- )
191
-
192
- content_display = gr.HTML(YOUR_WORK_HTML)
193
-
194
- view_toggle.change(
195
- fn=switch_content,
196
- inputs=view_toggle,
197
- outputs=content_display
198
- )
199
-
200
- selected_text = gr.Textbox(
201
- label="Selected text",
202
- placeholder="Highlight text above...",
203
- lines=3,
204
- interactive=True
205
- )
206
-
207
- explain_btn = gr.Button("Explain selection 🧠")
208
- output = gr.Markdown()
209
-
210
- explain_btn.click(
211
- fn=explain_text,
212
- inputs=selected_text,
213
- outputs=output,
214
- )
215
-
216
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useCallback } from "react";
2
+
3
+ const CONTENT = `Text generation is the task of producing natural language text given an input prompt. It is commonly used for chatbots, creative writing, summarization, and code generation.
4
+
5
+ Most modern text generation models are based on the transformer architecture and are trained using next-token prediction. The transformer uses self-attention mechanisms to weigh the importance of different words in a sequence when making predictions.
6
+
7
+ During inference, the model repeatedly samples the most likely next token until a stopping condition is reached. This process is called autoregressive generation. Parameters like temperature and top-p sampling control the randomness and diversity of the output.
8
+
9
+ Large language models (LLMs) like GPT-4, Claude, and Llama are trained on vast corpora of text from the internet, books, and other sources. This gives them broad world knowledge and language understanding.
10
+
11
+ Instruction-tuned models are further trained to follow user instructions, using techniques like supervised fine-tuning (SFT) and reinforcement learning from human feedback (RLHF). This makes them much more useful as assistants compared to base language models.`;
12
+
13
+ const PARAGRAPHS = CONTENT.split("\n\n");
14
+
15
+ export default function App() {
16
+ const [segments, setSegments] = useState(() =>
17
+ PARAGRAPHS.map((text, i) => ({
18
+ id: i,
19
+ parts: [{ type: "text", content: text }],
20
+ }))
21
+ );
22
+ const [selection, setSelection] = useState(null);
23
+ const [loading, setLoading] = useState(false);
24
+ const [buttonPos, setButtonPos] = useState(null);
25
+ const contentRef = useRef(null);
26
+
27
+ const handleMouseUp = useCallback(() => {
28
+ const sel = window.getSelection();
29
+ if (!sel || sel.isCollapsed) {
30
+ setSelection(null);
31
+ setButtonPos(null);
32
+ return;
33
+ }
34
+ const text = sel.toString().trim();
35
+ if (!text || text.length < 3) {
36
+ setSelection(null);
37
+ setButtonPos(null);
38
+ return;
39
+ }
40
+
41
+ // Find which paragraph this belongs to
42
+ let paraIndex = -1;
43
+ let node = sel.anchorNode;
44
+ while (node && node !== contentRef.current) {
45
+ if (node.dataset && node.dataset.para !== undefined) {
46
+ paraIndex = parseInt(node.dataset.para);
47
+ break;
48
+ }
49
+ node = node.parentNode;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
+
52
+ if (paraIndex === -1) {
53
+ setSelection(null);
54
+ setButtonPos(null);
55
+ return;
56
+ }
57
+
58
+ const range = sel.getRangeAt(0);
59
+ const rect = range.getBoundingClientRect();
60
+ const containerRect = contentRef.current.getBoundingClientRect();
61
+
62
+ setSelection({ text, paraIndex });
63
+ setButtonPos({
64
+ top: rect.bottom - containerRect.top + 8,
65
+ left: Math.min(
66
+ rect.left - containerRect.left,
67
+ containerRect.width - 160
68
+ ),
69
+ });
70
+ }, []);
71
+
72
+ const handleExplain = async () => {
73
+ if (!selection || loading) return;
74
+ const { text, paraIndex } = selection;
75
+ setLoading(true);
76
+ setButtonPos(null);
77
+
78
+ try {
79
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({
83
+ model: "claude-sonnet-4-20250514",
84
+ max_tokens: 1000,
85
+ messages: [
86
+ {
87
+ role: "user",
88
+ content: `You are an ML instructor. A student highlighted this text from a learning resource and wants a clear, concise explanation (2-4 sentences, no fluff):\n\n"${text}"\n\nExplain it simply and intuitively.`,
89
+ },
90
+ ],
91
+ }),
92
+ });
93
+ const data = await response.json();
94
+ const explanation = data.content?.[0]?.text || "Could not generate explanation.";
95
+
96
+ // Replace the selected text in the paragraph with [original, explanation]
97
+ setSegments((prev) =>
98
+ prev.map((para) => {
99
+ if (para.id !== paraIndex) return para;
100
+ const newParts = [];
101
+ for (const part of para.parts) {
102
+ if (part.type !== "text") {
103
+ newParts.push(part);
104
+ continue;
105
+ }
106
+ const idx = part.content.indexOf(text);
107
+ if (idx === -1) {
108
+ newParts.push(part);
109
+ continue;
110
+ }
111
+ if (idx > 0) newParts.push({ type: "text", content: part.content.slice(0, idx) });
112
+ newParts.push({ type: "explained", original: text, explanation });
113
+ const after = part.content.slice(idx + text.length);
114
+ if (after) newParts.push({ type: "text", content: after });
115
+ }
116
+ return { ...para, parts: newParts };
117
+ })
118
+ );
119
+ } catch (e) {
120
+ console.error(e);
121
+ }
122
+
123
+ setSelection(null);
124
+ setLoading(false);
125
+ window.getSelection()?.removeAllRanges();
126
+ };
127
+
128
+ const dismissExplain = (paraId, partIdx) => {
129
+ setSegments((prev) =>
130
+ prev.map((para) => {
131
+ if (para.id !== paraId) return para;
132
+ const newParts = [...para.parts];
133
+ const part = newParts[partIdx];
134
+ if (part.type === "explained") {
135
+ newParts.splice(partIdx, 1, { type: "text", content: part.original });
136
+ }
137
+ // Merge adjacent text parts
138
+ const merged = [];
139
+ for (const p of newParts) {
140
+ if (p.type === "text" && merged.length && merged[merged.length - 1].type === "text") {
141
+ merged[merged.length - 1] = { type: "text", content: merged[merged.length - 1].content + p.content };
142
+ } else {
143
+ merged.push(p);
144
+ }
145
+ }
146
+ return { ...para, parts: merged };
147
+ })
148
+ );
149
+ };
150
+
151
+ return (
152
+ <div style={{ fontFamily: "'Georgia', serif", background: "#faf8f4", minHeight: "100vh", padding: "0" }}>
153
+ <style>{`
154
+ @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;1,8..60,300&display=swap');
155
+
156
+ * { box-sizing: border-box; }
157
+
158
+ .reader-wrap {
159
+ max-width: 680px;
160
+ margin: 0 auto;
161
+ padding: 60px 32px 120px;
162
+ }
163
+
164
+ h1.title {
165
+ font-family: 'Playfair Display', Georgia, serif;
166
+ font-size: 2.4rem;
167
+ font-weight: 600;
168
+ color: #1a1208;
169
+ margin: 0 0 8px;
170
+ letter-spacing: -0.5px;
171
+ line-height: 1.15;
172
+ }
173
+
174
+ .subtitle {
175
+ font-family: 'Source Serif 4', Georgia, serif;
176
+ font-size: 0.9rem;
177
+ color: #a09070;
178
+ margin: 0 0 48px;
179
+ letter-spacing: 0.05em;
180
+ text-transform: uppercase;
181
+ }
182
+
183
+ .hint {
184
+ font-family: 'Source Serif 4', Georgia, serif;
185
+ font-size: 0.85rem;
186
+ color: #b0a080;
187
+ margin: 0 0 36px;
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 8px;
191
+ }
192
+
193
+ .hint-icon {
194
+ width: 18px;
195
+ height: 18px;
196
+ background: #e8dfc8;
197
+ border-radius: 50%;
198
+ display: inline-flex;
199
+ align-items: center;
200
+ justify-content: center;
201
+ font-size: 10px;
202
+ flex-shrink: 0;
203
+ }
204
+
205
+ .para {
206
+ font-family: 'Source Serif 4', Georgia, serif;
207
+ font-size: 1.08rem;
208
+ line-height: 1.85;
209
+ color: #2c2010;
210
+ margin-bottom: 1.6em;
211
+ position: relative;
212
+ }
213
+
214
+ .explained-bubble {
215
+ display: inline;
216
+ position: relative;
217
+ }
218
+
219
+ .explained-original {
220
+ background: linear-gradient(120deg, #f5e6a3 0%, #f0d870 100%);
221
+ border-radius: 3px;
222
+ padding: 1px 3px;
223
+ text-decoration: line-through;
224
+ text-decoration-color: #c8a800;
225
+ color: #6b5a10;
226
+ font-style: italic;
227
+ opacity: 0.7;
228
+ font-size: 0.88em;
229
+ cursor: pointer;
230
+ }
231
+
232
+ .explained-text {
233
+ display: inline-block;
234
+ background: linear-gradient(135deg, #fffbee 0%, #fff8e0 100%);
235
+ border-left: 3px solid #d4a800;
236
+ border-radius: 0 6px 6px 0;
237
+ padding: 6px 12px 6px 12px;
238
+ margin: 4px 0;
239
+ color: #3a2e08;
240
+ font-size: 0.95rem;
241
+ line-height: 1.65;
242
+ box-shadow: 0 2px 8px rgba(180,140,0,0.1);
243
+ position: relative;
244
+ }
245
+
246
+ .dismiss-btn {
247
+ position: absolute;
248
+ top: 4px;
249
+ right: 6px;
250
+ background: none;
251
+ border: none;
252
+ color: #c8a800;
253
+ cursor: pointer;
254
+ font-size: 14px;
255
+ padding: 0 2px;
256
+ opacity: 0.6;
257
+ line-height: 1;
258
+ transition: opacity 0.15s;
259
+ }
260
+ .dismiss-btn:hover { opacity: 1; }
261
+
262
+ .float-btn {
263
+ position: absolute;
264
+ z-index: 100;
265
+ background: #1a1208;
266
+ color: #f5e8c0;
267
+ border: none;
268
+ border-radius: 6px;
269
+ padding: 8px 16px;
270
+ font-family: 'Source Serif 4', Georgia, serif;
271
+ font-size: 0.82rem;
272
+ letter-spacing: 0.04em;
273
+ cursor: pointer;
274
+ box-shadow: 0 4px 16px rgba(0,0,0,0.25);
275
+ display: flex;
276
+ align-items: center;
277
+ gap: 6px;
278
+ transition: background 0.15s, transform 0.1s;
279
+ white-space: nowrap;
280
+ }
281
+ .float-btn:hover { background: #2e2010; transform: translateY(-1px); }
282
+ .float-btn:active { transform: translateY(0); }
283
+
284
+ .loading-overlay {
285
+ position: fixed;
286
+ bottom: 28px;
287
+ left: 50%;
288
+ transform: translateX(-50%);
289
+ background: #1a1208;
290
+ color: #f5e8c0;
291
+ border-radius: 24px;
292
+ padding: 10px 22px;
293
+ font-family: 'Source Serif 4', Georgia, serif;
294
+ font-size: 0.85rem;
295
+ display: flex;
296
+ align-items: center;
297
+ gap: 10px;
298
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3);
299
+ }
300
+
301
+ .spinner {
302
+ width: 14px;
303
+ height: 14px;
304
+ border: 2px solid rgba(245,232,192,0.3);
305
+ border-top-color: #f5e8c0;
306
+ border-radius: 50%;
307
+ animation: spin 0.7s linear infinite;
308
+ }
309
+ @keyframes spin { to { transform: rotate(360deg); } }
310
+
311
+ .divider {
312
+ border: none;
313
+ border-top: 1px solid #e0d4b8;
314
+ margin: 40px 0;
315
+ }
316
+ `}</style>
317
+
318
+ <div className="reader-wrap">
319
+ <h1 className="title">Text Generation</h1>
320
+ <p className="subtitle">Machine Learning · Reading Guide</p>
321
+
322
+ <div className="hint">
323
+ <span className="hint-icon">✦</span>
324
+ Highlight any text, then click <em style={{ fontStyle: "italic", color: "#8a7450" }}>&nbsp;Explain&nbsp;</em> to replace it with an AI explanation.
325
+ </div>
326
+
327
+ <hr className="divider" />
328
+
329
+ <div ref={contentRef} style={{ position: "relative" }} onMouseUp={handleMouseUp}>
330
+ {segments.map((para) => (
331
+ <p key={para.id} className="para" data-para={para.id}>
332
+ {para.parts.map((part, pi) =>
333
+ part.type === "text" ? (
334
+ <span key={pi} data-para={para.id}>{part.content}</span>
335
+ ) : (
336
+ <span key={pi} className="explained-bubble">
337
+ {" "}
338
+ <span className="explained-text">
339
+ <button className="dismiss-btn" onClick={() => dismissExplain(para.id, pi)} title="Restore original">✕</button>
340
+ {part.explanation}
341
+ </span>{" "}
342
+ <span
343
+ className="explained-original"
344
+ title="Click to restore"
345
+ onClick={() => dismissExplain(para.id, pi)}
346
+ >
347
+ {part.original}
348
+ </span>
349
+ </span>
350
+ )
351
+ )}
352
+ </p>
353
+ ))}
354
+
355
+ {buttonPos && !loading && (
356
+ <button
357
+ className="float-btn"
358
+ style={{ top: buttonPos.top, left: buttonPos.left }}
359
+ onMouseDown={(e) => e.preventDefault()}
360
+ onClick={handleExplain}
361
+ >
362
+ ✦ Explain
363
+ </button>
364
+ )}
365
+ </div>
366
+ </div>
367
+
368
+ {loading && (
369
+ <div className="loading-overlay">
370
+ <div className="spinner" />
371
+ Generating explanation…
372
+ </div>
373
+ )}
374
+ </div>
375
+ );
376
+ }