''' 恋愛シミュレーションチャットアプリ Streamlitの画面・メイン処理 ''' # pip install requirements.txt # streamlit run app.py from __future__ import annotations import os import re import sys from datetime import datetime from pathlib import Path from uuid import uuid4 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import streamlit as st # Webアプリの画面作成 import anthropic # Claude API 公式 SDK ENV_PATH = Path(__file__).resolve().parent.parent / "env" / ".env" try: from dotenv import load_dotenv load_dotenv(dotenv_path=ENV_PATH) except ImportError: raise ValueError("Unable to load API key") import memory as mem import json import ui # LINE風UIのCSS/HTMLテンプレート import session_paths as sp # セッションごとのデータ保存先 from typing import Any from persona import update_persona_text from prompt_builder import build_system_blocks #load model CHAT_MODEL = os.environ.get("CHAT_MODEL", "claude-haiku-4-5") MAX_OUTPUT_TOKENS = int(os.environ.get("MAX_OUTPUT_TOKENS", "256")) # LINE 風短文用。Anthropic は max_tokens 必須 HISTORY_TURNS = 8 #LLMに渡す直近の往復数 (古い分は history_summary に圧縮済み) # --- サービス設定 (環境変数 / Hugging Face Secrets で渡す) ---------------- # APP_PASSKEY: 設定すると、このパスキーを知っている人だけが利用できる。 # ANTHROPIC_API_KEY: 設定するとサーバ側のキーを使う (利用者はキー入力不要)。 # 未設定なら、利用者が自分のキーを画面で入力する。 APP_PASSKEY = os.environ.get("APP_PASSKEY", "") SERVER_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "") ROOT = sp.ROOT def load_json(path: Path, default: Any) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: return default # --- LLM ------------------------------------------------------------------ @st.cache_resource(show_spinner=False) def get_llm(api_key: str) -> anthropic.Anthropic: return anthropic.Anthropic(api_key=api_key) @st.cache_resource(show_spinner=False) def get_summary_model(): from data.Models.Text2History.TinySwallow import ( MODEL_NAME, pick_device, pick_dtype, ) from transformers import AutoModelForCausalLM, AutoTokenizer device = pick_device() tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) model = AutoModelForCausalLM.from_pretrained( MODEL_NAME, torch_dtype=pick_dtype(device), ).to(device) model.eval() return tokenizer, model, device # --- アクセス制御 / APIキー ----------------------------------------------- @st.cache_resource(show_spinner=False) def _cleanup_once() -> bool: """プロセス起動後に一度だけ、古いセッションデータを掃除する。""" sp.cleanup_old_sessions(max_age_hours=24.0) return True def require_passkey() -> None: """APP_PASSKEY が設定されている場合、正しいパスキーの入力を求める。""" if not APP_PASSKEY: return if st.session_state.get("authed"): return st.title("🔒 ログイン") st.caption("このアプリを使うにはパスキーが必要です。") with st.form("passkey_form"): entered = st.text_input("パスキー", type="password") submitted = st.form_submit_button("入る") if submitted: if entered == APP_PASSKEY: st.session_state.authed = True st.rerun() else: st.error("パスキーが違います。") st.stop() def resolve_api_key() -> str: """利用するAnthropic APIキーを決める。利用者入力 > サーバ既定。""" return st.session_state.get("user_api_key", "") or SERVER_API_KEY def require_api_key() -> str: """利用可能なAPIキーを確保する。なければ利用者に入力してもらう。""" api_key = resolve_api_key() if api_key: return api_key st.title("🔑 APIキーの入力") st.caption( "会話には Anthropic (Claude) の API キーが必要です。" "キーはこのセッション中だけメモリ上に保持され、サーバには保存されません。" ) with st.form("apikey_form"): entered = st.text_input("Anthropic API キー (sk-ant-...)", type="password") submitted = st.form_submit_button("開始する") if submitted: if entered.strip(): st.session_state.user_api_key = entered.strip() st.rerun() else: st.error("APIキーを入力してください。") st.markdown( "キーは [Anthropic Console](https://console.anthropic.com/settings/keys) で取得できます。" ) st.stop() # --- 画面共通の初期化 ----------------------------------------------------- st.set_page_config(page_title="チャット", page_icon=":speech_balloon:") st.markdown(ui.CSS_BLOCK, unsafe_allow_html=True) _cleanup_once() # 1) パスキーによるアクセス制御 require_passkey() # 2) セッション専用のデータ保存先を有効化 (利用者ごとに会話を分離) if "session_id" not in st.session_state: st.session_state.session_id = uuid4().hex sp.use_session(st.session_state.session_id) # 3) APIキーを確保 api_key = require_api_key() if "initialized" not in st.session_state: st.session_state.in_setup = True st.session_state.memory = mem.init_user_memory() st.session_state.initialized = True st.session_state.messages = [] memory = st.session_state.memory llm = get_llm(api_key) def render_sidebar() -> None: """APIキーの切り替えやログアウトなどの操作。""" with st.sidebar: st.subheader("アカウント") if st.session_state.get("user_api_key"): st.caption("APIキー: あなたのキーを使用中") if st.button("APIキーを消す", use_container_width=True): st.session_state.pop("user_api_key", None) st.rerun() elif SERVER_API_KEY: st.caption("APIキー: サーバ既定のキーを使用中") with st.expander("自分のAPIキーを使う"): with st.form("override_key_form"): k = st.text_input("Anthropic API キー", type="password") if st.form_submit_button("使う") and k.strip(): st.session_state.user_api_key = k.strip() st.rerun() if APP_PASSKEY and st.button("ログアウト", use_container_width=True): for key in ("authed", "user_api_key"): st.session_state.pop(key, None) st.rerun() render_sidebar() if st.session_state.pop("api_key_error", False): st.error("APIキーが正しくないようです。サイドバーの『アカウント』から設定し直してください。") def _save_uploaded_text(uploaded_file) -> Path | None: """アップロードされた .txt をこのセッションの text_save/ に1つだけ保存する。""" text_save_dir = sp.text_save_dir() text_save_dir.mkdir(parents=True, exist_ok=True) for old_file in text_save_dir.glob("*.txt"): old_file.unlink() filename = Path(str(uploaded_file.name)).name file_path = text_save_dir / filename file_path.write_bytes(uploaded_file.getvalue()) return file_path def _get_saved_text_file() -> Path | None: txt_files = list(sp.text_save_dir().glob("*.txt")) return txt_files[0] if txt_files else None def _self_name_from_memory(memory_dict: dict[str, Any]) -> str: profile = memory_dict.get("user_profile", {}) return ( profile.get("user_name") or profile.get("nickname") or "Rayta" ) #AI 返信を空行で分割し、複数バブルとして扱う def _split_into_bubbles(text: str) -> list[str]: parts = [s.strip() for s in text.split("\n\n") if s.strip()] return parts or [text.strip()] def generate_reply(memory_dict, conversation, llm, placeholder, persona_name) -> str: recent_conversation = conversation[-HISTORY_TURNS*2:] self_name = _self_name_from_memory(memory_dict) system_blocks = build_system_blocks(memory_dict, sp.text_save_dir(), self_name) msgs: list[dict[str, str]] = [] for turn in recent_conversation: role = turn.get("role") text = turn.get("text") or turn.get("content") or "" if not text: continue if role in ("user", "assistant"): msgs.append({"role": role, "content": text}) try: response = llm.messages.create( model=CHAT_MODEL, max_tokens=MAX_OUTPUT_TOKENS, temperature=0.85, system=system_blocks, messages=msgs, ) reply = next( (block.text for block in response.content if block.type == "text"), "", ) or "" placeholder.markdown( ui.message_html("assistant", reply, persona_name, True), unsafe_allow_html=True, ) except anthropic.AuthenticationError: st.session_state["api_key_error"] = True return "APIキーが正しくないみたい。左のメニューからキーを設定し直してね。" except anthropic.RateLimitError: return "ごめんね、今ちょっと API の利用上限に当たってるみたい。少し時間置いて。" except (anthropic.APIError, anthropic.APIConnectionError): return "ごめん、今うまく返事できなかった。もう一度だけ送ってみて。" return reply.strip() or "..." #ユーザーが送信したけどまだAIが返信していない状態を処理 def _process_pending(memory, llm, placeholder, ai_persona) -> None: last_user = st.session_state.messages[-1]["content"] mem.add_message(memory, "user", last_user) #会話履歴にユーザーメッセージを追加 recent_conversation = memory.get("conversation_history", []) raw = generate_reply(memory, recent_conversation, llm, placeholder, ai_persona["name"]) bubbles = _split_into_bubbles(raw) #ストリーミングで描画した内容を会話履歴に保存 (\n\n で複数バブルに分割) for b in bubbles: st.session_state.messages.append({"role": "assistant", "content": b}) mem.add_message(memory, "assistant", b) mem.save_memory(memory) if len(memory.get("conversation_history", [])) >= mem.BATCH_SIZE: # 履歴の要約はローカルモデルを使う重い処理。失敗しても会話は続行する。 try: summary_tokenizer, summary_model, summary_device = get_summary_model() mem.update_summary( memory, tokenizer=summary_tokenizer, model=summary_model, device=summary_device, ) mem.save_memory(memory) except Exception: pass # --- 画面 1: 初期設定 ----------------------------------------------------- def render_setup(memory): profile = memory["user_profile"] ai_persona = memory["ai_persona"] st.markdown(ui.header_html("人物設定"), unsafe_allow_html=True) st.subheader("相手の人物設定") ai_persona["name"] = st.text_input( "相手の名前", ai_persona.get("name", ""), key="setup_persona_name" ) st.subheader("あなたについて") profile["user_name"] = st.text_input( "あなたの名前", profile.get("user_name", ""), key="setup_user_name" ) profile["nickname"] = st.text_input( "どう呼ばれてる?(例: あなたの名前が「太郎」なら「タロちゃん」とか)", profile.get("nickname", ""), key="setup_nickname", ) profile["goals"] = st.text_input( "このアプリで何を練習したい?(例: 恋愛会話の練習 / 雑談を続ける練習)", profile.get("goals", ""), key="setup_goals", ) st.subheader("二人の関係性") st.caption( "自由に書いてください。**Ex) 知り合い / 友人 / 親しい友人 / 恋人 / 彼氏 / 幼馴染** " ) ai_persona["persona"] = st.text_area( "相手との関係性 (例: 大学のゼミで知り合った彼氏。半年前から付き合ってる)", ai_persona.get("persona", ""), #初期値 height=80, key="setup_ai_persona", ) st.subheader("テキストファイルのアップロード") uploaded = st.file_uploader( ".txt ファイルを選択 ", type=["txt"], accept_multiple_files=False, key="setup_text_upload", ) if uploaded and st.button("アップロード"): saved = _save_uploaded_text(uploaded) st.success(f"保存しました: {saved}") st.divider() col1, col2 = st.columns([3, 1]) with col1: if st.button( "会話を始める", type="primary", use_container_width=True, key="setup_go" ): if not ai_persona["name"].strip(): st.error("彼の名前を入力してください") else: mem.save_memory(memory) st.session_state.pop("persona_update_error", None) txt_file = _get_saved_text_file() if txt_file is not None: self_name = _self_name_from_memory(memory) try: with st.spinner("LINE履歴から人物像を作成中..."): updated_memory = update_persona_text( sp.user_memory_path(), txt_file, self_name, ) if isinstance(updated_memory, dict): st.session_state.memory = mem.load_memory() memory = st.session_state.memory except Exception as e: st.session_state.persona_update_error = ( f"LINE履歴の分析に失敗しました: {e}" ) try: with st.spinner("LINE履歴から会話要約を作成中..."): summary_tokenizer, summary_model, summary_device = get_summary_model() mem.create_summary_from_txt( memory, txt_file, self_name, max_lines=mem.BATCH_SIZE, tokenizer=summary_tokenizer, model=summary_model, device=summary_device, ) mem.save_memory(memory) except Exception as e: st.session_state.persona_update_error = ( f"LINE履歴からの要約生成に失敗しました: {e}" ) st.session_state.in_setup = False st.rerun() with col2: if st.button("リセット", use_container_width=True, key="setup_reset"): import shutil text_save_dir = sp.text_save_dir() shutil.rmtree(text_save_dir, ignore_errors=True) text_save_dir.mkdir(parents=True, exist_ok=True) st.session_state.memory = mem.init_user_memory() st.session_state.messages = [] st.session_state.in_setup = True st.rerun() # --- 画面 2: 会話 --------------------------------------------------------- def render_chat(): memory = st.session_state.memory ai_persona = memory["ai_persona"] if st.session_state.get("persona_update_error"): st.warning(st.session_state.persona_update_error) st.header("設定") if st.button("人物設定に戻る", use_container_width=True): st.session_state.in_setup = True st.rerun() st.divider() st.caption( f"**{ai_persona['name']}**" ) st.divider() if st.button("会話履歴をリセット", use_container_width=True): st.session_state.memory = mem.init_user_memory(True) st.session_state.messages = [] st.rerun() st.markdown(ui.header_html(ai_persona["name"]), unsafe_allow_html=True) st.markdown( ui.chat_html(st.session_state.messages, ai_persona["name"]), unsafe_allow_html=True, ) if ( st.session_state.messages and st.session_state.messages[-1]["role"] == "user" ): #placeholder: 最初は typing インジケータ、ストリーミング開始後は実バブルへ更新 placeholder = st.empty() placeholder.markdown( ui.typing_html(ai_persona["name"]), unsafe_allow_html=True ) _process_pending(memory, llm, placeholder, ai_persona) st.rerun() if user_input := st.chat_input("メッセージを入力..."): st.session_state.messages.append({"role": "user", "content": user_input}) st.rerun() # --- ルーティング -------- if st.session_state.in_setup: render_setup(memory) else: render_chat()