chat history with the NPC modal
This commit is contained in:
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -748,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):
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
29
public_web/templates/game/partials/npc_chat_history.html
Normal file
29
public_web/templates/game/partials/npc_chat_history.html
Normal 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>
|
||||||
@@ -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()">×</button>
|
<button class="modal-close" onclick="closeModal()">×</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 #}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user