LazyHuman10 commited on
Commit
1c844ec
·
1 Parent(s): 0328993

Replace inert HTML shell with working Gradio UI

Browse files
Files changed (1) hide show
  1. app.py +297 -14
app.py CHANGED
@@ -5,7 +5,6 @@ from __future__ import annotations
5
  import base64
6
  import io
7
  import json
8
- import os
9
  from typing import Any
10
 
11
  import gradio as gr
@@ -93,10 +92,211 @@ def get_share_card(npc_json: str) -> dict:
93
  return {"image_b64": f"data:image/png;base64,{image_b64}"}
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  def build_app() -> gr.Blocks:
97
- """Create the Gradio app with custom HTML and hidden API endpoints."""
98
- with gr.Blocks(title="NPCverse") as demo:
99
- gr.HTML(_load_index_html())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  with gr.Group(visible=False):
102
  image_path = gr.File(label="image_path", type="filepath")
@@ -143,16 +343,6 @@ def build_app() -> gr.Blocks:
143
  return demo
144
 
145
 
146
- def _load_index_html() -> str:
147
- """Load the custom frontend HTML if available."""
148
- index_path = os.path.join(os.path.dirname(__file__), "index.html")
149
- try:
150
- with open(index_path, "r", encoding="utf-8") as index_file:
151
- return index_file.read()
152
- except FileNotFoundError:
153
- return "<main><h1>NPCverse</h1><p>UI loading...</p></main>"
154
-
155
-
156
  def _extract_file_path(file_value: Any) -> str:
157
  """Extract an uploaded file path from Gradio's possible file payloads."""
158
  if isinstance(file_value, str):
@@ -199,6 +389,99 @@ def _json_safe_dict(payload: dict) -> dict:
199
  return json.loads(json.dumps(payload, ensure_ascii=False))
200
 
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  demo = build_app()
203
 
204
 
 
5
  import base64
6
  import io
7
  import json
 
8
  from typing import Any
9
 
10
  import gradio as gr
 
92
  return {"image_b64": f"data:image/png;base64,{image_b64}"}
93
 
94
 
95
+ @spaces.GPU
96
+ def ui_summon(image_path: Any) -> tuple[dict, list, int, list, str, str, list]:
97
+ """Summon an NPC and prepare the visible UI state."""
98
+ npc = summon_npc(image_path)
99
+ opening_line = str(npc.get("opening_line", "The veil parts. I have arrived."))
100
+ chat_messages = [
101
+ {
102
+ "role": "assistant",
103
+ "content": f"{npc.get('emoji', '✦')} **{npc.get('name', 'NPC')}**: {opening_line}",
104
+ }
105
+ ]
106
+ return (
107
+ npc,
108
+ [{"role": "assistant", "content": opening_line}],
109
+ 0,
110
+ [],
111
+ _render_character_card(npc),
112
+ _render_quests(npc),
113
+ chat_messages,
114
+ )
115
+
116
+
117
+ @spaces.GPU
118
+ def ui_chat(
119
+ user_text: str,
120
+ npc: dict,
121
+ history: list,
122
+ msg_count: int,
123
+ unlocked_secrets: list,
124
+ visible_chat: list,
125
+ ) -> tuple[str, list, list, int, list, str, str]:
126
+ """Send a chat message and update visible chat/progression state."""
127
+ if not user_text:
128
+ return "", history, visible_chat, msg_count, unlocked_secrets, _friendship_status(msg_count), ""
129
+ if not isinstance(npc, dict) or not npc:
130
+ return (
131
+ "",
132
+ history,
133
+ visible_chat,
134
+ msg_count,
135
+ unlocked_secrets,
136
+ _friendship_status(msg_count),
137
+ "*Summon an NPC before opening a dialogue.*",
138
+ )
139
+
140
+ result = chat_with_npc(
141
+ npc_json=json.dumps(npc, ensure_ascii=False),
142
+ history_json=json.dumps(history or [], ensure_ascii=False),
143
+ user_message=user_text,
144
+ msg_count=_safe_int(msg_count),
145
+ unlocked_secrets_json=json.dumps(unlocked_secrets or []),
146
+ )
147
+
148
+ response = str(result.get("response", "The realm connection falters..."))
149
+ new_msg_count = _safe_int(result.get("new_msg_count"))
150
+ newly_unlocked = result.get("newly_unlocked", [])
151
+ secret_texts = result.get("secret_texts", [])
152
+
153
+ new_history = [
154
+ *(history or []),
155
+ {"role": "user", "content": user_text},
156
+ {"role": "assistant", "content": response},
157
+ ][-20:]
158
+ new_visible_chat = [
159
+ *(visible_chat or []),
160
+ {"role": "user", "content": user_text},
161
+ {
162
+ "role": "assistant",
163
+ "content": f"{npc.get('emoji', '✦')} **{npc.get('name', 'NPC')}**: {response}",
164
+ },
165
+ ]
166
+ new_unlocked = list(dict.fromkeys([*(unlocked_secrets or []), *newly_unlocked]))
167
+ secret_md = _render_secret_unlocks(secret_texts)
168
+
169
+ return (
170
+ "",
171
+ new_history,
172
+ new_visible_chat,
173
+ new_msg_count,
174
+ new_unlocked,
175
+ _friendship_status(new_msg_count),
176
+ secret_md,
177
+ )
178
+
179
+
180
+ def ui_new_quest(npc: dict, history: list, msg_count: int, unlocked_secrets: list, visible_chat: list):
181
+ """Ask the NPC for a new quest."""
182
+ return ui_chat("Give me a new quest", npc, history, msg_count, unlocked_secrets, visible_chat)
183
+
184
+
185
+ def ui_share_card(npc: dict):
186
+ """Generate a visible PIL share card image."""
187
+ if not isinstance(npc, dict) or not npc:
188
+ npc = DEFAULT_NPC
189
+ return generate_share_card(npc)
190
+
191
+
192
  def build_app() -> gr.Blocks:
