mari-chat-3 / components_chat_interface.py
sirochild's picture
Upload 2 files
71dcd30 verified
"""
チャットインターフェースコンポーネント
Streamlitのチャット機能を使用したメッセージ表示と入力処理
マスクアイコンとフリップアニメーション機能を含む
"""
import streamlit as st
import logging
import re
import uuid
from typing import List, Dict, Optional, Tuple
from datetime import datetime
logger = logging.getLogger(__name__)
class ChatInterface:
"""チャットインターフェースを管理するクラス"""
def __init__(self, max_input_length: int = 1000):
"""
Args:
max_input_length: 入力メッセージの最大長
"""
self.max_input_length = max_input_length
def render_chat_history(self, messages: List[Dict[str, str]],
memory_summary: str = "") -> None:
"""
チャット履歴を表示する(マスク機能付き、最適化版)
Args:
messages: チャットメッセージのリスト
memory_summary: メモリサマリー(重要単語から生成)
"""
logger.info(f"🎯 render_chat_history 開始: {len(messages) if messages else 0}件のメッセージ")
try:
# 初期メッセージの存在確認と復元
if messages:
initial_messages = [msg for msg in messages if msg.get('is_initial', False)]
if not initial_messages:
logger.warning("初期メッセージが見つかりません - 復元を試行")
# 初期メッセージが存在しない場合は先頭に追加
initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
messages.insert(0, initial_message)
logger.info("初期メッセージを復元しました")
# 履歴表示の重複実行を防ぐ(改良版)
# セッション固有のレンダリング状態を管理
if 'chat_render_state' not in st.session_state:
st.session_state.chat_render_state = {
'last_messages_hash': None,
'render_count': 0,
'last_render_time': 0
}
# メッセージ内容とポチモード状態を含むハッシュを生成
show_all_hidden = st.session_state.get('show_all_hidden', False)
messages_with_state = {
'messages': [{'role': msg.get('role'), 'content': msg.get('content', '')[:100]} for msg in messages], # 内容を短縮してハッシュ計算を軽量化
'show_all_hidden': show_all_hidden,
'message_count': len(messages)
}
import time
current_time = time.time()
messages_hash = hash(str(messages_with_state))
last_render_hash = st.session_state.chat_render_state['last_messages_hash']
# 時間制限を一時的に無効化(デバッグ用)
time_since_last_render = current_time - st.session_state.chat_render_state['last_render_time']
# if time_since_last_render < 0.5 and last_render_hash == messages_hash:
# logger.debug(f"短時間での重複レンダリングをスキップ({time_since_last_render:.2f}秒前)")
# return
logger.debug(f"時間制限無効化 - 前回から{time_since_last_render:.2f}秒経過")
# 強制表示条件をより厳密に制御
force_render_conditions = [
st.session_state.get('tutorial_start_requested', False),
st.session_state.get('tutorial_skip_requested', False),
st.session_state.get('show_all_hidden_changed', False),
st.session_state.get('force_chat_rerender', False) # 明示的な強制レンダリングフラグ
]
should_force_render = any(force_render_conditions)
# キャッシュロジックを一時的に無効化(デバッグ用)
# if last_render_hash == messages_hash and not should_force_render:
# logger.debug("チャット履歴表示をスキップ(変更なし)")
# return
logger.debug("キャッシュスキップを無効化 - 常に表示を実行")
# レンダリング実行(ログ削除で軽量化)
# レンダリング状態を更新
st.session_state.chat_render_state['last_messages_hash'] = messages_hash
st.session_state.chat_render_state['render_count'] += 1
st.session_state.chat_render_state['last_render_time'] = current_time
# メモリサマリーがある場合は表示
if memory_summary:
with st.expander("💭 過去の会話の記憶", expanded=False):
st.info(memory_summary)
# 独自のチャット表示(st.chat_messageを使わない安定版)
if not messages:
st.info("まだメッセージがありません。下のチャット欄で麻理に話しかけてみてください。")
return
for i, message in enumerate(messages):
role = message.get("role", "user")
content = message.get("content", "")
timestamp = message.get("timestamp")
is_initial = message.get("is_initial", False)
message_id = message.get("message_id", f"msg_{i}")
# 独自のチャットバブル表示
self._render_custom_chat_bubble(role, content, is_initial, message_id, timestamp)
# 履歴表示完了をマーク
# 既にレンダリング状態は上で更新済み
logger.debug(f"チャット履歴表示完了({len(messages)}件)")
# 強制表示フラグをクリア(表示完了後に実行)
if st.session_state.get('show_all_hidden_changed', False):
st.session_state.show_all_hidden_changed = False
logger.info("犬のボタン状態変更フラグをクリアしました")
if st.session_state.get('force_chat_rerender', False):
st.session_state.force_chat_rerender = False
logger.info("強制チャット再レンダリングフラグをクリアしました")
except Exception as e:
logger.error(f"チャット履歴表示エラー: {e}")
st.error("チャット履歴の表示中にエラーが発生しました。")
def _render_custom_chat_bubble(self, role: str, content: str, is_initial: bool, message_id: str, timestamp: str = None):
"""独自のチャットバブル表示(st.chat_messageを使わない安定版)"""
logger.info(f"🎨 カスタムチャットバブル開始: {role} - '{content[:30]}...' - 初期:{is_initial}")
try:
# チャットバブルのCSS
bubble_css = """
<style>
.custom-chat-container {
margin: 10px 0;
display: flex;
flex-direction: column;
}
.custom-chat-bubble {
max-width: 80%;
padding: 12px 16px;
border-radius: 18px;
margin: 4px 0;
word-wrap: break-word;
line-height: 1.5;
font-size: 18px;
}
.user-bubble {
background: #007bff;
color: white;
align-self: flex-end;
margin-left: auto;
}
.assistant-bubble {
background: #f1f3f4;
color: #333;
align-self: flex-start;
margin-right: auto;
border: 1px solid #e0e0e0;
}
.initial-message-bubble {
background: #e8f5e8 !important;
color: #2d5a2d !important;
font-weight: 500 !important;
border: 2px solid #4caf50 !important;
}
.chat-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
margin: 0 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.user-avatar {
background: #007bff;
color: white;
}
.assistant-avatar {
background: #ff69b4;
color: white;
}
.chat-row {
display: flex;
align-items: flex-start;
margin: 8px 0;
}
.user-row {
flex-direction: row-reverse;
}
.assistant-row {
flex-direction: row;
}
.timestamp {
font-size: 0.8em;
color: #666;
margin-top: 4px;
text-align: center;
}
</style>
"""
st.markdown(bubble_css, unsafe_allow_html=True)
# アバターとバブルのスタイル決定
if role == "user":
avatar_class = "user-avatar"
bubble_class = "user-bubble"
row_class = "user-row"
avatar_icon = "👤"
else:
avatar_class = "assistant-avatar"
bubble_class = "assistant-bubble"
row_class = "assistant-row"
avatar_icon = "🤖"
# 初期メッセージの場合は特別なスタイル
if is_initial:
bubble_class += " initial-message-bubble"
avatar_icon = "💬"
# コンテンツのHTMLエスケープ処理(HTMLタグとStreamlitクラス名を完全に除去)
import html
import re
# HTMLタグとStreamlitの内部クラス名を完全に除去
# 1. HTMLタグを除去(開始・終了タグ両方)
clean_content = re.sub(r'<[^>]*>', '', content)
# 2. Streamlitの内部クラス名を除去(より包括的)
clean_content = re.sub(r'st-emotion-cache-[a-zA-Z0-9]+', '', clean_content)
clean_content = re.sub(r'class="[^"]*st-emotion-cache[^"]*"', '', clean_content)
clean_content = re.sub(r'class="[^"]*st-[^"]*"', '', clean_content)
clean_content = re.sub(r"class='[^']*st-emotion-cache[^']*'", '', clean_content)
clean_content = re.sub(r"class='[^']*st-[^']*'", '', clean_content)
# 3. HTML属性を除去
clean_content = re.sub(r'data-testid="[^"]*"', '', clean_content)
clean_content = re.sub(r'data-[^=]*="[^"]*"', '', clean_content)
clean_content = re.sub(r'class="[^"]*"', '', clean_content)
clean_content = re.sub(r"class='[^']*'", '', clean_content)
clean_content = re.sub(r'id="[^"]*"', '', clean_content)
clean_content = re.sub(r"id='[^']*'", '', clean_content)
# 4. その他のHTML関連文字列を除去
clean_content = re.sub(r'&[a-zA-Z0-9#]+;', '', clean_content) # HTMLエンティティ
clean_content = re.sub(r'[<>]', '', clean_content) # 残った角括弧
# 5. 余分な空白を除去
clean_content = re.sub(r'\s+', ' ', clean_content).strip()
# 6. HTMLエスケープ
escaped_content = html.escape(clean_content)
if content != clean_content:
logger.error(f"🚨 HTMLタグ混入検出! 元の内容: '{content}'")
logger.error(f"🚨 クリーン後: '{clean_content}'")
# スタックトレースを出力して呼び出し元を特定
import traceback
logger.error(f"🚨 呼び出しスタック: {traceback.format_stack()}")
else:
logger.debug(f"通常テキストをHTMLエスケープ: '{content[:30]}...'")
# チャットバブルのHTML生成
chat_html = f"""
<div class="custom-chat-container">
<div class="chat-row {row_class}">
<div class="chat-avatar {avatar_class}">
{avatar_icon}
</div>
<div class="custom-chat-bubble {bubble_class}">
{escaped_content}
{f'<div class="timestamp">{html.escape(timestamp)}</div>' if timestamp and st.session_state.get("debug_mode", False) else ''}
</div>
</div>
</div>
"""
st.markdown(chat_html, unsafe_allow_html=True)
# 麻理のメッセージで隠された真実がある場合の処理
if role == "assistant" and not is_initial:
has_hidden_content, visible_content, hidden_content = self._detect_hidden_content(content)
if has_hidden_content:
# 犬のボタンの状態に応じて表示を切り替え
show_all_hidden = st.session_state.get('show_all_hidden', False)
logger.debug(f"隠された真実の表示判定: show_all_hidden={show_all_hidden}, has_hidden={has_hidden_content}")
if show_all_hidden:
# 本音表示モードの場合は隠された内容を表示
# HTMLタグとStreamlitクラス名を除去してからエスケープ
clean_hidden_content = re.sub(r'<[^>]*>', '', hidden_content)
clean_hidden_content = re.sub(r'st-emotion-cache-[a-zA-Z0-9]+', '', clean_hidden_content)
clean_hidden_content = re.sub(r'class="[^"]*"', '', clean_hidden_content)
clean_hidden_content = re.sub(r"class='[^']*'", '', clean_hidden_content)
clean_hidden_content = re.sub(r'data-[^=]*="[^"]*"', '', clean_hidden_content)
clean_hidden_content = re.sub(r'&[a-zA-Z0-9#]+;', '', clean_hidden_content)
clean_hidden_content = re.sub(r'[<>]', '', clean_hidden_content)
clean_hidden_content = re.sub(r'\s+', ' ', clean_hidden_content).strip()
escaped_hidden_content = html.escape(clean_hidden_content)
hidden_html = f"""
<div class="custom-chat-container">
<div class="chat-row assistant-row">
<div class="chat-avatar assistant-avatar">
🐕
</div>
<div class="custom-chat-bubble assistant-bubble" style="background: #fff8e1 !important; border: 2px solid #ffc107 !important;">
<strong>🐕 ポチの本音翻訳:</strong><br>
{escaped_hidden_content}
</div>
</div>
</div>
"""
st.markdown(hidden_html, unsafe_allow_html=True)
logger.debug(f"隠された真実を表示: '{clean_hidden_content[:30]}...'")
else:
logger.debug("通常モードのため隠された真実は非表示")
logger.debug(f"カスタムチャットバブル表示完了: {role} - {message_id}")
except Exception as e:
logger.error(f"カスタムチャットバブル表示エラー: {e}")
logger.error(f"エラー詳細: role={role}, content_len={len(content)}, is_initial={is_initial}, message_id={message_id}")
import traceback
logger.error(f"スタックトレース: {traceback.format_exc()}")
# フォールバック: シンプルなテキスト表示
st.markdown(f"**{role}**: {content}")
logger.info("フォールバック表示を実行しました")
def _render_mari_message_with_mask(self, message_id: str, content: str, is_initial: bool = False) -> None:
"""
麻理のメッセージをマスク機能付きで表示する(廃止予定)
Args:
message_id: メッセージの一意ID
content: メッセージ内容
is_initial: 初期メッセージかどうか
"""
logger.warning("⚠️ 廃止予定のメソッドが呼ばれました: _render_mari_message_with_mask")
# カスタムチャットバブルに移行
self._render_custom_chat_bubble("assistant", content, is_initial, message_id)
return
try:
# メッセージ処理キャッシュをチェック(重複処理防止)
cache_key = f"processed_{message_id}_{hash(content)}"
if cache_key in st.session_state:
# キャッシュから結果を取得
cached_result = st.session_state[cache_key]
has_hidden_content = cached_result['has_hidden']
visible_content = cached_result['visible_content']
hidden_content = cached_result['hidden_content']
logger.debug(f"キャッシュからメッセージ処理結果を取得: {message_id}")
else:
# 隠された真実を検出
has_hidden_content, visible_content, hidden_content = self._detect_hidden_content(content)
# 結果をキャッシュに保存
st.session_state[cache_key] = {
'has_hidden': has_hidden_content,
'visible_content': visible_content,
'hidden_content': hidden_content
}
logger.debug(f"メッセージ処理結果をキャッシュに保存: {message_id}")
# 隠された真実が検出されない場合のフォールバック処理
if not has_hidden_content:
logger.warning(f"隠された真実が検出されませんでした: '{content[:50]}...'")
# AIが[HIDDEN:...]形式で応答していない場合は通常表示
# セッション状態でフリップ状態を管理
if 'message_flip_states' not in st.session_state:
st.session_state.message_flip_states = {}
is_flipped = st.session_state.message_flip_states.get(message_id, False)
if has_hidden_content:
# マスクアイコン付きメッセージを表示
self._render_message_with_flip_animation(
message_id, visible_content, hidden_content, is_flipped, is_initial
)
else:
# 通常のメッセージ表示
if is_initial:
# 初期メッセージは確実に黒文字で表示(強制スタイル適用)
initial_message_html = f'''
<div class="mari-initial-message" style="
color: #333333 !important;
font-weight: 500 !important;
background: #f5f5f5 !important;
padding: 15px !important;
border-radius: 12px !important;
border: 1px solid rgba(0,0,0,0.1) !important;
margin: 8px 0 !important;
font-family: var(--mari-font) !important;
line-height: 1.7 !important;
">
{content}
</div>
'''
st.markdown(initial_message_html, unsafe_allow_html=True)
logger.debug(f"初期メッセージを表示: '{content}'")
else:
st.markdown(content)
except Exception as e:
logger.error(f"マスク付きメッセージ表示エラー: {e}")
# フォールバック: 通常のメッセージ表示
st.markdown(content)
def _detect_hidden_content(self, content: str) -> Tuple[bool, str, str]:
"""
メッセージから隠された真実を検出する
Args:
content: メッセージ内容
Returns:
(隠された内容があるか, 表示用内容, 隠された内容)
"""
try:
# デバッグ用ログ(重複実行防止)
logger.debug(f"🔍 隠された内容検出中: '{content[:50]}...'")
# 隠された真実のマーカーを検索
# 形式: [HIDDEN:隠された内容]表示される内容
hidden_pattern = r'\[HIDDEN:(.*?)\](.*)'
match = re.search(hidden_pattern, content)
if match:
hidden_content = match.group(1).strip()
visible_content = match.group(2).strip()
logger.info(f"🐕 隠された真実を検出: 表示='{visible_content}', 隠し='{hidden_content}'")
return True, visible_content, hidden_content
# マーカーがない場合は通常のメッセージ
logger.debug(f"📝 通常メッセージ: '{content[:30]}...'")
return False, content, ""
except Exception as e:
logger.error(f"隠された内容検出エラー: {e}")
return False, content, ""
def _render_message_with_flip_animation(self, message_id: str, visible_content: str,
hidden_content: str, is_flipped: bool, is_initial: bool = False) -> None:
"""
フリップアニメーション付きメッセージを表示する
Args:
message_id: メッセージID
visible_content: 表示用内容
hidden_content: 隠された内容
is_flipped: 現在フリップされているか
is_initial: 初期メッセージかどうか
"""
try:
logger.info(f"🐕 ポチモード付きメッセージを表示: ID={message_id}, フリップ={is_flipped}")
# フリップアニメーション用CSS
flip_css = f"""
<style>
.message-container-{message_id} {{
position: relative;
perspective: 1000px;
margin: 10px 0;
}}
.message-flip-{message_id} {{
position: relative;
width: 100%;
height: auto;
min-height: 60px;
transform-style: preserve-3d;
transition: transform 0.4s ease-in-out;
transform: {'rotateY(180deg)' if is_flipped else 'rotateY(0deg)'};
}}
.message-side-{message_id} {{
position: absolute;
width: 100%;
backface-visibility: hidden;
padding: 15px 45px 15px 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-family: var(--mari-font);
line-height: 1.7;
min-height: 50px;
}}
.message-front-{message_id} {{
background: var(--mari-bubble-bg);
border: 1px solid rgba(0, 0, 0, 0.1);
color: var(--text-color);
transform: rotateY(0deg);
}}
.message-back-{message_id} {{
background: var(--hidden-bubble-bg);
border: 1px solid rgba(255, 248, 225, 0.7);
color: var(--text-color);
transform: rotateY(180deg);
box-shadow: 0 2px 8px rgba(255, 248, 225, 0.3);
}}
.mask-icon-{message_id} {{
position: absolute;
bottom: 12px;
right: 12px;
font-size: 20px;
cursor: pointer;
padding: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
z-index: 10;
user-select: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}}
.mask-icon-{message_id}:hover {{
background: rgba(255, 255, 255, 1);
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}}
.mask-icon-{message_id}:active {{
transform: scale(0.95);
}}
.tutorial-pulse-{message_id} {{
animation: tutorialPulse 2s ease-in-out infinite;
}}
@keyframes tutorialPulse {{
0%, 100% {{
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 105, 180, 0.7);
}}
50% {{
transform: scale(1.1);
box-shadow: 0 0 0 10px rgba(255, 105, 180, 0);
}}
}}
</style>
"""
# 犬のボタンの状態を事前にチェックして即座に反映(無限ループ防止)
show_all_hidden = st.session_state.get('show_all_hidden', False)
# 犬のボタンの状態に従って表示を切り替え(状態変更時のみ)
current_flip_state = st.session_state.message_flip_states.get(message_id, False)
if show_all_hidden != current_flip_state:
st.session_state.message_flip_states[message_id] = show_all_hidden
is_flipped = show_all_hidden
logger.debug(f"メッセージ {message_id} のフリップ状態を更新: {is_flipped}")
else:
is_flipped = current_flip_state
# 現在表示するコンテンツを決定
current_content = hidden_content if is_flipped else visible_content
# 初期メッセージの場合は確実に黒文字で表示
if is_initial:
initial_style = "color: #333333 !important; font-weight: 500 !important;"
initial_class = "mari-initial-message"
# 初期メッセージは背景色を固定
bg_color = "#F5F5F5"
logger.debug(f"初期メッセージをフリップ表示: '{current_content}'")
else:
initial_style = ""
initial_class = ""
# 通常メッセージは背景色を動的に設定
bg_color = "#FFF8E1" if is_flipped else "#F5F5F5"
# メッセージを全幅で表示(ボタンは削除)
message_style = f"""
<div style="
padding: 15px;
background: {bg_color};
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.1);
min-height: 50px;
font-family: var(--mari-font);
line-height: 1.7;
margin: 8px 0;
">
<div class="{initial_class}" style="{initial_style}">{current_content}</div>
</div>
"""
st.markdown(message_style, unsafe_allow_html=True)
# 本音表示機能の状態表示(開発用)
if st.session_state.get("debug_mode", False):
st.caption(f"🐕 Dog Mode: ID={message_id}, Hidden={len(hidden_content)>0}, Showing={is_flipped}")
except Exception as e:
logger.error(f"フリップアニメーション表示エラー: {e}")
# フォールバック: 通常のメッセージ表示
st.markdown(visible_content)
def _is_tutorial_message(self, message_id: str) -> bool:
"""
チュートリアル用のメッセージかどうかを判定する
Args:
message_id: メッセージID
Returns:
チュートリアルメッセージかどうか
"""
# 初回のマスク付きメッセージの場合はチュートリアル扱い
tutorial_completed = st.session_state.get('mask_tutorial_completed', False)
return not tutorial_completed and message_id == "msg_0"
def validate_input(self, message: str) -> Tuple[bool, str]:
"""
入力メッセージの検証
Args:
message: 入力メッセージ
Returns:
(検証結果, エラーメッセージ)
"""
if not message or not message.strip():
return False, "メッセージが空です。"
if len(message) > self.max_input_length:
return False, f"メッセージが長すぎます。{self.max_input_length}文字以内で入力してください。"
# 不正な文字のチェック
if any(ord(char) < 32 and char not in ['\n', '\r', '\t'] for char in message):
return False, "不正な文字が含まれています。"
return True, ""
def sanitize_message(self, message: str) -> str:
"""
メッセージをサニタイズする
Args:
message: 入力メッセージ
Returns:
サニタイズされたメッセージ
"""
try:
# 基本的なサニタイズ
sanitized = message.strip()
# HTMLエスケープ(Streamlitが自動で行うが念のため)
sanitized = sanitized.replace("<", "&lt;").replace(">", "&gt;")
# 連続する空白を単一の空白に変換
import re
sanitized = re.sub(r'\s+', ' ', sanitized)
return sanitized
except Exception as e:
logger.error(f"メッセージサニタイズエラー: {e}")
return message
def add_message(self, role: str, content: str,
messages: Optional[List[Dict[str, str]]] = None,
message_id: Optional[str] = None) -> List[Dict[str, str]]:
"""
メッセージをリストに追加する(マスク機能対応)
Args:
role: メッセージの役割 ('user' or 'assistant')
content: メッセージ内容
messages: メッセージリスト(Noneの場合はsession_stateから取得)
message_id: メッセージの一意ID(Noneの場合は自動生成)
Returns:
更新されたメッセージリスト
"""
try:
if messages is None:
messages = st.session_state.get('messages', [])
# メッセージIDを生成または使用
if message_id is None:
message_id = f"msg_{len(messages)}_{uuid.uuid4().hex[:8]}"
# メッセージオブジェクトを作成
message = {
"role": role,
"content": self.sanitize_message(content),
"timestamp": datetime.now().isoformat(),
"message_id": message_id
}
messages.append(message)
# セッション状態を更新
st.session_state.messages = messages
logger.info(f"メッセージを追加: {role} - {len(content)}文字 (ID: {message_id})")
return messages
except Exception as e:
logger.error(f"メッセージ追加エラー: {e}")
return messages or []
def create_hidden_content_message(self, visible_content: str, hidden_content: str) -> str:
"""
隠された真実を含むメッセージを作成する
Args:
visible_content: 表示される内容
hidden_content: 隠された内容
Returns:
マーカー付きメッセージ
"""
return f"[HIDDEN:{hidden_content}]{visible_content}"
def generate_mock_hidden_content(self, visible_content: str) -> str:
"""
テスト用のモック隠された内容を生成する
Args:
visible_content: 表示される内容
Returns:
隠された内容
"""
# 簡単なモック生成ロジック
mock_patterns = {
"何の用?": "(本当は嬉しいけど...素直になれない)",
"別に": "(実はすごく気になってる)",
"そうね": "(もっと話していたい)",
"まあまあ": "(とても楽しい!)",
"普通": "(特別な時間だと思ってる)",
"いいんじゃない": "(すごく良いと思う!)",
"そんなことない": "(本当はそう思ってる)"
}
for pattern, hidden in mock_patterns.items():
if pattern in visible_content:
return hidden
# デフォルトの隠された内容
return "(本当の気持ちは...)"
def render_input_area(self, placeholder: str = "メッセージを入力してください...") -> Optional[str]:
"""
入力エリアをレンダリングし、入力を取得する
Args:
placeholder: 入力フィールドのプレースホルダー
Returns:
入力されたメッセージ(入力がない場合はNone)
"""
try:
# レート制限チェック
if st.session_state.get('limiter_state', {}).get('is_blocked', False):
st.warning("⏰ レート制限中です。しばらくお待ちください。")
st.chat_input(placeholder, disabled=True)
return None
# 通常の入力フィールド(autocomplete属性付き)
# Streamlitのchat_inputにautocomplete属性を追加するためのCSS
chat_input_css = """
<style>
/* チャット入力フィールドのautocomplete属性を有効化 */
.stChatInput input[type="text"] {
}
.stChatInput textarea {
}
</style>
"""
st.markdown(chat_input_css, unsafe_allow_html=True)
user_input = st.chat_input(placeholder)
if user_input:
# 入力検証
is_valid, error_msg = self.validate_input(user_input)
if not is_valid:
st.error(error_msg)
return None
return user_input
return None
except Exception as e:
logger.error(f"入力エリア表示エラー: {e}")
st.error("入力エリアの表示中にエラーが発生しました。")
return None
def show_typing_indicator(self, message: str = "考え中...") -> None:
"""
タイピングインジケーターを表示する
Args:
message: 表示するメッセージ
"""
return st.spinner(message)
def clear_chat_history(self) -> None:
"""チャット履歴をクリアする"""
try:
st.session_state.messages = []
logger.info("チャット履歴をクリアしました")
except Exception as e:
logger.error(f"チャット履歴クリアエラー: {e}")
def get_chat_stats(self) -> Dict[str, int]:
"""
チャットの統計情報を取得する
Returns:
統計情報の辞書
"""
try:
messages = st.session_state.get('messages', [])
user_messages = [msg for msg in messages if msg.get("role") == "user"]
assistant_messages = [msg for msg in messages if msg.get("role") == "assistant"]
total_chars = sum(len(msg.get("content", "")) for msg in messages)
return {
"total_messages": len(messages),
"user_messages": len(user_messages),
"assistant_messages": len(assistant_messages),
"total_characters": total_chars,
"average_message_length": total_chars // len(messages) if messages else 0
}
except Exception as e:
logger.error(f"統計情報取得エラー: {e}")
return {
"total_messages": 0,
"user_messages": 0,
"assistant_messages": 0,
"total_characters": 0,
"average_message_length": 0
}
def export_chat_history(self) -> str:
"""
チャット履歴をエクスポート用の文字列として取得する
Returns:
エクスポート用の文字列
"""
try:
messages = st.session_state.get('messages', [])
if not messages:
return "チャット履歴がありません。"
export_lines = []
export_lines.append("=== 麻理チャット履歴 ===")
export_lines.append(f"エクスポート日時: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
export_lines.append("")
for i, message in enumerate(messages, 1):
role = "ユーザー" if message.get("role") == "user" else "麻理"
content = message.get("content", "")
timestamp = message.get("timestamp", "")
export_lines.append(f"[{i}] {role}: {content}")
if timestamp:
export_lines.append(f" 時刻: {timestamp}")
export_lines.append("")
return "\n".join(export_lines)
except Exception as e:
logger.error(f"履歴エクスポートエラー: {e}")
return "エクスポート中にエラーが発生しました。"