Skip to content

Commit 52c5264

Browse files
committed
character conversation
1 parent 80c9516 commit 52c5264

14 files changed

Lines changed: 721 additions & 52 deletions

File tree

ai/generation.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ def generate_entity(
173173

174174
entity = create_entity(entity_data, user_input, session_id)
175175

176+
# Generate portrait in background (non-blocking)
177+
_spawn_portrait_generation(entity)
178+
176179
arrival_content = (
177180
f"On Day {entity.get('world_day', 1)}, {profile.name} arrived in "
178181
f"{profile.suggested_location}. {profile.arrival_note}"
@@ -220,6 +223,25 @@ def generate_entity(
220223
return None, "The Oracle was silent. Try again."
221224

222225

226+
def _spawn_portrait_generation(entity: dict) -> None:
227+
"""Fire-and-forget portrait generation in a background thread."""
228+
import threading
229+
def _gen():
230+
try:
231+
from ai.image_generation import generate_soul_portrait
232+
from world.database import db_session
233+
path = generate_soul_portrait(entity)
234+
if path:
235+
with db_session() as conn:
236+
conn.execute(
237+
"UPDATE entities SET portrait_url=? WHERE id=?",
238+
(path, entity["id"])
239+
)
240+
except Exception:
241+
pass
242+
threading.Thread(target=_gen, daemon=True).start()
243+
244+
223245
def _generate_secret_name(true_name: str) -> str:
224246
words = true_name.replace("The ", "").split()
225247
if len(words) >= 2:

ai/image_generation.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Soul portrait generation via HuggingFace Inference API (FLUX.1-schnell)."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from pathlib import Path
7+
8+
PORTRAITS_DIR = Path(__file__).parent.parent / "assets" / "generated"
9+
10+
11+
def _portraits_dir() -> Path:
12+
PORTRAITS_DIR.mkdir(parents=True, exist_ok=True)
13+
return PORTRAITS_DIR
14+
15+
16+
def _build_prompt(entity: dict) -> str:
17+
"""Build a vivid image prompt from entity data."""
18+
appearance = (entity.get("appearance") or "")[:220]
19+
etype = entity.get("type", "character")
20+
traits = entity.get("personality_traits") or []
21+
trait_str = ", ".join(t for t in traits[:2]) if traits else ""
22+
23+
type_style = {
24+
"character": "fantasy portrait, close-up, mysterious figure",
25+
"creature": "fantasy creature, full body, magical beast",
26+
"object": "magical enchanted artifact, studio lighting, detailed",
27+
"place": "mystical location concept art, atmospheric, wide view",
28+
}.get(etype, "fantasy entity")
29+
30+
prompt = (
31+
f"{appearance}. {type_style}. {trait_str}. "
32+
"Dark fantasy art style, painterly, dramatic atmospheric lighting, "
33+
"highly detailed, moody, cinematic, oil painting, intricate details, "
34+
"no text, no watermark, masterpiece quality"
35+
)
36+
return prompt[:500]
37+
38+
39+
def generate_soul_portrait(entity: dict) -> str | None:
40+
"""
41+
Generate a portrait image for the given entity using HF Inference API.
42+
Returns the local file path (string) on success, or None on failure/skip.
43+
Silently skips if HF_TOKEN is not set.
44+
"""
45+
import requests
46+
47+
hf_token = os.environ.get("HF_TOKEN")
48+
if not hf_token:
49+
return None
50+
51+
entity_id = entity.get("id")
52+
if not entity_id:
53+
return None
54+
55+
out_path = _portraits_dir() / f"{entity_id}.jpg"
56+
if out_path.exists():
57+
return str(out_path)
58+
59+
prompt = _build_prompt(entity)
60+
61+
# Try FLUX.1-schnell first (best quality, still fast)
62+
models = [
63+
"black-forest-labs/FLUX.1-schnell",
64+
"stabilityai/sdxl-turbo",
65+
]
66+
67+
for model in models:
68+
try:
69+
resp = requests.post(
70+
f"https://api-inference.huggingface.co/models/{model}",
71+
headers={"Authorization": f"Bearer {hf_token}"},
72+
json={
73+
"inputs": prompt,
74+
"parameters": {
75+
"num_inference_steps": 4,
76+
"width": 512,
77+
"height": 512,
78+
"guidance_scale": 0.0,
79+
},
80+
},
81+
timeout=45,
82+
)
83+
if resp.status_code == 200 and resp.content:
84+
out_path.write_bytes(resp.content)
85+
return str(out_path)
86+
except Exception:
87+
continue
88+
89+
return None
90+
91+
92+
def portrait_url_for(entity: dict) -> str | None:
93+
"""Return the Gradio-servable URL for a portrait if it exists on disk."""
94+
entity_id = entity.get("id")
95+
if not entity_id:
96+
return None
97+
p = PORTRAITS_DIR / f"{entity_id}.jpg"
98+
return str(p) if p.exists() else None

ai/interaction.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,22 @@ def generate_interaction(
3535
event_title = current_event["title"] if current_event else "None"
3636
event_desc = current_event["description"] if current_event else "None"
3737

38+
# Build quest context lines
39+
quest_a = entity_a.get("active_quest")
40+
quest_b = entity_b.get("active_quest")
41+
quest_context = ""
42+
if quest_a:
43+
quest_context += f"\nACTIVE QUEST for {entity_a['name']}: {quest_a}"
44+
if quest_b:
45+
quest_context += f"\nACTIVE QUEST for {entity_b['name']}: {quest_b}"
46+
if quest_context:
47+
quest_context = f"\n--- PLAYER-ASSIGNED QUESTS (weave these into the interaction outcome) ---{quest_context}\n"
48+
3849
user_prompt = prompts.INTERACTION_USER.format(
3950
location_name=location["name"],
4051
location_description=location["short_description"],
4152
current_event_title=event_title,
42-
current_event_description=event_desc,
53+
current_event_description=event_desc + quest_context,
4354
entity_a_name=entity_a["name"],
4455
entity_a_type=entity_a["type"],
4556
entity_a_appearance_summary=entity_a["appearance"][:200],

app.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
load_dotenv()
1414

1515
from world.database import get_world_state, init_database
16+
from world.quests import assign_quest, get_entity_quest, get_active_quests
17+
from world.presence import heartbeat as presence_heartbeat, get_active_count, get_recent_arrivals
1618
from world.entities import (
1719
check_rate_limit,
1820
get_all_entities,
@@ -301,6 +303,40 @@ def chat_with_soul(soul_id: str, player_message: str) -> str:
301303
return entity.get("greeting") or "..."
302304

303305

306+
def assign_quest_api(entity_id: str, quest_title: str) -> str:
307+
"""Assign a quest to a soul. Called from the diorama iframe via Gradio API."""
308+
if not entity_id or not quest_title or not quest_title.strip():
309+
return ""
310+
try:
311+
from world.entities import get_entity
312+
entity = get_entity(entity_id)
313+
if not entity:
314+
return "Soul not found."
315+
q = assign_quest(entity_id, quest_title.strip()[:200])
316+
return f"Quest assigned: {q['title']}"
317+
except Exception as e:
318+
return f"Could not assign quest: {e}"
319+
320+
321+
def get_live_presence(session_id: str) -> str:
322+
"""Heartbeat + return live visitor HTML."""
323+
try:
324+
presence_heartbeat(session_id)
325+
count = get_active_count()
326+
arrivals = get_recent_arrivals(since_seconds=120)
327+
parts = []
328+
if count > 1:
329+
parts.append(f'<span class="presence-count">⬡ {count} visitors in the Realm right now</span>')
330+
if arrivals:
331+
names = ", ".join(a["display_name"] for a in arrivals[:3])
332+
parts.append(f'<span class="presence-arrivals">Recently summoned: {names}</span>')
333+
if not parts:
334+
return ""
335+
return f'<div class="presence-bar">{" · ".join(parts)}</div>'
336+
except Exception:
337+
return ""
338+
339+
304340
def trigger_live_tick() -> str:
305341
"""Run one simulation tick on demand and return a summary."""
306342
try:
@@ -381,6 +417,7 @@ def build_app() -> gr.Blocks:
381417
with gr.Blocks(**blocks_kwargs) as demo:
382418
session_id = gr.State(value=str(uuid.uuid4()))
383419

420+
presence_bar = gr.HTML(value="", elem_id="presence-bar")
384421
header = gr.HTML(value=get_header_html(), elem_classes=["realm-header-wrap"])
385422

386423
with gr.Tabs(elem_classes=["realm-tabs"]) as tabs:
@@ -689,18 +726,36 @@ def _map_open_diorama(pick):
689726
outputs=[world_map, header, current_event, activity_feed, world_vignette, featured_chars, realm_pulse],
690727
)
691728

692-
# ── Soul chat API (called from diorama iframe via fetch) ──
729+
# ── Soul chat API ──
693730
chat_soul_id_comp = gr.Textbox(visible=False, elem_id="chat_soul_id_input")
694731
chat_msg_comp = gr.Textbox(visible=False, elem_id="chat_message_input")
695732
chat_resp_comp = gr.Textbox(visible=False, elem_id="chat_response_output")
696-
697733
chat_msg_comp.change(
698734
fn=chat_with_soul,
699735
inputs=[chat_soul_id_comp, chat_msg_comp],
700736
outputs=[chat_resp_comp],
701737
api_name="chat_with_soul",
702738
)
703739

740+
# ── Quest API ──
741+
quest_entity_comp = gr.Textbox(visible=False, elem_id="quest_entity_input")
742+
quest_title_comp = gr.Textbox(visible=False, elem_id="quest_title_input")
743+
quest_resp_comp = gr.Textbox(visible=False, elem_id="quest_response_output")
744+
quest_title_comp.change(
745+
fn=assign_quest_api,
746+
inputs=[quest_entity_comp, quest_title_comp],
747+
outputs=[quest_resp_comp],
748+
api_name="assign_quest",
749+
)
750+
751+
# ── Presence heartbeat (every 10s) ──
752+
presence_timer = gr.Timer(10)
753+
presence_timer.tick(
754+
fn=get_live_presence,
755+
inputs=[session_id],
756+
outputs=[presence_bar],
757+
)
758+
704759
book_filter.change(filter_book, [book_filter, book_search], [book_display])
705760
book_search.change(filter_book, [book_filter, book_search], [book_display])
706761

@@ -776,7 +831,10 @@ def _map_open_diorama(pick):
776831
"server_name": "0.0.0.0",
777832
"server_port": int(os.environ.get("PORT", 7860)),
778833
"share": False,
779-
"allowed_paths": assets.allowed_paths() + [str(Path(tempfile.gettempdir()) / "aether_soul_cards")],
834+
"allowed_paths": assets.allowed_paths() + [
835+
str(Path(tempfile.gettempdir()) / "aether_soul_cards"),
836+
str(Path(__file__).parent / "assets" / "generated"),
837+
],
780838
}
781839
if GRADIO_MAJOR >= 6:
782840
launch_kwargs["css"] = CUSTOM_CSS

0 commit comments

Comments
 (0)