GitHub Actions commited on
Commit
29530f8
·
1 Parent(s): dfa947a

fix: corpus dropdown, LLM error display, chat UX, invite endpoint, getting started text

Browse files

- ask.py: _get_corpora() scans rag.list_corpora + registry for rag.query entries
Refresh Corpora button added
LLM errors surfaced as text instead of silent 'No response'
- chat.py: show MY node_id, HF Space single-node note, broadcast with '*',
delivery status 'direct'/'queued' shown clearly, auto echo for self-msg
- getting_started.py: remove 'pip install hearthnet' (not on PyPI), link to HF Space
- settings.py: invite uses SPACE_HOST env var on HF Space (not 127.0.0.1)
- app.py: stable node_id from SPACE_HOST hash, register rag.list_corpora capability

app.py CHANGED
@@ -163,6 +163,10 @@ def _build_node():
163
  Uses HfLocalBackend (SmolLM2-135M) so inference works without Ollama.
164
  Falls back to _UnavailableBackend if transformers is not installed.
165
  """
 
 
 
 
166
  from hearthnet.node import HearthNode
167
  from hearthnet.services.chat.service import ChatService
168
  from hearthnet.services.demo import RagService as DemoRagService
@@ -171,10 +175,16 @@ def _build_node():
171
  from hearthnet.services.llm.service import LlmService
172
  from hearthnet.services.marketplace.service import MarketplaceService
173
 
 
 
 
 
 
 
174
  node = HearthNode(
175
- node_id="hf-space",
176
- display_name="HearthNet Space",
177
- community_id="ed25519:hf-space-demo",
178
  )
179
 
180
  # LLM — HF Transformers backend (SmolLM2 by default)
@@ -253,6 +263,17 @@ def _build_node():
253
  rag.documents = list(SEED_CORPUS)
254
  node.bus.register_service(rag)
255
 
 
 
 
 
 
 
 
 
 
 
 
256
  # Marketplace, Chat, Files
257
  node.bus.register_service(MarketplaceService())
258
  node.bus.register_service(ChatService(node.node_id))
 
163
  Uses HfLocalBackend (SmolLM2-135M) so inference works without Ollama.
164
  Falls back to _UnavailableBackend if transformers is not installed.
165
  """
166
+ import hashlib
167
+ import os
168
+ import socket
169
+
170
  from hearthnet.node import HearthNode
171
  from hearthnet.services.chat.service import ChatService
172
  from hearthnet.services.demo import RagService as DemoRagService
 
175
  from hearthnet.services.llm.service import LlmService
176
  from hearthnet.services.marketplace.service import MarketplaceService
177
 
178
+ # Generate a stable node_id from the HF Space hostname (so it doesn't change on restart)
179
+ _host = os.getenv("SPACE_HOST", socket.gethostname())
180
+ _suffix = hashlib.sha256(_host.encode()).hexdigest()[:8]
181
+ _node_id = f"hf-space-{_suffix}"
182
+ _display = os.getenv("SPACE_TITLE", f"HearthNet Space ({_suffix})")
183
+
184
  node = HearthNode(
185
+ node_id=_node_id,
186
+ display_name=_display,
187
+ community_id="ed25519:hf-space-community",
188
  )
189
 
190
  # LLM — HF Transformers backend (SmolLM2 by default)
 
263
  rag.documents = list(SEED_CORPUS)
264
  node.bus.register_service(rag)
265
 
266
+ # Register a synthetic rag.list_corpora so the Ask tab can discover corpora
267
+ from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
268
+
269
+ async def _list_corpora(req: RouteRequest) -> dict:
270
+ return {"output": {"corpora": ["community"]}, "meta": {}}
271
+
272
+ node.bus.register_capability(
273
+ CapabilityDescriptor(name="rag.list_corpora", version=(1, 0)),
274
+ _list_corpora,
275
+ )
276
+
277
  # Marketplace, Chat, Files
278
  node.bus.register_service(MarketplaceService())
279
  node.bus.register_service(ChatService(node.node_id))
hearthnet/ui/tabs/ask.py CHANGED
@@ -14,9 +14,40 @@ Spec: docs/M04-llm.md, docs/M05-rag.md, docs/M03-bus.md §4
14
  from __future__ import annotations
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def build_ask_tab(bus=None):
18
  import gradio as gr