193
+ """Create the Gradio app with visible UI and hidden API endpoints."""
194
+ with gr.Blocks(title="NPCverse", css=APP_CSS) as demo:
195
+ npc_state = gr.State({})
196
+ history_state = gr.State([])
197
+ msg_count_state = gr.State(0)
198
+ unlocked_secrets_state = gr.State([])
199
+ visible_chat_state = gr.State([])
200
+
201
+ gr.Markdown(
202
+ """
203
+ # ⚔ NPCverse
204
+ Turn anyone into a living RPG character
205
+ """,
206
+ elem_classes=["hero"],
207
+ )
208
+
209
+ with gr.Row(elem_classes=["app-grid"]):
210
+ with gr.Column(scale=4, elem_classes=["left-column"]):
211
+ image_upload = gr.Image(
212
+ label="Drop a portrait into the rift",
213
+ sources=["upload", "webcam", "clipboard"],
214
+ type="filepath",
215
+ height=300,
216
+ )
217
+ summon_visible = gr.Button("⚡ SUMMON NPC", variant="primary")
218
+ character_md = gr.Markdown("Summon an NPC to reveal their character sheet.")
219
+ share_visible = gr.Button("📤 Export Share Card")
220
+ share_image = gr.Image(label="Generated NPC share card", type="pil")
221
+
222
+ with gr.Column(scale=6, elem_classes=["right-column"]):
223
+ friendship_md = gr.Markdown(_friendship_status(0))
224
+ secret_md = gr.Markdown("")
225
+ chat_box = gr.Chatbot(
226
+ label="Dialogue",
227
+ type="messages",
228
+ height=430,
229
+ show_copy_button=True,
230
+ )
231
+ with gr.Row():
232
+ chat_input = gr.Textbox(
233
+ label="Message",
234
+ placeholder="Speak to your summoned NPC...",
235
+ scale=5,
236
+ )
237
+ send_visible = gr.Button("Send", scale=1)
238
+ quests_md = gr.Markdown("No active quests yet.")
239
+ new_quest_visible = gr.Button("New Quest")
240
+
241
+ summon_visible.click(
242
+ fn=ui_summon,
243
+ inputs=[image_upload],
244
+ outputs=[
245
+ npc_state,
246
+ history_state,
247
+ msg_count_state,
248
+ unlocked_secrets_state,
249
+ character_md,
250
+ quests_md,
251
+ chat_box,
252
+ ],
253
+ ).then(lambda messages: messages, inputs=[chat_box], outputs=[visible_chat_state])
254
+
255
+ chat_outputs = [
256
+ chat_input,
257
+ history_state,
258
+ chat_box,
259
+ msg_count_state,
260
+ unlocked_secrets_state,
261
+ friendship_md,
262
+ secret_md,
263
+ ]
264
+ send_visible.click(
265
+ fn=ui_chat,
266
+ inputs=[
267
+ chat_input,
268
+ npc_state,
269
+ history_state,
270
+ msg_count_state,
271
+ unlocked_secrets_state,
272
+ visible_chat_state,
273
+ ],
274
+ outputs=chat_outputs,
275
+ ).then(lambda messages: messages, inputs=[chat_box], outputs=[visible_chat_state])
276
+ chat_input.submit(
277
+ fn=ui_chat,
278
+ inputs=[
279
+ chat_input,
280
+ npc_state,
281
+ history_state,
282
+ msg_count_state,
283
+ unlocked_secrets_state,
284
+ visible_chat_state,
285
+ ],
286
+ outputs=chat_outputs,
287
+ ).then(lambda messages: messages, inputs=[chat_box], outputs=[visible_chat_state])
288
+ new_quest_visible.click(
289
+ fn=ui_new_quest,
290
+ inputs=[
291
+ npc_state,
292
+ history_state,
293
+ msg_count_state,
294
+ unlocked_secrets_state,
295
+ visible_chat_state,
296
+ ],
297
+ outputs=chat_outputs,
298
+ ).then(lambda messages: messages, inputs=[chat_box], outputs=[visible_chat_state])
299
+ share_visible.click(fn=ui_share_card, inputs=[npc_state], outputs=[share_image])
300
 
