Compare commits

...

2 Commits

Author SHA1 Message Date
196346165f chat history with the NPC modal 2025-11-25 21:16:01 -06:00
20cb279793 fix: resolve NPC chat database persistence and modal targeting
Fixed two critical bugs in NPC chat functionality:

  1. Database Persistence - Metadata serialization bug
     - Empty dict {} was falsy, preventing JSON conversion
     - Changed to unconditional serialization in ChatMessageService
     - Messages now successfully save to chat_messages collection

  2. Modal Targeting - HTMX targeting lost during polling
     - poll_job() wasn't preserving hx-target/hx-swap parameters
     - Pass targeting params through query string in polling cycle
     - Responses now correctly load in modal instead of main panel

  Files modified:
  - api/app/services/chat_message_service.py
  - public_web/templates/game/partials/job_polling.html
  - public_web/app/views/game_views.py
2025-11-25 20:44:24 -06:00
10 changed files with 262 additions and 54 deletions

View File

@@ -29,7 +29,7 @@ logger = get_logger(__file__)
chat_bp = Blueprint('chat', __name__) chat_bp = Blueprint('chat', __name__)
@chat_bp.route('/characters/<character_id>/chats', methods=['GET']) @chat_bp.route('/api/v1/characters/<character_id>/chats', methods=['GET'])
@require_auth @require_auth
def get_conversations_summary(character_id: str): def get_conversations_summary(character_id: str):
""" """
@@ -76,7 +76,7 @@ def get_conversations_summary(character_id: str):
return error_response(f"Failed to retrieve conversations: {str(e)}", 500) return error_response(f"Failed to retrieve conversations: {str(e)}", 500)
@chat_bp.route('/characters/<character_id>/chats/<npc_id>', methods=['GET']) @chat_bp.route('/api/v1/characters/<character_id>/chats/<npc_id>', methods=['GET'])
@require_auth @require_auth
def get_conversation_history(character_id: str, npc_id: str): def get_conversation_history(character_id: str, npc_id: str):
""" """
@@ -160,7 +160,7 @@ def get_conversation_history(character_id: str, npc_id: str):
return error_response(f"Failed to retrieve conversation: {str(e)}", 500) return error_response(f"Failed to retrieve conversation: {str(e)}", 500)
@chat_bp.route('/characters/<character_id>/chats/search', methods=['GET']) @chat_bp.route('/api/v1/characters/<character_id>/chats/search', methods=['GET'])
@require_auth @require_auth
def search_messages(character_id: str): def search_messages(character_id: str):
""" """
@@ -265,7 +265,7 @@ def search_messages(character_id: str):
return error_response(f"Search failed: {str(e)}", 500) return error_response(f"Search failed: {str(e)}", 500)
@chat_bp.route('/characters/<character_id>/chats/<message_id>', methods=['DELETE']) @chat_bp.route('/api/v1/characters/<character_id>/chats/<message_id>', methods=['DELETE'])
@require_auth @require_auth
def delete_message(character_id: str, message_id: str): def delete_message(character_id: str, message_id: str):
""" """

View File

