前回、OCI で AI チャットを作りました。
あれはあれで、良いんですが、もうちょっと頑張って 履歴保存が出来るようにしてみました。
履歴保存するには、ユーザーを管理しないと出来ないんで、googleさんに認証してもらうことにしました。
別に、streamlit が対応してりゃ、他でも出来ると思います。
但し、ユーザーを一意識別する方法は、調べないとダメですよ。
ということで、なんちゃって 履歴保存付き AI チャットです。
履歴保存には、OCIの NoSQL を使いました。
しかしこれ、NoSQLなのに、がっつり SQL使ってるんですけど、どういうこと。
まー、良いんですけど、Deleteとかって、何故か一括で削除出来ないとか、仕様がよくわからんです。
ということで、これが完成ソースだー
import streamlit as st
import oci
from oci.generative_ai import GenerativeAiClient
from oci.generative_ai_inference import GenerativeAiInferenceClient
from oci.generative_ai_inference.models import (
ChatDetails,
CohereChatRequest,
OnDemandServingMode
)
import uuid
import datetime
import pytz
import hashlib
# テーマの取得
theme = "dark" if st.config.get_option("theme.base") == "dark" else "light"
# モバイル表示の問題を修正
# テーマに応じたCSSを適用
st.markdown(f"""
<style>
@media (max-width: 800px) {{
.stChatInput {{
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding: 10px;
z-index: 1000;
transition: background-color 0.3s ease;
}}
.stChatInput textarea {{
width: 100%;
box-sizing: border-box;
}}
/* Lightモードのスタイル */
.{theme}-mode .stChatInput {{
background-color: #ffffff;
border-top: 1px solid #ccc;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1);
}}
/* Darkモードのスタイル */
.dark-mode .stChatInput {{
background-color: #1e1e1e;
border-top: 1px solid #444;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.5);
}}
.dark-mode .stChatInput textarea {{
color: #ffffff;
background-color: #1e1e1e;
}}
}}
</style>
""", unsafe_allow_html=True)
# --- OCI NoSQL Database の設定 ---
NOSQL_TABLE_NAME = "ChatHistory" # NoSQLテーブル名
# --- OCI設定ファイルのロード ---
config = oci.config.from_file("~/.oci/config", "DEFAULT")
COMPARTMENT_ID = "ocid1.compartment.oc1..aaaaaaaamgmw22hogecwqnunirb3urhoger4ihdgoilkdjkv2sabokaq5svc"
# NoSQLクライアントの初期化
nosqlcl : oci.nosql.nosql_client.NosqlClient = oci.nosql.nosql_client.NosqlClient(config)
# Generative AI クライアントの初期化
DEFAULT_MODEL = "cohere.command-a-03-2025"
client = GenerativeAiInferenceClient(config=config)
generative_ai_client = GenerativeAiClient(config)
jst_timezone = pytz.timezone('Asia/Tokyo')
st.markdown("""
<style>
@media (max-width: 800px) {
.stChatInput {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background-color: white;
padding: 10px;
z-index: 1000;
}
}
</style>
""", unsafe_allow_html=True)
#日時変換->JST
def parseDateTime( tm ) :
return datetime.datetime.fromisoformat(tm.replace('Z', '+00:00')).astimezone(jst_timezone)
# セッションID生成
def generate_unique_session_id() -> str:
"""一意性を保ちつつ、より短いセッションIDを生成する。"""
return hashlib.md5(str(uuid.uuid4()).encode('utf-8')).hexdigest()[:12]
# 索引作成
def create_session_id_index_if_not_exists():
print(f"NoSQL インデックス 'idx_session_id' を確認・作成中...")
try:
index_name = "idx_session_id"
create_index_details = oci.nosql.models.CreateIndexDetails(
name=index_name,
compartment_id=COMPARTMENT_ID,
keys=[
oci.nosql.models.IndexKey(column_name="user_id"), # user_idも指定してクエリと合わせる
oci.nosql.models.IndexKey(column_name="session_id")
],
is_if_not_exists = True
)
response: oci.response.Response = nosqlcl.create_index(table_name_or_id=NOSQL_TABLE_NAME,create_index_details=create_index_details)
print(f"インデックス作成リクエスト送信済み。ワークリクエストID: {response.request_id}")
oci.wait_until(
nosqlcl,
nosqlcl.get_index(table_name_or_id=NOSQL_TABLE_NAME, index_name=index_name),
'lifecycle_state',
'ACTIVE'
)
print(f"インデックス '{index_name}' が ACTIVE 状態になりました。")
except oci.exceptions.ServiceError as e:
if e.code == 'IndexAlreadyExists':
print(f"インデックス '{index_name}' は既に存在します。")
else:
print(f"インデックス作成中にエラーが発生しました: {e}")
print(f"詳細エラーメッセージ: {e.message}")
except Exception as e:
print(f"予期せぬエラー: {e}")
# テーブル作成
def create_chat_history_table_if_not_exists():
"""チャット履歴テーブルが存在しない場合に作成する"""
print(f"NoSQL テーブル '{NOSQL_TABLE_NAME}' を確認・作成中...")
try:
try:
print(f"NoSQL テーブル '{NOSQL_TABLE_NAME}' を確認中...")
# テーブルが既に存在するか確認
response: oci.response.Response = nosqlcl.get_table(table_name_or_id=NOSQL_TABLE_NAME, compartment_id=COMPARTMENT_ID)
tbl : oci.nosql.models.Table = response.data
if tbl.lifecycle_state == oci.nosql.models.Table.LIFECYCLE_STATE_ACTIVE:
print(f"テーブル '{NOSQL_TABLE_NAME}' は既に存在します。")
return
except oci.exceptions.ServiceError as e:
if e.message.startswith('Table not found') == False:
print(f"テーブル確認中にエラーが発生しました: {e.message}")
return
print(f"NoSQL テーブル '{NOSQL_TABLE_NAME}' を作成中...")
# TableLimitsを定義 (プロビジョニング容量の例)
table_limits = oci.nosql.models.TableLimits(
max_read_units=10,
max_write_units=5,
max_storage_in_g_bs=1,
capacity_mode = oci.nosql.models.TableLimits.CAPACITY_MODE_PROVISIONED
)
ddl_statement = f"""
CREATE TABLE {NOSQL_TABLE_NAME} (
user_id STRING,
session_id STRING,
message_timestamp TIMESTAMP(3),
role STRING,
message STRING,
PRIMARY KEY (SHARD(user_id), session_id, message_timestamp)
) USING TTL 90 days
"""
create_table_details = oci.nosql.models.CreateTableDetails(
name=NOSQL_TABLE_NAME,
compartment_id=COMPARTMENT_ID,
ddl_statement=ddl_statement,
table_limits=table_limits
)
response: oci.response.Response = nosqlcl.create_table(create_table_details)
print(f"テーブル作成リクエスト送信済み。ワークリクエストID: {response.request_id}")
oci.wait_until(
nosqlcl,
nosqlcl.get_table(table_name_or_id=NOSQL_TABLE_NAME, compartment_id=COMPARTMENT_ID),
'lifecycle_state',
'ACTIVE'
)
print(f"テーブル '{NOSQL_TABLE_NAME}' が ACTIVE 状態になりました。")
create_session_id_index_if_not_exists()
except oci.exceptions.ServiceError as e:
if e.code == 'TableAlreadyExists':
print(f"テーブル '{NOSQL_TABLE_NAME}' は既に存在します。")
else:
print(f"テーブル作成中にエラーが発生しました: {e}")
print(f"詳細エラーメッセージ: {e.message}")
except Exception as e:
print(f"予期せぬエラー: {e}")
# チャット履歴保存
def save_chat_message(user_id: str, session_id: str, role: str, message: str):
"""チャットメッセージをNoSQL Databaseに保存する"""
try:
timestamp = datetime.datetime.now(pytz.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
row_data = {
'user_id': user_id,
'session_id': session_id,
'message_timestamp': timestamp,
'role': role,
'message': message
}
put_row_details = oci.nosql.models.UpdateRowDetails(
compartment_id=COMPARTMENT_ID,
value=row_data
)
nosqlcl.update_row(
table_name_or_id=NOSQL_TABLE_NAME,
update_row_details=put_row_details
)
except Exception as e:
print(f"NoSQL: メッセージ保存中にエラーが発生しました: {e}")
# チャット履歴ロード (特定のセッションIDを指定)
def load_chat_history_for_session(user_id: str, session_id: str):
"""NoSQL Databaseから特定のセッションのチャット履歴を読み込む"""
print(f"NoSQL: ユーザー {user_id}, セッション {session_id} の履歴を読み込み中...")
history = []
try:
query_statement = f"""
SELECT role, message, message_timestamp
FROM {NOSQL_TABLE_NAME}
WHERE user_id = '{user_id}' AND session_id = '{session_id}'
"""
query_details = oci.nosql.models.QueryDetails(
compartment_id=COMPARTMENT_ID,
statement=query_statement
)
response: oci.response.Response = nosqlcl.query(query_details)
result: oci.nosql.models.QueryResultCollection = response.data
for item in result.items:
st_role = "assistant" if item['role'] == "CHATBOT" else "user"
history.append({"role": st_role, "message": item['message']})
print(f"NoSQL: {len(history)} 件の履歴を読み込みました。")
except Exception as e:
print(f"NoSQL: 履歴読み込み中にエラーが発生しました: {e}")
return history
# ユーザーの全セッションIDを取得する新しい関数
def get_user_session_ids(user_id: str):
"""指定されたユーザーのすべてのチャットセッションIDをNoSQL Databaseから取得する"""
print(f"NoSQL: ユーザー {user_id} のセッションIDを検索中...")
session_ids = dict()
try:
# DISTINCTキーワードを使用して、重複しないsession_idを取得
query_statement = f"""
SELECT session_id, min(message_timestamp) as timestamp
FROM {NOSQL_TABLE_NAME}
WHERE user_id = '{user_id}'
GROUP BY session_id
"""
query_details = oci.nosql.models.QueryDetails(
compartment_id=COMPARTMENT_ID,
statement=query_statement
)
response: oci.response.Response = nosqlcl.query(query_details)
result: oci.nosql.models.QueryResultCollection = response.data
for item in result.items:
s_id = item['session_id']
t_stamp = item['timestamp']
session_ids[s_id] = t_stamp
print(f"NoSQL: {len(session_ids)} 件のセッションIDを検出しました。")
except Exception as e:
print(f"NoSQL: セッションID取得中にエラーが発生しました: {e}")
return session_ids
# ユーザーの指定セッションを削除する
def delete_user_session(user_id: str, session_id: str):
"""指定されたユーザーの特定のチャットセッションをNoSQL Databaseから削除する"""
print(f"NoSQL: ユーザー {user_id}, セッション {session_id} を削除中...")
try:
query_statement = f"""
SELECT role, message, message_timestamp
FROM {NOSQL_TABLE_NAME}
WHERE user_id = '{user_id}' AND session_id = '{session_id}'
"""
query_details = oci.nosql.models.QueryDetails(
compartment_id=COMPARTMENT_ID,
statement=query_statement
)
response: oci.response.Response = nosqlcl.query(query_details)
result: oci.nosql.models.QueryResultCollection = response.data
for item in result.items:
response: oci.response.Response = nosqlcl.delete_row(
compartment_id=COMPARTMENT_ID,
table_name_or_id=NOSQL_TABLE_NAME,
key=[f"user_id:{user_id}",f"session_id:{session_id}", f"message_timestamp:{item['message_timestamp']}"]
)
drr:oci.nosql.models.DeleteRowResult = response.data
print(f"NoSQL: セッション {session_id} の削除が完了しました。")
except Exception as e:
print(f"NoSQL: セッション削除中にエラーが発生しました: {e}")
# 許可確認する
def isContain(oid) :
return True
#モデル一覧
available_models = []
ret:oci.response.Response = generative_ai_client.list_models( compartment_id=COMPARTMENT_ID)
models:oci.generative_ai.models.ModelCollection = ret.data
model:oci.generative_ai.models.Model
for model in models.items:
if( "CHAT" in model.capabilities ):
available_models.append(model.display_name)
#タイトル
st.title("OCI AI Chat")
if not st.user.is_logged_in:
st.title("ログインしてください")
if st.button("Googleでログイン"):
st.login("google")
st.stop()
else:
oid = st.user.get("sub")
if 'nosql_table_checked' not in st.session_state:
create_chat_history_table_if_not_exists()
st.session_state.nosql_table_checked = True
if 'current_chat_session_id' not in st.session_state:
st.session_state.current_chat_session_id = None
if 'messages_loaded_for_session' not in st.session_state:
st.session_state.messages_loaded_for_session = None
# ログアウトボタン
if st.button("ログアウト"):
st.logout()
# 利用可能権限チェック
if( isContain(oid) == False ) :
st.write("許可されていません")
else :
st.sidebar.header(f"Login: {st.user.name}")
selected_model = st.sidebar.selectbox("使用するモデルを選択", available_models, index=available_models.index(DEFAULT_MODEL) if DEFAULT_MODEL in available_models else 0)
# セッションIDの管理と選択
# ユーザーの全セッションIDを取得
all_session_ids: dict = get_user_session_ids(oid)
# 新しいセッションを開始するためのオプションを追加
NEWCHAT = "新しいチャットを開始"
options : dict = dict()
options["-1"] = NEWCHAT
for s_id,timestamp in all_session_ids.items():
options[s_id] = parseDateTime(timestamp)
# サイドバーでセッションを選択
selected_session_option = st.sidebar.selectbox(
"過去チャットを選択",
options,
index=0,
format_func = lambda s_id: f"{options[s_id]}",
key="session_select_box"
)
print(f"{selected_model},{selected_session_option},{st.session_state.current_chat_session_id}")
# 新しいセッションIDが既存のものと異なる場合のみリセット
if selected_session_option == "-1": # 仮のIDを設定して区別
if st.session_state.messages_loaded_for_session is None and st.session_state.current_chat_session_id is not None:
#新規で継続中
st.session_state.messages = load_chat_history_for_session(oid, st.session_state.current_chat_session_id)
else :
st.session_state.current_chat_session_id = generate_unique_session_id()
st.session_state.messages = []
st.session_state.messages_loaded_for_session = None
else:
print(f"履歴ロード {options[selected_session_option]}")
# 選択された既存のセッションIDをロード
if st.session_state.current_chat_session_id != selected_session_option:
st.session_state.current_chat_session_id = selected_session_option
st.session_state.messages = load_chat_history_for_session(oid, st.session_state.current_chat_session_id)
st.session_state.messages_loaded_for_session = selected_session_option
# セッションのリセットボタン
if st.session_state.messages_loaded_for_session is None and st.session_state.current_chat_session_id is not None:
if st.sidebar.button("リセット"):
st.session_state.current_chat_session_id = None
st.session_state.messages = []
st.session_state.messages_loaded_for_session = None
st.rerun()
# 選択されたセッション履歴を削除
if selected_session_option != "-1":
if st.sidebar.button("削除"):
delete_user_session(oid, st.session_state.current_chat_session_id)
st.session_state.current_chat_session_id = None
st.session_state.messages = []
st.session_state.messages_loaded_for_session = None
st.rerun()
# チャット履歴表示
for message in st.session_state.messages:
role = "assistant" if message["role"] == "assistant" else "user"
with st.chat_message(role):
st.markdown(message["message"])
# チャット 入力待ち
if prompt := st.chat_input("ここにメッセージを入力してください..."):
# セッション チャット履歴追加
st.session_state.messages.append({"role": "user", "message": prompt})
# DB チャット履歴追加
save_chat_message(oid, st.session_state.current_chat_session_id, "USER", prompt)
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
with st.spinner("思考中..."):
chat_history = []
for message in st.session_state.messages:
if message["role"] == "user":
chat_history.append({"role": "USER", "message": message["message"]})
elif message["role"] == "assistant":
chat_history.append({"role": "CHATBOT", "message": message["message"]})
chat_request = CohereChatRequest(
message=prompt,
chat_history=chat_history[:-1] if chat_history else None,
max_tokens=3000,
temperature=0.7,
is_echo=True,
is_stream=False
)
serving_mode = OnDemandServingMode(model_id=selected_model)
chat_details = ChatDetails(
compartment_id=COMPARTMENT_ID,
chat_request=chat_request,
serving_mode=serving_mode
)
response = client.chat(chat_details)
bot_reply = response.data.chat_response.text
if bot_reply:
# セッション チャット履歴追加
st.session_state.messages.append({"role": "assistant", "message": bot_reply})
# DB チャット履歴追加
save_chat_message(oid, st.session_state.current_chat_session_id, "CHATBOT", bot_reply)
# 出力
st.markdown(bot_reply)
認証SSOは、GCP で、OAuth 2.0 クライアント ID を取得して 良い感じの 承認済みのリダイレクト URI を指定すればOkです。
./streamlit/secrets.toml
後は、ChatGPT とか、Gemeni に聞けばわかります。
こんな感じ Darkモードですね