301
  with gr.Group(visible=False):
302
  image_path = gr.File(label="image_path", type="filepath")
 
343
  return demo
344
 
345
 
 
 
 
 
 
 
 
 
 
 
346
  def _extract_file_path(file_value: Any) -> str:
347
  """Extract an uploaded file path from Gradio's possible file payloads."""
348
  if isinstance(file_value, str):
 
389
  return json.loads(json.dumps(payload, ensure_ascii=False))
390
 
391
 
392
+ def _render_character_card(npc: dict) -> str:
393
+ """Render NPC data as markdown for the visible app."""
394
+ stats = npc.get("stats", {}) if isinstance(npc.get("stats"), dict) else {}
395
+ passive = npc.get("passive_ability", {}) if isinstance(npc.get("passive_ability"), dict) else {}
396
+ ultimate = npc.get("ultimate", {}) if isinstance(npc.get("ultimate"), dict) else {}
397
+ stat_lines = "\n".join(
398
+ f"- **{name.title()}**: `{stats.get(name, 50)}`"
399
+ for name in ["strength", "intelligence", "charisma", "luck", "stealth", "chaos"]
400
+ )
401
+ return f"""
402
+ ## {npc.get("emoji", "✦")} {npc.get("name", "Unknown Hero")}
403
+ **{npc.get("class", "Adventurer")} — Lv.{npc.get("level", 1)}**
404
+ `{npc.get("rarity", "Common")}` · `{npc.get("alignment", "Unaligned")}`
405
+
406
+ {stat_lines}
407
+
408
+ ### Passive: {passive.get("name", "Hidden Spark")}
409
+ {passive.get("description", "A quiet power waits beneath the surface.")}
410
+
411
+ ### Ultimate: {ultimate.get("name", "Final Gambit")}
412
+ {ultimate.get("description", "Turns desperation into one decisive moment.")}
413
+
414
+ *{npc.get("lore", "A mysterious figure steps into legend.")}*
415
+
416
+ **World:** {npc.get("world", "Unknown Realm")}
417
+ **Faction:** {npc.get("faction", "Unaffiliated")}
418
+ """
419
+
420
+
421
+ def _render_quests(npc: dict) -> str:
422
+ """Render NPC quest list as markdown."""
423
+ quests = npc.get("quests", []) if isinstance(npc.get("quests"), list) else []
424
+ if not quests:
425
+ return "No active quests yet."
426
+ quest_blocks = []
427
+ for quest in quests[:3]:
428
+ if not isinstance(quest, dict):
429
+ continue
430
+ quest_blocks.append(
431
+ f"### {quest.get('title', 'Unnamed Quest')}\n"
432
+ f"{quest.get('description', '')}\n\n"
433
+ f"**Reward:** {quest.get('reward', 'Unknown')} · `{quest.get('rarity', 'Common')}`"
434
+ )
435
+ return "\n\n---\n\n".join(quest_blocks) or "No active quests yet."
436
+
437
+
438
+ def _friendship_status(msg_count: int) -> str:
439
+ """Render the friendship meter as markdown."""
440
+ pct = min(100.0, (_safe_int(msg_count) / 55) * 100)
441
+ label = get_friendship_label(_safe_int(msg_count))
442
+ return f"## {label}\nProgress: **{pct:.0f}%**"
443
+
444
+
445
+ def _render_secret_unlocks(secret_texts: list) -> str:
446
+ """Render newly unlocked secrets."""
447
+ if not secret_texts:
448
+ return ""
449
+ return "\n\n".join(f"### Secret Revealed\n*{secret}*" for secret in secret_texts)
450
+
451
+
452
+ APP_CSS = """
453
+ :root {
454
+ --bg-void: #0d0d1a;
455
+ --bg-dark: #1a1a2e;
456
+ --bg-card: #16213e;
457
+ --gold: #c9a227;
458
+ --text-gold: #e8d5b7;
459
+ --border-dim: #2a2a4a;
460
+ }
461
+ .gradio-container {
462
+ background-color: var(--bg-void) !important;
463
+ background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),
464
+ linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
465
+ background-size: 18px 18px;
466
+ color: var(--text-gold);
467
+ }
468
+ .hero { text-align: center; color: var(--text-gold); }
469
+ .hero h1 { color: var(--gold); font-size: 2.8rem; }
470
+ .left-column, .right-column, .form, .block, .panel {
471
+ background: rgba(22, 33, 62, .84) !important;
472
+ border-color: var(--border-dim) !important;
473
+ }
474
+ button.primary {
475
+ background: linear-gradient(135deg, #f4d76a, #c9a227, #8b6914) !important;
476
+ color: #130f07 !important;
477
+ }
478
+ textarea, input {
479
+ background: #10172d !important;
480
+ color: var(--text-gold) !important;
481
+ }
482
+ """
483
+
484
+
485
  demo = build_app()
486
 
487