@@ -108,9 +108,9 @@ class ChatMessageService:
# Save to database # Save to database
message_data = chat_message.to_dict() message_data = chat_message.to_dict()
# Convert metadata dict to JSON string for storage # Convert metadata dict to JSON string for storage (Appwrite requires string type)
if message_data.get('metadata'): # Always convert, even if empty dict (empty dict {} is falsy in Python!)
message_data['metadata'] = json.dumps(message_data['metadata']) message_data['metadata'] = json.dumps(message_data.get('metadata') or {})
self.db.create_row( self.db.create_row(
table_id='chat_messages', table_id='chat_messages',

View File

@@ -12,6 +12,7 @@ import structlog
import yaml import yaml
import os import os
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -61,6 +62,34 @@ def create_app():
app.register_blueprint(character_bp) app.register_blueprint(character_bp)
app.register_blueprint(game_bp) app.register_blueprint(game_bp)
# Register Jinja filters
def format_timestamp(iso_string: str) -> str:
"""Convert ISO timestamp to relative time (e.g., '2 mins ago')"""
if not iso_string:
return ""
try:
timestamp = datetime.fromisoformat(iso_string.replace('Z', '+00:00'))
now = datetime.now(timezone.utc)
diff = now - timestamp
seconds = diff.total_seconds()
if seconds < 60:
return "Just now"
elif seconds < 3600:
mins = int(seconds / 60)
return f"{mins} min{'s' if mins != 1 else ''} ago"
elif seconds < 86400:
hours = int(seconds / 3600)
return f"{hours} hr{'s' if hours != 1 else ''} ago"
else:
days = int(seconds / 86400)
return f"{days} day{'s' if days != 1 else ''} ago"
except Exception as e:
logger.warning("timestamp_format_failed", iso_string=iso_string, error=str(e))
return iso_string
app.jinja_env.filters['format_timestamp'] = format_timestamp
# Register dev blueprint only in development # Register dev blueprint only in development
env = os.getenv("FLASK_ENV", "development") env = os.getenv("FLASK_ENV", "development")
if env == "development": if env == "development":

View File

@@ -506,6 +506,10 @@ def poll_job(session_id: str, job_id: str):
"""Poll job status - returns updated partial.""" """Poll job status - returns updated partial."""
client = get_api_client() client = get_api_client()
# Get hx_target and hx_swap from query params (passed through from original request)
hx_target = request.args.get('_hx_target')
hx_swap = request.args.get('_hx_swap')
try: try:
response = client.get(f'/api/v1/jobs/{job_id}/status') response = client.get(f'/api/v1/jobs/{job_id}/status')
result = response.get('result', {}) result = response.get('result', {})
@@ -540,11 +544,14 @@ def poll_job(session_id: str, job_id: str):
else: else:
# Still processing - return polling partial to continue # Still processing - return polling partial to continue
# Pass through hx_target and hx_swap to maintain targeting
return render_template( return render_template(
'game/partials/job_polling.html', 'game/partials/job_polling.html',
session_id=session_id, session_id=session_id,
job_id=job_id, job_id=job_id,
status=status status=status,
hx_target=hx_target,
hx_swap=hx_swap
) )
except APIError as e: except APIError as e:
@@ -741,6 +748,40 @@ def npc_chat_modal(session_id: str, npc_id: str):
''' '''
@game_bp.route('/session/<session_id>/npc/<npc_id>/history')
@require_auth
def npc_chat_history(session_id: str, npc_id: str):
"""Get last 5 chat messages for history sidebar."""
client = get_api_client()
try:
# Get session to find character_id
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
character_id = session_data.get('character_id')
# Fetch last 5 messages from chat service
# API endpoint: GET /api/v1/characters/{character_id}/chats/{npc_id}?limit=5
history_response = client.get(
f'/api/v1/characters/{character_id}/chats/{npc_id}',
params={'limit': 5, 'offset': 0}
)
result_data = history_response.get('result', {})
messages = result_data.get('messages', []) # Extract messages array from result
# Render history partial
return render_template(
'game/partials/npc_chat_history.html',
messages=messages,
session_id=session_id,
npc_id=npc_id
)
except APIError as e:
logger.error("failed_to_load_chat_history", session_id=session_id, npc_id=npc_id, error=str(e))
return '<div class="history-empty">Failed to load history</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>/talk', methods=['POST']) @game_bp.route('/session/<session_id>/npc/<npc_id>/talk', methods=['POST'])
@require_auth @require_auth
def talk_to_npc(session_id: str, npc_id: str): def talk_to_npc(session_id: str, npc_id: str):
@@ -767,12 +808,15 @@ def talk_to_npc(session_id: str, npc_id: str):
job_id = result.get('job_id') job_id = result.get('job_id')
if job_id: if job_id:
# Return job polling partial for the chat area # Return job polling partial for the chat area
# Use hx-target="this" and hx-swap="outerHTML" to replace loading div with response in-place
return render_template( return render_template(
'game/partials/job_polling.html', 'game/partials/job_polling.html',
job_id=job_id, job_id=job_id,
session_id=session_id, session_id=session_id,
status='queued', status='queued',
is_npc_dialogue=True is_npc_dialogue=True,
hx_target='this', # Target the loading div itself
hx_swap='outerHTML' # Replace entire loading div with response
) )
# Immediate response (if AI is sync or cached) # Immediate response (if AI is sync or cached)

View File

@@ -1033,7 +1033,7 @@
.modal-content--sm { max-width: 400px; } .modal-content--sm { max-width: 400px; }
.modal-content--md { max-width: 500px; } .modal-content--md { max-width: 500px; }
.modal-content--lg { max-width: 700px; } .modal-content--lg { max-width: 700px; }
.modal-content--xl { max-width: 900px; } .modal-content--xl { max-width: 1000px; } /* Expanded for 3-column NPC chat */
@keyframes slideUp { @keyframes slideUp {
from { transform: translateY(20px); opacity: 0; } from { transform: translateY(20px); opacity: 0; }
@@ -1434,6 +1434,14 @@
min-height: 400px; min-height: 400px;
} }
/* 3-column layout for chat modal with history sidebar */
.npc-modal-body--three-col {
display: grid;
grid-template-columns: 200px 1fr 280px; /* Profile | Chat | History */
gap: 1rem;
max-height: 70vh;
}
/* NPC Profile (Left Column) */ /* NPC Profile (Left Column) */
.npc-profile { .npc-profile {
width: 200px; width: 200px;
@@ -1587,18 +1595,20 @@
font-weight: 600; font-weight: 600;
} }
/* NPC Conversation (Right Column) */ /* NPC Conversation (Middle Column) */
.npc-conversation { .npc-conversation {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0; /* Important for grid child to enable scrolling */
} }
.npc-conversation .chat-history { .npc-conversation .chat-history {
flex: 1; flex: 1;
min-height: 250px; min-height: 250px;
max-height: none; max-height: 500px; /* Set max height to enable scrolling */
overflow-y: auto; /* Enable vertical scroll */
} }
.chat-empty-state { .chat-empty-state {
@@ -1608,6 +1618,87 @@
font-style: italic; font-style: italic;
} }
/* ===== NPC CHAT HISTORY SIDEBAR ===== */
.npc-history-panel {
display: flex;
flex-direction: column;
border-left: 1px solid var(--play-border);
padding-left: 1rem;
overflow-y: auto;
max-height: 70vh;
}
.history-header {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 0.75rem;
letter-spacing: 0.05em;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Compact history cards */
.history-card {
background: var(--bg-secondary);
border: 1px solid var(--play-border);
border-radius: 4px;
padding: 0.5rem;
font-size: 0.8rem;
transition: background 0.2s;
}
.history-card:hover {
background: var(--bg-tertiary);
}
.history-timestamp {
font-size: 0.7rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
font-style: italic;
}
.history-player {
color: var(--text-primary);
margin-bottom: 0.125rem;
line-height: 1.4;
}
.history-player strong {
color: var(--accent-gold);
}
.history-npc {
color: var(--text-primary);
line-height: 1.4;
}
.history-npc strong {
color: var(--accent-gold);
}
.history-empty {
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
padding: 2rem 0;
font-style: italic;
}
.loading-state-small {
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
padding: 1rem 0;
font-style: italic;
}
/* Responsive NPC Modal */ /* Responsive NPC Modal */
@media (max-width: 700px) { @media (max-width: 700px) {
.npc-modal-body { .npc-modal-body {
@@ -1650,6 +1741,31 @@
} }
} }
/* Responsive: 3-column modal stacks on smaller screens */
@media (max-width: 1024px) {
.npc-modal-body--three-col {
grid-template-columns: 1fr; /* Single column */
grid-template-rows: auto 1fr auto;
}
.npc-profile {
order: 1;
}
.npc-conversation {
order: 2;
}
.npc-history-panel {
order: 3;
border-left: none;
border-top: 1px solid var(--play-border);
padding-left: 0;
padding-top: 1rem;
max-height: 200px; /* Shorter on mobile */
}
}
/* ===== UTILITY CLASSES FOR PLAY SCREEN ===== */ /* ===== UTILITY CLASSES FOR PLAY SCREEN ===== */
.play-hidden { .play-hidden {
display: none !important; display: none !important;

View File

@@ -16,6 +16,8 @@
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- HTMX JSON encoding extension --> <!-- HTMX JSON encoding extension -->
<script src="https://unpkg.com/htmx.org@1.9.10/dist/ext/json-enc.js"></script> <script src="https://unpkg.com/htmx.org@1.9.10/dist/ext/json-enc.js"></script>
<!-- Hyperscript for custom events -->
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>

View File

@@ -10,10 +10,10 @@ Shows loading state while waiting for AI response, auto-polls for completion
{% endif %} {% endif %}
<div class="loading-state" <div class="loading-state"
hx-get="{{ url_for('game.poll_job', session_id=session_id, job_id=job_id) }}" hx-get="{{ url_for('game.poll_job', session_id=session_id, job_id=job_id, _hx_target=hx_target, _hx_swap=hx_swap) }}"
hx-trigger="load delay:1s" hx-trigger="load delay:1s"
hx-swap="innerHTML" hx-swap="{{ hx_swap|default('innerHTML') }}"
hx-target="#narrative-content"> hx-target="{{ hx_target|default('#narrative-content') }}">
<div class="loading-spinner-large"></div> <div class="loading-spinner-large"></div>
<p class="loading-text"> <p class="loading-text">
{% if status == 'queued' %} {% if status == 'queued' %}

View File

@@ -0,0 +1,29 @@
{#
NPC Chat History Sidebar
Shows last 5 messages in compact cards with timestamps
#}
<div class="chat-history-sidebar">
<h4 class="history-header">Recent Messages</h4>
{% if messages %}
<div class="history-list">
{% for msg in messages|reverse %}
<div class="history-card">
<div class="history-timestamp">
{{ msg.timestamp|format_timestamp }}
</div>
<div class="history-player">
<strong>You:</strong> {{ msg.player_message|truncate(60) }}
</div>
<div class="history-npc">
<strong>NPC:</strong> {{ msg.npc_response|truncate(60) }}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="history-empty">
No previous messages
</div>
{% endif %}
</div>

View File

@@ -3,15 +3,15 @@ NPC Chat Modal (Expanded)
Shows NPC profile with portrait, relationship meter, and conversation interface Shows NPC profile with portrait, relationship meter, and conversation interface
#} #}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()"> <div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--lg"> <div class="modal-content modal-content--xl">
{# Modal Header #} {# Modal Header #}
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">{{ npc.name }}</h3> <h3 class="modal-title">{{ npc.name }}</h3>
<button class="modal-close" onclick="closeModal()">&times;</button> <button class="modal-close" onclick="closeModal()">&times;</button>
</div> </div>
{# Modal Body - Two Column Layout #} {# Modal Body - Three Column Layout #}
<div class="modal-body npc-modal-body"> <div class="modal-body npc-modal-body npc-modal-body--three-col">
{# Left Column: NPC Profile #} {# Left Column: NPC Profile #}
<div class="npc-profile"> <div class="npc-profile">
{# NPC Portrait #} {# NPC Portrait #}
@@ -115,6 +115,17 @@ Shows NPC profile with portrait, relationship meter, and conversation interface
</button> </button>
</form> </form>
</div> </div>
{# Right Column: Message History Sidebar #}
<aside class="npc-history-panel"
id="npc-history-{{ npc.npc_id }}"
hx-get="{{ url_for('game.npc_chat_history', session_id=session_id, npc_id=npc.npc_id) }}"
hx-trigger="load, newMessage from:body"
hx-swap="innerHTML"
hx-indicator=".history-loading">
{# History loaded via HTMX #}
<div class="loading-state-small history-loading">Loading history...</div>
</aside>
</div> </div>
{# Modal Footer #} {# Modal Footer #}

View File

@@ -11,46 +11,23 @@ Expected context:
- session_id: For any follow-up actions - session_id: For any follow-up actions
#} #}
<div class="npc-dialogue-response"> {# Only show CURRENT exchange (removed conversation_history loop) #}
<div class="npc-dialogue-header">
<span class="npc-dialogue-title">{{ npc_name }} says:</span>
</div>
<div class="npc-dialogue-content">
{# Show conversation history if present #}
{% if conversation_history %}
<div class="conversation-history">
{% for exchange in conversation_history[-3:] %}
<div class="history-exchange">
<div class="history-player">
<span class="speaker player">{{ character_name }}:</span>
<span class="text">{{ exchange.player_line }}</span>
</div>
<div class="history-npc">
<span class="speaker npc">{{ npc_name }}:</span>
<span class="text">{{ exchange.npc_response }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# Show current exchange #}
<div class="current-exchange"> <div class="current-exchange">
{% if player_line %} {% if player_line %}
<div class="player-message"> <div class="chat-message chat-message--player">
<span class="speaker player">{{ character_name }}:</span> <strong>{{ character_name }}:</strong> {{ player_line }}
<span class="text">{{ player_line }}</span>
</div> </div>
{% endif %} {% endif %}
<div class="npc-message"> <div class="chat-message chat-message--npc">
<span class="speaker npc">{{ npc_name }}:</span> <strong>{{ npc_name }}:</strong> {{ dialogue }}
<span class="text">{{ dialogue }}</span>
</div>
</div>
</div> </div>
</div> </div>
{# Trigger history refresh after new message #}
<div hx-trigger="load"
_="on load trigger newMessage on body"
style="display: none;"></div>
{# Trigger sidebar refreshes after NPC dialogue #} {# Trigger sidebar refreshes after NPC dialogue #}
<div hx-get="{{ url_for('game.npcs_accordion', session_id=session_id) }}" <div hx-get="{{ url_for('game.npcs_accordion', session_id=session_id) }}"
hx-trigger="load" hx-trigger="load"