import gradio as nn_gradio # Rename to avoid name shadowing import openai import time import os import random import tempfile import re import html # --- Detect HF Spaces Environment or local env keys --- IS_HF_SPACE = (os.getenv("SYSTEM") == "spaces") or (os.getenv("SPACE_ID") is not None) DEFAULT_HF_TOKEN = os.getenv("HF_TOKEN", "") DEFAULT_OPENAI_KEY = os.getenv("OPENAI_API_KEY", "") if IS_HF_SPACE: # If in HF Space, default to HF Serverless Inference with Llama-3 model DEFAULT_API_KEY = DEFAULT_HF_TOKEN DEFAULT_BASE_URL = "https://router.huggingface.co/v1" DEFAULT_MODEL = "google/gemma-4-26B-A4B-it" else: # Default local configuration DEFAULT_API_KEY = DEFAULT_OPENAI_KEY DEFAULT_BASE_URL = "https://api.openai.com/v1" DEFAULT_MODEL = "手動入力(gpt-4o-miniなど)" # --- Premium CSS Styling --- THEME_CSS = """ /* Typography & Reset */ body, html { font-family: 'Outfit', 'Inter', -apple-system, sans-serif !important; width: 100% !important; margin: 0 !important; padding: 0 !important; } .gradio-container { max-width: 100% !important; width: 100% !important; margin: 0 auto !important; padding: 24px !important; transition: background-color 0.3s ease, color 0.3s ease; } /* --- Light Mode Colors & Styles --- */ :root, .gradio-container { --body-background-fill: #f8fafc !important; --block-background-fill: #ffffff !important; --block-border-color: rgba(0, 0, 0, 0.08) !important; --block-border-width: 1px !important; --block-title-text-color: #4f46e5 !important; --input-background-fill: #ffffff !important; --input-border-color: rgba(0, 0, 0, 0.1) !important; --input-text-color: #0f172a !important; --body-text-color: #0f172a !important; --background-fill-primary: #ffffff !important; --background-fill-secondary: #f8fafc !important; --border-color-primary: rgba(0, 0, 0, 0.08) !important; --button-secondary-background-fill: #f1f5f9 !important; --button-secondary-text-color: #0f172a !important; --button-secondary-border-color: rgba(0, 0, 0, 0.1) !important; --slider-color: #4f46e5 !important; } body, html { background-color: #f8fafc !important; color: #0f172a !important; } .gradio-container { background-color: #f8fafc !important; } /* Glassmorphism panels in Light Mode */ .glass-panel { background: rgba(255, 255, 255, 0.95) !important; border: 1px solid rgba(0, 0, 0, 0.06) !important; border-radius: 16px !important; padding: 20px !important; box-shadow: 0 8px 32px 0 rgba(148, 163, 184, 0.1) !important; } /* Chat bubble styling for Light Mode */ .chat-bubble { padding: 14px 18px; border-radius: 12px; margin: 8px 0; line-height: 1.6; font-size: 0.98rem; border-left: 5px solid; transition: all 0.2s ease; box-shadow: 0 4px 12px rgba(148, 163, 184, 0.08); } .chat-bubble:hover { transform: translateY(-1px); box-shadow: 0 6px 16px rgba(148, 163, 184, 0.15); } .bubble-persona-0 { background: rgba(99, 102, 241, 0.08) !important; border-color: #4f46e5 !important; color: #1e1b4b !important; } .bubble-persona-1 { background: rgba(236, 72, 153, 0.08) !important; border-color: #db2777 !important; color: #500724 !important; } .bubble-persona-2 { background: rgba(16, 185, 129, 0.08) !important; border-color: #059669 !important; color: #064e3b !important; } .bubble-persona-3 { background: rgba(245, 158, 11, 0.08) !important; border-color: #d97706 !important; color: #451a03 !important; } /* --- Dark Mode Colors & Styles --- */ .dark, .dark .gradio-container { --body-background-fill: #0f111a !important; --block-background-fill: #161c2d !important; --block-border-color: rgba(255, 255, 255, 0.08) !important; --block-border-width: 1px !important; --block-title-text-color: #a5b4fc !important; --input-background-fill: #0f111a !important; --input-border-color: rgba(255, 255, 255, 0.1) !important; --input-text-color: #e2e8f0 !important; --body-text-color: #e2e8f0 !important; --background-fill-primary: #161c2d !important; --background-fill-secondary: #0f111a !important; --border-color-primary: rgba(255, 255, 255, 0.08) !important; --button-secondary-background-fill: #1e293b !important; --button-secondary-text-color: #e2e8f0 !important; --button-secondary-border-color: rgba(255, 255, 255, 0.1) !important; --slider-color: #6366f1 !important; } .dark body, .dark html { background-color: #0f111a !important; color: #e2e8f0 !important; } .dark .gradio-container { background-color: #0f111a !important; } /* Glassmorphism panels in Dark Mode */ .dark .glass-panel { background: rgba(22, 28, 45, 0.95) !important; border: 1px solid rgba(255, 255, 255, 0.08) !important; box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37) !important; } /* Chat bubble styling for Dark Mode */ .dark .chat-bubble { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .dark .chat-bubble:hover { box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25); } .dark .bubble-persona-0 { background: rgba(99, 102, 241, 0.15) !important; border-color: #6366f1 !important; color: #e0e7ff !important; } .dark .bubble-persona-1 { background: rgba(236, 72, 153, 0.15) !important; border-color: #ec4899 !important; color: #fce7f3 !important; } .dark .bubble-persona-2 { background: rgba(16, 185, 129, 0.15) !important; border-color: #10b981 !important; color: #d1fae5 !important; } .dark .bubble-persona-3 { background: rgba(245, 158, 11, 0.15) !important; border-color: #f59e0b !important; color: #fef3c7 !important; } /* Buttons and accents */ .start-btn { background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%) !important; border: none !important; color: white !important; font-weight: 600 !important; border-radius: 8px !important; } .start-btn:hover { filter: brightness(1.1) !important; } /* Align items to bottom in a Row */ .align-end { align-items: flex-end !important; } /* Refresh Button styling */ .refresh-btn { height: 84px !important; min-height: 84px !important; max-height: 84px !important; display: flex !important; align-items: center !important; justify-content: center !important; padding: 0 !important; font-size: 2rem !important; background: #f1f5f9 !important; border: 1px solid rgba(0, 0, 0, 0.1) !important; color: #4f46e5 !important; transition: all 0.2s ease !important; border-radius: 8px !important; cursor: pointer !important; margin-bottom: 0px !important; } .refresh-btn:hover { background: #e2e8f0 !important; color: #4f46e5 !important; border-color: rgba(99, 102, 241, 0.3) !important; transform: translateY(-1px); } .dark .refresh-btn { background: #1e293b !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; color: #a5b4fc !important; } .dark .refresh-btn:hover { background: #334155 !important; color: #a5b4fc !important; border-color: rgba(165, 180, 252, 0.3) !important; } /* Mobile responsive optimizations */ @media (max-width: 768px) { .gradio-container { padding: 8px !important; } .glass-panel { padding: 12px !important; margin-bottom: 12px !important; border-radius: 12px !important; } .chat-bubble { padding: 10px 14px !important; font-size: 0.92rem !important; } } """ HEAD_HTML = '' # --- Localization & Translation Setup --- I18N_DATA = { "en": { "app_title": "# 🌐 tAIwa - Multi-Persona Chatroom", "app_desc": "A tool where multiple AI agents (and you) can converse freely.", "api_config": "### ⚙️ 1. OpenAI-compatible API Configuration", "api_key": "API Key", "api_key_placeholder": "Leave empty for shared Hugging Face Token...", "api_key_info": "If empty, the shared space token (HF Serverless Inference) will be used. If you hit rate limits, please input your own Hugging Face Token or OpenAI Key.", "base_url": "Base URL", "model_name": "Model Name", "persona_config": "### 👥 2. Persona Settings", "num_personas": "Number of Personas", "persona_title_1": "#### Ichinohe", "persona_title_2": "#### Ninomiya", "persona_title_3": "#### Miura", "persona_title_4": "#### Shinomiya", "name_label": "Name", "human_label": "You (Human)", "system_prompt_label": "System Prompt / Description", "system_prompt_default": "You are an expert with critical thinking. Speak in a concise and conversational tone.", "persona_default_name_1": "Ichinohe", "persona_default_name_2": "Ninomiya", "persona_default_name_3": "Miura", "persona_default_name_4": "Shinomiya", "chatroom_panel": "### 💬 Chatroom Panel", "agenda_label": "Agenda / Topic", "agenda_placeholder": "Enter the discussion topic...", "agenda_default": "What is the biggest challenge in deploying local AI models?", "btn_start": "🚀 Start", "btn_next": "➡️ Next Turn", "btn_reset": "🔄 Reset", "btn_undo": "↩️ Undo", "human_resp_label": "Your Response", "human_resp_placeholder": "Enter your response...", "btn_send": "Send", "autoplay_label": "Auto-Play", "preset_label": "Preset Template", "preset_custom": "Custom (Manual Setup)", "btn_copy": "📋 Copy Log", "btn_save": "💾 Save Log", "file_label": "Download Log" }, "ja": { "app_title": "# 🌐 tAIwa - マルチペルソナ チャットルーム", "app_desc": "複数のAIエージェント(とあなた)が自由に対話できるツールです。", "api_config": "### ⚙️ 1. OpenAI互換API設定", "api_key": "APIキー", "api_key_placeholder": "空欄時は共有Hugging Faceトークンを使用...", "api_key_info": "空欄の場合、Spaceの共有トークン(HF Serverless Inference)を使用します。レート制限などでエラーが発生する場合は、ご自身のHugging FaceトークンまたはOpenAI APIキーを入力してください。", "base_url": "ベースURL", "model_name": "モデル名", "persona_config": "### 👥 2. ペルソナ設定", "num_personas": "ペルソナの数", "persona_title_1": "#### 一戸", "persona_title_2": "#### 二宮", "persona_title_3": "#### 三浦", "persona_title_4": "#### 四宮", "name_label": "名前", "human_label": "あなた (Human)", "system_prompt_label": "システムプロンプト / 説明", "system_prompt_default": "あなたは批判的思考を持つ専門家です。簡潔かつ会話的なトーンで話してください。", "persona_default_name_1": "一戸", "persona_default_name_2": "二宮", "persona_default_name_3": "三浦", "persona_default_name_4": "四宮", "chatroom_panel": "### 💬 チャットルーム パネル", "agenda_label": "アジェンダ / トピック", "agenda_placeholder": "ディスカッショントピックを入力してください...", "agenda_default": "ローカルAIモデルをデプロイする上での最大の課題は何ですか?", "btn_start": "🚀 開始する", "btn_next": "➡️ 次のターン", "btn_reset": "🔄 リセット", "btn_undo": "↩️ 1つ戻す", "human_resp_label": "あなたの返答", "human_resp_placeholder": "返答を入力してください...", "btn_send": "送信", "autoplay_label": "自動進行", "preset_label": "プリセットテンプレート", "preset_custom": "カスタム(手動設定)", "btn_copy": "📋 ログをコピー", "btn_save": "💾 ログを保存", "file_label": "ログのダウンロード" } } if not IS_HF_SPACE: I18N_DATA["en"]["api_key_info"] = "Please enter your OpenAI API Key." I18N_DATA["ja"]["api_key_info"] = "OpenAI APIキーを入力してください。" I18N_DATA["en"]["api_key_placeholder"] = "API Key..." I18N_DATA["ja"]["api_key_placeholder"] = "APIキーを入力..." i18n = nn_gradio.I18n(**I18N_DATA) def get_language(request: nn_gradio.Request = None) -> str: """Helper to detect language from request headers.""" if not request: return "en" accept_lang = request.headers.get("accept-language", "en") if "ja" in accept_lang.lower(): return "ja" return "en" LOCALIZED_STRINGS = { "en": { "setup_failed_key": "⚠️ **Setup failed**: API Key is required.", "agenda_prefix": "📌 **Agenda/Topic**: {agenda}", "setup_instruction": "Configure settings on the left, then click **🚀 Start**.", "empty_chat_log": "
{code}'
text = text.replace(placeholder, styled)
for idx, block in enumerate(code_blocks):
placeholder = f"__CODE_BLOCK_PLACEHOLDER_{idx}__"
styled = f'{block}'
text = text.replace(placeholder, styled)
return text
def format_local_base_url(base_url):
"""Ensures local base URLs (like localhost:1234) have the /v1 suffix appended automatically."""
if not base_url:
return base_url
base_url_stripped = base_url.rstrip("/")
if any(loc in base_url_stripped.lower() for loc in ["localhost", "127.0.0.1", "::1"]):
if not base_url_stripped.endswith("/v1") and not base_url_stripped.endswith("/v1/"):
return base_url_stripped + "/v1"
return base_url_stripped
def generate_ai_response(api_key, base_url, model, system_prompt, full_prompt):
"""Calls OpenAI-compatible API to generate a response."""
base_url = format_local_base_url(base_url)
print(f"\n[AI Request] Connecting to API...")
print(f" Base URL: {base_url}")
print(f" Model: {model}")
try:
if not api_key:
is_local = any(loc in (base_url or "").lower() for loc in ["localhost", "127.0.0.1", "::1"])
if is_local:
api_key = "lm-studio"
else:
return "⚠️ System error: API Key is missing. Please set it in the config."
client = openai.OpenAI(
api_key=api_key,
base_url=base_url if base_url else None,
timeout=60.0
)
print(f" Sending chat completion request...")
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": full_prompt}
],
temperature=0.7,
max_tokens=100000
)
print(f" Response received successfully.")
return response.choices[0].message.content.strip()
except Exception as e:
print(f" [API Error] {e}")
return f"⚠️ API Error: {str(e)}"
def format_history_for_prompt(chat_log):
"""Formats the accumulated chat log for context in the LLM prompt."""
formatted_lines = []
for speaker, text in chat_log:
formatted_lines.append(f"{speaker}: {text}")
return "\n".join(formatted_lines)
def update_ui_state(lang, current_idx, participants_list):
"""Determines helper messages and handles human vs AI turn UI updates."""
if not participants_list:
return LOCALIZED_STRINGS[lang]["turn_start"], False
current_persona = participants_list[current_idx]
name = current_persona["name"]
is_human = current_persona["is_human"]
if is_human:
status_msg = LOCALIZED_STRINGS[lang]["turn_human"].format(name=name)
return status_msg, True
else:
status_msg = LOCALIZED_STRINGS[lang]["turn_ai"].format(name=name)
return status_msg, False
def update_models_dropdown(api_key, base_url, current_model):
"""Fetches the list of models from the OpenAI-compatible API and updates the dropdown choices."""
base_url = format_local_base_url(base_url)
if not api_key:
is_local = any(loc in (base_url or "").lower() for loc in ["localhost", "127.0.0.1", "::1"])
if is_local:
api_key = "lm-studio"
else:
choices = [current_model] if current_model else [DEFAULT_MODEL]
return nn_gradio.Dropdown(choices=choices, value=current_model, interactive=True)
try:
client = openai.OpenAI(
api_key=api_key,
base_url=base_url if base_url else None,
timeout=5.0
)
models_data = client.models.list()
model_ids = sorted([model.id for model in models_data.data])
if not model_ids:
choices = [current_model] if current_model else [DEFAULT_MODEL]
else:
choices = model_ids
if current_model and current_model not in choices:
choices = [current_model] + choices
return nn_gradio.Dropdown(choices=choices, value=current_model, interactive=True)
except Exception as e:
print(f"Error fetching models: {e}")
choices = [current_model] if current_model else [DEFAULT_MODEL]
return nn_gradio.Dropdown(choices=choices, value=current_model, interactive=True)
# Gradio interface build function
def build_app():
with nn_gradio.Blocks(title="tAIwa - Multi-Persona Chatroom") as demo:
# State stores
state_history = nn_gradio.State([]) # List of (speaker, content, index_for_color)
state_participants = nn_gradio.State([]) # List of persona dicts
state_current_idx = nn_gradio.State(0) # Index in the participant list
state_agenda = nn_gradio.State("") # Topic/Agenda
state_lang = nn_gradio.State("en") # Language State ("en" or "ja")
# Language Switcher Radio on top
with nn_gradio.Row():
language_selector = nn_gradio.Radio(
choices=["日本語", "English"],
value="English",
label="Language / 言語",
scale=2
)
app_title_md = nn_gradio.Markdown(I18N_DATA["en"]["app_title"])
app_desc_md = nn_gradio.Markdown(I18N_DATA["en"]["app_desc"])
with nn_gradio.Row():
# --- LEFT COLUMN: SETTINGS ---
with nn_gradio.Column(scale=4, elem_classes=["glass-panel"]):
api_config_md = nn_gradio.Markdown(I18N_DATA["en"]["api_config"])
api_key_input = nn_gradio.Textbox(
label=I18N_DATA["en"]["api_key"],
type="password",
placeholder=I18N_DATA["en"]["api_key_placeholder"],
info=I18N_DATA["en"]["api_key_info"],
value=DEFAULT_API_KEY
)
base_url_input = nn_gradio.Textbox(label=I18N_DATA["en"]["base_url"], placeholder="https://api.openai.com/v1", value=DEFAULT_BASE_URL)
with nn_gradio.Row(elem_classes=["align-end"]):
model_input = nn_gradio.Dropdown(
label=I18N_DATA["en"]["model_name"],
choices=[DEFAULT_MODEL],
value=DEFAULT_MODEL,
allow_custom_value=True,
scale=4
)
btn_fetch_models = nn_gradio.Button("🔄", scale=0, min_width=50, elem_classes=["refresh-btn"])
persona_config_md = nn_gradio.Markdown(I18N_DATA["en"]["persona_config"])
preset_dropdown = nn_gradio.Dropdown(
label=I18N_DATA["en"]["preset_label"],
choices=["Custom (Manual Setup)"],
value="Custom (Manual Setup)",
interactive=True
)
persona_count = nn_gradio.Slider(minimum=2, maximum=4, step=1, value=3, label=I18N_DATA["en"]["num_personas"])
# Persona Configuration Blocks (Max 4)
persona_configs = []
for i in range(4):
with nn_gradio.Group(visible=(i < 3)) as group:
p_title = nn_gradio.Markdown(I18N_DATA["en"][f"persona_title_{i+1}"])
with nn_gradio.Row():
name = nn_gradio.Textbox(label=I18N_DATA["en"]["name_label"], value=I18N_DATA["en"][f"persona_default_name_{i+1}"], scale=3)
is_human = nn_gradio.Checkbox(label=I18N_DATA["en"]["human_label"], value=False, scale=1)
prompt = nn_gradio.Textbox(label=I18N_DATA["en"]["system_prompt_label"], value=I18N_DATA["en"]["system_prompt_default"], lines=2)
persona_configs.append({
"group": group,
"title_md": p_title,
"name": name,
"is_human": is_human,
"prompt": prompt
})
# Handle dynamic slider visibility changes
def adjust_persona_visibility(count):
updates = []
for i in range(4):
updates.append(nn_gradio.Group(visible=(i < count)))
return updates
persona_count.change(
fn=adjust_persona_visibility,
inputs=[persona_count],
outputs=[cfg["group"] for cfg in persona_configs]
)
# --- RIGHT COLUMN: CHATROOM ---
with nn_gradio.Column(scale=6, elem_classes=["glass-panel"]):
chatroom_panel_md = nn_gradio.Markdown(I18N_DATA["en"]["chatroom_panel"])
with nn_gradio.Row():
agenda_input = nn_gradio.Textbox(
label=I18N_DATA["en"]["agenda_label"],
placeholder=I18N_DATA["en"]["agenda_placeholder"],
value=I18N_DATA["en"]["agenda_default"],
scale=8
)
btn_random_agenda = nn_gradio.Button("🎲", scale=0, min_width=50, elem_classes=["refresh-btn"])
with nn_gradio.Row():
btn_initialize = nn_gradio.Button(I18N_DATA["en"]["btn_start"], elem_classes=["start-btn"], scale=2)
btn_step = nn_gradio.Button(I18N_DATA["en"]["btn_next"], variant="secondary", interactive=False, scale=2)
autoplay_check = nn_gradio.Checkbox(label=I18N_DATA["en"]["autoplay_label"], value=False, scale=1)
btn_undo = nn_gradio.Button(I18N_DATA["en"]["btn_undo"], variant="secondary", interactive=False, scale=1)
btn_reset = nn_gradio.Button(I18N_DATA["en"]["btn_reset"], variant="stop", scale=1)
status_box = nn_gradio.Markdown(LOCALIZED_STRINGS["en"]["setup_instruction"])
# Visual Chat Log (Custom styled HTML display for color-coding)
chat_display = nn_gradio.HTML(
value=LOCALIZED_STRINGS["en"]["empty_chat_log"],
label="Chat Log"
)
with nn_gradio.Row():
btn_step_bottom = nn_gradio.Button(I18N_DATA["en"]["btn_next"], variant="secondary", interactive=False, scale=2)
btn_copy = nn_gradio.Button(I18N_DATA["en"]["btn_copy"], scale=1)
btn_export = nn_gradio.Button(I18N_DATA["en"]["btn_save"], scale=1)
# Hidden components for copying / downloading
hidden_markdown = nn_gradio.Textbox(visible=False)
download_file = nn_gradio.File(label=I18N_DATA["en"]["file_label"], visible=False)
# Human Response input row (initially hidden/disabled)
with nn_gradio.Row(visible=False) as human_input_row:
human_textbox = nn_gradio.Textbox(
label=I18N_DATA["en"]["human_resp_label"],
placeholder=I18N_DATA["en"]["human_resp_placeholder"],
scale=4
)
btn_submit_human = nn_gradio.Button(I18N_DATA["en"]["btn_send"], variant="primary", scale=1)
# --- Functions and Event Logic ---
def render_chat_html(history_list):
"""Renders conversation history as custom-styled HTML blocks."""
if not history_list:
return "