19
 
 
 
 
20
  with gr.Column():
21
  gr.Markdown("""### 💬 Ask the Mesh
22
 
@@ -35,16 +66,17 @@ to the best available LLM node — either on this device or on a peer.
35
  with gr.Row():
36
  corpus_selector = gr.Dropdown(
37
  label="RAG Corpus (leave blank for direct LLM)",
38
- choices=["(none)"],
39
- value="(none)",
40
  scale=3,
41
  )
42
  model_selector = gr.Dropdown(
43
  label="Model (auto = bus picks best node)",
44
  choices=["auto"],
45
  value="auto",
46
- scale=3,
47
  )
 
48
 
49
  chatbot = gr.Chatbot(
50
  label="Conversation",
@@ -65,6 +97,10 @@ to the best available LLM node — either on this device or on a peer.
65
  sources_out = gr.JSON(label="📚 RAG Sources", visible=False, scale=2)
66
  route_out = gr.JSON(label="🛣️ Routing Trace", visible=False, scale=2)
67
 
 
 
 
 
68
  async def handle_send(message: str, history: list, corpus: str, model: str):
69
  if not message.strip():
70
  return history, "", gr.update(visible=False), gr.update(visible=False)
@@ -132,14 +168,25 @@ to the best available LLM node — either on this device or on a peer.
132
  (1, 0),
133
  {"params": params, "input": {"messages": llm_messages}},
134
  )
135
- reply = result.get("output", {}).get("message", {}).get("content", "No response")
136
- routed_via_llm = result.get("_routed_via", "local")
137
- trace["llm"] = {
138
- "capability": "llm.chat",
139
- "model_requested": model if model != "auto" else "(any)",
140
- "routed_via": routed_via_llm,
141
- }
142
- trace["routed_to"] = routed_via_llm
 
 
 
 
 
 
 
 
 
 
 
143
 
144
  history.append({"role": "assistant", "content": reply})
145
 
@@ -160,6 +207,7 @@ to the best available LLM node — either on this device or on a peer.
160
  gr.update(visible=True, value=trace),
161
  )
162
 
 
163
  send_btn.click(
164
  handle_send,
165
  inputs=[msg_input, chatbot, corpus_selector, model_selector],
 
14
  from __future__ import annotations
15
 
16
 
17
+ def _get_corpora(bus) -> list[str]:
18
+ """Scan the bus registry for all rag.query corpus names."""
19
+ if bus is None:
20
+ return []
21
+ corpora: list[str] = []
22
+ try:
23
+ # Try rag.list_corpora capability first (real RagService has it)
24
+ import asyncio
25
+ loop = asyncio.new_event_loop()
26
+ r = loop.run_until_complete(bus.call("rag.list_corpora", (1, 0), {"input": {}}))
27
+ loop.close()
28
+ corpora = r.get("output", {}).get("corpora", [])
29
+ except Exception:
30
+ pass
31
+ if not corpora:
32
+ # Fallback: inspect registry for rag.query entries and extract corpus param
33
+ try:
34
+ all_entries = list(bus.registry.all())
35
+ for entry in all_entries:
36
+ if entry.descriptor.name == "rag.query":
37
+ corpus = (entry.descriptor.params or {}).get("corpus")
38
+ if corpus and corpus not in corpora:
39
+ corpora.append(corpus)
40
+ except Exception:
41
+ pass
42
+ return corpora
43
+
44
+
45
  def build_ask_tab(bus=None):
46
  import gradio as gr
47
 
48
+ corpora = _get_corpora(bus)
49
+ corpus_choices = ["(none)"] + corpora
50
+
51
  with gr.Column():
52
  gr.Markdown("""### 💬 Ask the Mesh
53
 
 
66
  with gr.Row():
67
  corpus_selector = gr.Dropdown(
68
  label="RAG Corpus (leave blank for direct LLM)",
69
+ choices=corpus_choices,
70
+ value=corpus_choices[0],
71
  scale=3,
72
  )
73
  model_selector = gr.Dropdown(
74
  label="Model (auto = bus picks best node)",
75
  choices=["auto"],
76
  value="auto",
77
+ scale=2,
78
  )
79
+ refresh_corpora_btn = gr.Button("🔄 Refresh Corpora", size="sm", scale=1)
80
 
81
  chatbot = gr.Chatbot(
82
  label="Conversation",
 
97
  sources_out = gr.JSON(label="📚 RAG Sources", visible=False, scale=2)
98
  route_out = gr.JSON(label="🛣️ Routing Trace", visible=False, scale=2)
99
 
100
+ def refresh_corpora():
101
+ choices = ["(none)"] + _get_corpora(bus)
102
+ return gr.update(choices=choices, value=choices[0])
103
+
104
  async def handle_send(message: str, history: list, corpus: str, model: str):
105
  if not message.strip():
106
  return history, "", gr.update(visible=False), gr.update(visible=False)
 
168
  (1, 0),
169
  {"params": params, "input": {"messages": llm_messages}},
170
  )
171
+
172
+ # Surface errors clearly instead of showing "No response"
173
+ if "error" in result:
174
+ err_msg = result.get("message", result.get("error", "unknown error"))
175
+ reply = f"⚠️ LLM error: {err_msg}"
176
+ trace["llm"] = {"error": err_msg}
177
+ else:
178
+ reply = (
179
+ result.get("output", {}).get("message", {}).get("content")
180
+ or result.get("output", {}).get("text")
181
+ or "(empty response — model may still be loading)"
182
+ )
183
+ routed_via_llm = result.get("_routed_via", "local")
184
+ trace["llm"] = {
185
+ "capability": "llm.chat",
186
+ "model_requested": model if model != "auto" else "(any)",
187
+ "routed_via": routed_via_llm,
188
+ }
189
+ trace["routed_to"] = routed_via_llm
190
 
191
  history.append({"role": "assistant", "content": reply})
192
 
 
207
  gr.update(visible=True, value=trace),
208
  )
209
 
210
+ refresh_corpora_btn.click(refresh_corpora, outputs=corpus_selector)
211
  send_btn.click(
212
  handle_send,
213
  inputs=[msg_input, chatbot, corpus_selector, model_selector],
hearthnet/ui/tabs/chat.py CHANGED
@@ -6,71 +6,121 @@ from __future__ import annotations
6
  def build_chat_tab(bus=None):
7
  import gradio as gr
8
 
 
 
9
  with gr.Column():
10
  gr.Markdown("""### 💬 Direct Messages
11
 
12
  Send and receive messages between HearthNet nodes (M10).
13
  Messages are **event-sourced** with Lamport clocks — delivery order is deterministic
14
  even when nodes reconnect after an offline period.
 
 
 
 
 
 
 
15
 
 
16
  **How to use:**
17
- 1. Enter the recipient's Node ID (copy from their Settings tab)
18
- 2. Click **Load History** to see past messages
19
  3. Type a message and press **Send**
20
 
21
- The delivery confirmation shows whether the message was:
 
 
 
22
  - `queued` — stored locally, will deliver when recipient reconnects
23
- - `delivered` — recipient acknowledged receipt
 
 
 
24
  """)
25
 
26
  with gr.Row():
27
- peer_id = gr.Textbox(label="Recipient Node ID", placeholder="ed25519:...", scale=4)
 
 
 
 
28
  history_btn = gr.Button("Load History", scale=1)
29
 
30
- chat_out = gr.Chatbot(label="Messages", height=300)
31
 
32
  with gr.Row():
33
- msg_input = gr.Textbox(label="Message", scale=7)
34
  send_btn = gr.Button("Send", scale=1, variant="primary")
35
- send_result = gr.JSON(visible=False)
 
36
 
37
  async def load_history(peer):
38
  if bus is None:
39
- return [{"role": "assistant", "content": "Bus not connected"}]
 
40
  try:
41
- r = await bus.call("chat.history", (1, 0), {"input": {"peer": peer or None}})
42
  msgs = r.get("output", {}).get("messages", [])
 
 
43
  result = []
 
44
  for m in msgs:
45
- result.append(
46
- {"role": "user", "content": f"[{m.get('from', '?')}]: {m.get('body', '')}"}
47
- )
 
 
 
48
  return result
49
  except Exception as e:
50
- return [{"role": "assistant", "content": f"Error: {e}"}]
51
 
52
  async def send_msg(peer, msg, history):
53
- if not peer or not msg:
54
  return history, "", gr.update(visible=False)
55
  history = history or []
56
  if bus is None:
57
- history = history + [
58
- {"role": "user", "content": msg},
59
- {"role": "assistant", "content": "⚠️ Bus not connected"},
60
- ]
61
- return history, "", gr.update(visible=False)
62
- try:
63
- r = await bus.call(
64
- "chat.send",
65
- (1, 0),
66
- {"input": {"recipient": peer, "body": msg}},
67
  )
68
- status = r.get("output", {}).get("delivered", "sent")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  history = history + [
70
  {"role": "user", "content": msg},
71
- {"role": "assistant", "content": f"✓ delivered={status}"},
72
  ]
73
- return history, "", gr.update(visible=True, value=r.get("output"))
 
 
 
 
74
  except Exception as e:
75
  history = history + [
76
  {"role": "user", "content": msg},
@@ -82,5 +132,10 @@ The delivery confirmation shows whether the message was:
82
  send_btn.click(
83
  send_msg,
84
  inputs=[peer_id, msg_input, chat_out],
85
- outputs=[chat_out, msg_input, send_result],
 
 
 
 
 
86
  )
 
6
  def build_chat_tab(bus=None):
7
  import gradio as gr
8
 
9
+ my_node_id = getattr(bus, "node_id_full", None) if bus else None
10
+
11
  with gr.Column():
12
  gr.Markdown("""### 💬 Direct Messages
13
 
14
  Send and receive messages between HearthNet nodes (M10).
15
  Messages are **event-sourced** with Lamport clocks — delivery order is deterministic
16
  even when nodes reconnect after an offline period.
17
+ """)
18
+
19
+ if my_node_id:
20
+ gr.Markdown(
21
+ f"**Your Node ID** (share this so others can message you):\n\n"
22
+ f"```\n{my_node_id}\n```"
23
+ )
24
 
25
+ gr.Markdown("""
26
  **How to use:**
27
+ 1. Enter a **Recipient Node ID** copy it from their Settings tab
28
+ 2. Click **Load History** to see past messages
29
  3. Type a message and press **Send**
30
 
31
+ **Send to all peers:** use `*` as the recipient to broadcast to every known peer.
32
+
33
+ **Delivery status:**
34
+ - `direct` — sent to yourself (same node)
35
  - `queued` — stored locally, will deliver when recipient reconnects
36
+ - `delivered` — recipient on a live peer node acknowledged receipt
37
+
38
+ > On the **HF Space** (single node): only self-messages (`direct`) work.
39
+ > For real peer messaging, run two local nodes — see Getting Started.
40
  """)
41
 
42
  with gr.Row():
43
+ peer_id = gr.Textbox(
44
+ label="Recipient Node ID",
45
+ placeholder=f"e.g. {my_node_id or 'ed25519:...'} (use * for broadcast)",
46
+ scale=4,
47
+ )
48
  history_btn = gr.Button("Load History", scale=1)
49
 
50
+ chat_out = gr.Chatbot(label="Messages", height=340)
51
 
52
  with gr.Row():
53
+ msg_input = gr.Textbox(label="Message", placeholder="Type a message…", scale=7)
54
  send_btn = gr.Button("Send", scale=1, variant="primary")
55
+
56
+ status_out = gr.Markdown(visible=False)
57
 
58
  async def load_history(peer):
59
  if bus is None:
60
+ return [{"role": "assistant", "content": "⚠️ Bus not connected"}]
61
+ target = peer.strip() if peer else None
62
  try:
63
+ r = await bus.call("chat.history", (1, 0), {"input": {"peer": target}})
64
  msgs = r.get("output", {}).get("messages", [])
65
+ if not msgs:
66
+ return [{"role": "assistant", "content": "(no messages yet)"}]
67
  result = []
68
+ node_me = getattr(bus, "node_id_full", "me")
69
  for m in msgs:
70
+ sender = m.get("from", "?")
71
+ is_mine = sender == node_me
72
+ result.append({
73
+ "role": "user" if is_mine else "assistant",
74
+ "content": f"{'You' if is_mine else sender}: {m.get('body', '')}",
75
+ })
76
  return result
77
  except Exception as e:
78
+ return [{"role": "assistant", "content": f"Error loading history: {e}"}]
79
 
80
  async def send_msg(peer, msg, history):
81
+ if not msg.strip():
82
  return history, "", gr.update(visible=False)
83
  history = history or []
84
  if bus is None:
85
+ return (
86
+ history + [
87
+ {"role": "user", "content": msg},
88
+ {"role": "assistant", "content": "⚠️ Bus not connected"},
89
+ ],
90
+ "",
91
+ gr.update(visible=False),
 
 
 
92
  )
93
+
94
+ # Broadcast to all peers if * used
95
+ recipient = peer.strip() if peer else getattr(bus, "node_id_full", "")
96
+ if recipient == "*":
97
+ # Send to all known peers
98
+ peers_snapshot = getattr(bus, "topology_snapshot", lambda: None)()
99
+ all_peers = [p.get("node_id") for p in (getattr(peers_snapshot, "peers", []) or [])]
100
+ if not all_peers:
101
+ all_peers = [getattr(bus, "node_id_full", recipient)]
102
+ results = []
103
+ for p in all_peers:
104
+ try:
105
+ r = await bus.call("chat.send", (1, 0), {"input": {"recipient": p, "body": msg}})
106
+ results.append(r.get("output", {}).get("delivered", "queued"))
107
+ except Exception:
108
+ results.append("error")
109
+ history = history + [{"role": "user", "content": f"[broadcast to {len(all_peers)} peers] {msg}"}]
110
+ note = f"✓ Broadcast sent to {len(all_peers)} peer(s): {results}"
111
+ return history, "", gr.update(visible=True, value=note)
112
+
113
+ try:
114
+ r = await bus.call("chat.send", (1, 0), {"input": {"recipient": recipient, "body": msg}})
115
+ status = r.get("output", {}).get("delivered", "queued")
116
  history = history + [
117
  {"role": "user", "content": msg},
 
118
  ]
119
+ if status == "direct":
120
+ # Self-message — also show it as received
121
+ history.append({"role": "assistant", "content": f"[echo] {msg}"})
122
+ note = f"✓ {status} → `{recipient}`"
123
+ return history, "", gr.update(visible=True, value=note)
124
  except Exception as e:
125
  history = history + [
126
  {"role": "user", "content": msg},
 
132
  send_btn.click(
133
  send_msg,
134
  inputs=[peer_id, msg_input, chat_out],
135
+ outputs=[chat_out, msg_input, status_out],
136
+ )
137
+ msg_input.submit(
138
+ send_msg,
139
+ inputs=[peer_id, msg_input, chat_out],
140
+ outputs=[chat_out, msg_input, status_out],
141
  )
hearthnet/ui/tabs/getting_started.py CHANGED
@@ -18,23 +18,20 @@ capabilities, files, and community posts — no central server required.
18
  ## Quick Start (any device with Python)
19
 
20
  ```bash
21
- # 1. Install from source (pip install hearthnet coming once published to PyPI)
22
  git clone https://huggingface.co/spaces/build-small-hackathon/HearthNet
23
  cd HearthNet
24
  pip install -e .
25
 
26
- # 2. Run a node
27
  python -m hearthnet.cli run
28
 
29
  # 3. Open the UI
30
  # http://localhost:7860
31
  ```
32
 
33
- Other devices on the **same Wi-Fi/LAN discover this node automatically** (mDNS).
34
- No configuration needed for same-network peers.
35
-
36
- > **PyPI package**: `pip install hearthnet` will work once the package is published.
37
- > Until then use `pip install -e .` from the cloned repo.
38
 
39
  ---
40
 
 
18
  ## Quick Start (any device with Python)
19
 
20
  ```bash
21
+ # 1. Clone the repo (PyPI package coming soon use git clone for now)
22
  git clone https://huggingface.co/spaces/build-small-hackathon/HearthNet
23
  cd HearthNet
24
  pip install -e .
25
 
26
+ # 2. Run your local node
27
  python -m hearthnet.cli run
28
 
29
  # 3. Open the UI
30
  # http://localhost:7860
31
  ```
32
 
33
+ The **HF Space** above is the public demo single node, SmolLM2-135M, no real peer mesh.
34
+ A **local install** gives you Ollama/llama.cpp models, real peer discovery, file sharing, and chat.
 
 
 
35
 
36
  ---
37
 
hearthnet/ui/tabs/settings.py CHANGED
@@ -158,37 +158,56 @@ Set `relay_url` in `~/.hearthnet/config.toml` for cross-internet connections.
158
  if bus is None:
159
  return "<p style='color:#f44'>Bus not connected — run as a real node.</p>", ""
160
  try:
 
161
  from pathlib import Path
162
 
163
  from hearthnet.identity.keys import load_or_generate
164
  from hearthnet.ui.onboarding import encode_invite, make_invite
165
 
166
  kp = load_or_generate(Path.home() / ".hearthnet" / "keys")
 
 
 
 
 
 
 
 
 
 
 
 
167
  cm_prov = getattr(bus, "community_manifest_provider", None)
168
  cm = cm_prov() if cm_prov else None
169
  if cm is None:
170
- port_obj = getattr(config, "transport", None)
171
- port_val = getattr(port_obj, "port", 7080) if port_obj else 7080
172
  link = (
173
  f"hnvite://v1/{bus.node_id_full}"
174
- f"?host=127.0.0.1&port={port_val}&level={level}"
175
  )
176
- return _qr_svg(link), link
177
- from hearthnet.identity.manifest import Endpoint
178
-
179
- blob = make_invite(
180
- invitee_node_id_full=invitee or "ed25519:any",
181
- inviter_kp=kp,
182
- community_manifest=cm,
183
- bootstrap_endpoints=[
184
- Endpoint(transport="http", host="127.0.0.1", port=7080)
185
- ],
186
- initial_level=level,
187
- )
188
- link = encode_invite(blob)
189
- return _qr_svg(link), link
 
 
 
 
 
 
 
 
190
  except Exception as exc:
191
- return f"<p style='color:#f44'>Error: {exc}</p>", ""
192
 
193
  make_invite_btn.click(
194
  gen_invite,
 
158
  if bus is None:
159
  return "<p style='color:#f44'>Bus not connected — run as a real node.</p>", ""
160
  try:
161
+ import os
162
  from pathlib import Path
163
 
164
  from hearthnet.identity.keys import load_or_generate
165
  from hearthnet.ui.onboarding import encode_invite, make_invite
166
 
167
  kp = load_or_generate(Path.home() / ".hearthnet" / "keys")
168
+ # Detect whether we're on HF Space or local
169
+ hf_space_host = os.getenv("SPACE_HOST") # e.g. build-small-hackathon-hearthnet.hf.space
170
+ if hf_space_host:
171
+ public_host = hf_space_host
172
+ public_port = 443
173
+ transport = "https"
174
+ else:
175
+ port_obj = getattr(config, "transport", None)
176
+ public_port = getattr(port_obj, "port", 7080) if port_obj else 7080
177
+ public_host = "127.0.0.1"
178
+ transport = "http"
179
+
180
  cm_prov = getattr(bus, "community_manifest_provider", None)
181
  cm = cm_prov() if cm_prov else None
182
  if cm is None:
 
 
183
  link = (
184
  f"hnvite://v1/{bus.node_id_full}"
185
+ f"?host={public_host}&port={public_port}&transport={transport}&level={level}"
186
  )
187
+ qr_data = link
188
+ else:
189
+ from hearthnet.identity.manifest import Endpoint
190
+
191
+ blob = make_invite(
192
+ invitee_node_id_full=invitee or "ed25519:any",
193
+ inviter_kp=kp,
194
+ community_manifest=cm,
195
+ bootstrap_endpoints=[
196
+ Endpoint(transport=transport, host=public_host, port=public_port)
197
+ ],
198
+ initial_level=level,
199
+ )
200
+ link = encode_invite(blob)
201
+ qr_data = link
202
+
203
+ note = ""
204
+ if hf_space_host:
205
+ note = f"\n\n> ℹ️ This invite uses the **HF Space URL** (`{public_host}`). Peers outside the Space can use it."
206
+ else:
207
+ note = f"\n\n> ℹ️ Host is `{public_host}:{public_port}`. Make sure this is reachable by the invitee."
208
+ return _qr_svg(qr_data), link + note
209
  except Exception as exc:
210
+ return f"<p style='color:#f44'>Error: {exc}</p>", f"Error: {exc}"
211
 
212
  make_invite_btn.click(
213
  gen_invite,