import logging import os from hashlib import sha256 import gradio as gr from huggingface_hub import InferenceClient from openai import OpenAI from engine.chat import generate_response from engine.memory import load_conversation, sync_history from engine.persona import get_empty_persona, is_persona_empty, persona_summary, validate_persona from engine.prompt_builder import get_avatar_path, load_character, list_characters from engine.saveload import export_save, import_save, restore_save from engine.suggestions import generate_suggestions from engine.memory_controls import change_memory, set_relationship_options, context_messages from engine.relationship import relationship_descriptions, get_default_relationship logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # ── Configuration ────────────────────────────────────────────────────────────── # Switch DEFAULT_CHARACTER to load a different character from characters/*.json # Switch MODEL to change the LLM (any model available on HuggingFace Inference API) DEFAULT_CHARACTER = "hermione_granger" MODELS = [ ("DeepSeek V3", "deepseek-ai/DeepSeek-V3"), ("GPT-4o Mini", "openai/gpt-4o-mini"), ("GPT-OSS", "openai/gpt-oss-120b"), ("GLM-4.5", "zai-org/GLM-4.5"), ("Gemma-3 27B", "google/gemma-3-27b-it"), ("Mistral 7B Instruct", "mistralai/Mistral-7B-Instruct-v0.3"), ("Mistral Small 3.2 24B Instruct", "mistralai/Mistral-Small-3.2-24B-Instruct-2506"), ("Llama 3.3 70B Instruct", "meta-llama/Llama-3.3-70B-Instruct"), ("Llama 3.1 8B Instruct", "meta-llama/Llama-3.1-8B-Instruct"), ("Hermes 3 Llama 3.1 8B", "NousResearch/Hermes-3-Llama-3.1-8B"), ("Qwen 2.5 72B Instruct", "Qwen/Qwen2.5-72B-Instruct"), ("Qwen 2.5 14B Instruct", "Qwen/Qwen2.5-14B-Instruct"), ("Qwen 3 32 B", "Qwen/Qwen3-32B"), ("Qwen 3 30B Instruct", "Qwen/Qwen3-30B-A3B-Instruct-2507"), ("Qwen 3 235B Instruct", "Qwen/Qwen3-235B-A22B-Instruct-2507"), ("Qwen3.8 27B OBLITERATED", "OBLITERATUS/Qwen3.8-27B-OBLITERATED:featherless-ai"), ("Huihui CyberStrike OffSec 35B", "huihui-ai/Huihui-CyberStrike-OffSec-35B-abliterated:featherless-ai"), ("Gemma 4 E4B OBLITERATED", "OBLITERATUS/gemma-4-E4B-it-OBLITERATED:featherless-ai"), ("Qwen 3 32B abliterated", "huihui-ai/Qwen3-32B-abliterated:featherless-ai"), ("Mistral Nemo 12B HERETIC", "DavidAU/Mistral-Nemo-2407-12B-Thinking-Claude-Gemini-GPT5.2-Uncensored-HERETIC:featherless-ai"), ("Gemma 4 26B A4B uncensored", "TrevorJS/gemma-4-26B-A4B-it-uncensored:featherless-ai"), ("Qwen3.8 9B Distill uncensored", "petruhonk/Qwen3.8-9B-Distill-uncensored-heretic:featherless-ai"), ("Wizard Vicuna 7B Uncensored", "QuixiAI/Wizard-Vicuna-7B-Uncensored:featherless-ai"), ("Llama 3 70B abliterated", "failspy/llama-3-70B-Instruct-abliterated:featherless-ai"), ("Llama 3 8B abliterated v3", "failspy/Meta-Llama-3-8B-Instruct-abliterated-v3:featherless-ai"), ("Llama 3.1 8B abliterated", "mlabonne/Meta-Llama-3.1-8B-Instruct-abliterated:featherless-ai"), ("Qwen 2.5 7B abliterated v2", "huihui-ai/Qwen2.5-7B-Instruct-abliterated-v2:featherless-ai"), ("Qwen 2.5 72B abliterated", "huihui-ai/Qwen2.5-72B-Instruct-abliterated:featherless-ai"), ("Llama 3.3 8B Thinking HERETIC", "DavidAU/Llama3.3-8B-Instruct-Thinking-Heretic-Uncensored-Claude-4.5-Opus-High-Reasoning:featherless-ai"), ("Qwen 3.5 9B abliterated", "wangzhang/Qwen3.5-9B-abliterated:featherless-ai"), ("Qwen3.8 27B AEON Ultimate", "AEON-7/Qwen3.8-27B-AEON-ULTIMATE-UNCENSORED-BF16:featherless-ai"), ("NeuralDaredevil 8B abliterated", "mlabonne/NeuralDaredevil-8B-abliterated:featherless-ai"), ("Llama 3 70B abliterated v3.5", "failspy/Meta-Llama-3-70B-Instruct-abliterated-v3.5:featherless-ai"), ("Qwen 2.5 7B Uncensored", "Orion-zhen/Qwen2.5-7B-Instruct-Uncensored:featherless-ai"), ("Llama 3.3 70B abliterated", "huihui-ai/Llama-3.3-70B-Instruct-abliterated:featherless-ai"), ("DeepSeek R1 Distill Qwen 14B abliterated", "huihui-ai/DeepSeek-R1-Distill-Qwen-14B-abliterated-v2:featherless-ai"), ("Qwen 3 8B abliterated", "mlabonne/Qwen3-8B-abliterated:featherless-ai"), ("Huihui Qwen 3 14B abliterated v2", "huihui-ai/Huihui-Qwen3-14B-abliterated-v2:featherless-ai"), ("Huihui Qwen 3 8B abliterated v2", "huihui-ai/Huihui-Qwen3-8B-abliterated-v2:featherless-ai"), ("Josiefied Qwen 3 4B gabliterated v2", "Goekdeniz-Guelmez/Josiefied-Qwen3-4B-Instruct-2507-gabliterated-v2:featherless-ai"), ("Qwen 3 0.6B HERETIC", "DavidAU/Qwen3-0.6B-heretic-abliterated-uncensored:featherless-ai"), ("Qwen 3.5 27B Derestricted", "ArliAI/Qwen3.5-27B-Derestricted:featherless-ai"), ("Loki V2 HERETIC Uncensored", "hackoffice/Loki-V2.0-Heretic-Uncensored:featherless-ai"), ("Gemma 4 12B uncensored", "TrevorJS/gemma-4-12B-it-uncensored:featherless-ai"), ("Gemma 4 12B Coder uncensored HERETIC", "llmfan46/gemma-4-12B-coder-fable5-composer2.5-v1-uncensored-heretic:featherless-ai"), ("Huihui Qwythos 9B abliterated", "huihui-ai/Huihui-Qwythos-9B-Claude-Mythos-5-1M-abliterated:featherless-ai"), ("Llama 3.1 Dark Reasoning HERETIC 8B", "DavidAU/L3.1-Dark-Reasoning-LewdPlay-evo-Hermes-R1-Uncensored-8B:featherless-ai"), ("DAN L3 R1 8B", "UnfilteredAI/DAN-L3-R1-8B:featherless-ai"), ] LANGUAGES = ["English", "French", "German", "Italian", "Russian", "Turkish", "Chinese"] DEFAULT_MODEL = MODELS[4][1] DEFAULT_LANGUAGE = "English" TRANSLATION_MODEL = "google/gemma-3-27b-it" #MODEL = "deepseek-ai/DeepSeek-V3" # or e.g. "openai/gpt-4o-mini" #MODEL = "Qwen/Qwen3-235B-A22B-Instruct-2507" #MODEL = "Qwen/Qwen3-30B-A3B-Instruct-2507" #MODEL = "Qwen/Qwen3-32B" #MODEL = "zai-org/GLM-4.5" #MODEL = "openai/gpt-oss-120b" #MODEL = "mistralai/Mistral-Small-3.2-24B-Instruct-2506" #MODEL = "meta-llama/Llama-3.3-70B-Instruct" #MODEL = "google/gemma-3-27b-it" #MODEL = "NousResearch/Hermes-3-Llama-3.1-8B" #MODEL = "Qwen/Qwen2.5-14B-Instruct" #MODEL = "Mantis2024/Dirty-Muse-Writer-v01-Uncensored-Erotica-NSFW" # ── HuggingFace token ────────────────────────────────────────────────────────── # Add your token as a Space secret: Settings → Variables and secrets → New secret # Name: HF_TOKEN | Value: hf_xxxxxxxxxxxxxxxx HF_TOKEN = os.environ.get("HF_TOKEN") if not HF_TOKEN: logger.warning("HF_TOKEN secret is not set. Inference calls will fail.") def create_hf_client(): """Return a client compatible with both the legacy HF API and HF Router.""" return InferenceClient(token=HF_TOKEN, timeout=60) def create_router_client(): """Create the OpenAI-compatible client for Hugging Face's Router API.""" return OpenAI( base_url="https://router.huggingface.co/v1", api_key=HF_TOKEN, timeout=60, ) # ── Load default character on startup ───────────────────────────────────────── character = load_character(DEFAULT_CHARACTER) avatar_path = get_avatar_path(DEFAULT_CHARACTER) char_name = character.get("name", DEFAULT_CHARACTER) char_role = character.get("identity", {}).get("role", "") greeting = character.get("greeting", "Hello. What do you want?") # ── Discover all characters for the selector ─────────────────────────────────── all_characters = list_characters() character_by_id = {c["id"]: c for c in all_characters} category_names = [] for item in all_characters: for category in item.get("categories", []): if category not in category_names: category_names.append(category) char_choices = [ (f"{category} / {character_by_id[character_id]['name']}", character_id) for category in category_names for character_id in (item["id"] for item in all_characters if category in item.get("categories", [])) ] # ── Session states ────────────────────────────────────────────────────────────── persona_state = gr.State(get_empty_persona()) char_id_state = gr.State(DEFAULT_CHARACTER) language_state = gr.State(DEFAULT_LANGUAGE) model_state = gr.State(DEFAULT_MODEL) use_translator_state = gr.State(False) # True = separate translator model, False = main model handles language # ── Gradio respond function ──────────────────────────────────────────────────── def session_key(request: gr.Request) -> str: if request is None or not request.session_hash: raise gr.Error("A browser session is required. Please reload the page.") return sha256(request.session_hash.encode()).hexdigest()[:32] def persist_visible_history(history, char_id: str, request: gr.Request): sync_history(session_key(request), char_id, history) def respond( message: str, history: list[dict], # Gradio messages; normalized before model use max_tokens: int, temperature: float, top_p: float, persona: dict, # gr.State — user persona, empty by default (form added in Phase 9) char_id: str, language: str, model_id: str, use_translator: bool, # True = separate translator model, False = main model handles language request: gr.Request, ): session_id = session_key(request) if not HF_TOKEN: yield "⚠️ HF_TOKEN secret is not set. Add it in Space settings and restart." return client = create_router_client() if model_id.endswith(":featherless-ai") else create_hf_client() # Pass persona only when at least one field is filled active_persona = validate_persona(persona) if persona else None if active_persona and is_persona_empty(active_persona): active_persona = None yield from generate_response( message=message, session_id=session_id, history=history, character_id=char_id, client=client, persona=active_persona, model=model_id, max_tokens=max_tokens, temperature=temperature, top_p=top_p, language=language, translation_model=TRANSLATION_MODEL, use_translator=use_translator, ) class SessionChatInterface(gr.ChatInterface): """Expose Gradio's generation dependencies so resets also cancel streaming. This small adapter is covered by integration tests against pinned Gradio 6.12.0. """ def _setup_stop_events(self, *, event_triggers, events_to_cancel, after_success): self.generation_events = events_to_cancel super()._setup_stop_events(event_triggers=event_triggers, events_to_cancel=events_to_cancel, after_success=after_success) # ── Event handlers ───────────────────────────────────────────────────────────── def ui_history(history): # State outputs bypass component postprocessing; Gradio retry expects text blocks. return chatbot.chatbot.preprocess(chatbot.chatbot.postprocess(history)) def clear_chat_fn(char_id: str, request: gr.Request): sync_history(session_key(request), char_id, []) return [], [], [] def stop_chat_fn(history, char_id: str, request: gr.Request): persist_visible_history(history, char_id, request) normalized = ui_history(history) return normalized, normalized def on_character_change(new_char_id: str, request: gr.Request): session_id = session_key(request) logger.info(f"[App] Character change requested → '{new_char_id}'") try: char = load_character(new_char_id) except Exception as e: logger.error(f"[App] Could not load character '{new_char_id}': {e}") return [gr.update()] * 7 name = char.get("name", new_char_id) role = char.get("identity", {}).get("role", "") avatar = get_avatar_path(new_char_id) logger.info(f"[App] Character loaded: name='{name}', role='{role}', avatar={'found' if avatar else 'missing'}") history = load_conversation(session_id, new_char_id)["recent_messages"] return ( new_char_id, f"## {name}", f"*{role}*" if role else "", gr.update(value=avatar, visible=avatar is not None), gr.update( value=history, label=name, avatar_images=(None, avatar), ), ui_history(history), ui_history(history), ) def apply_persona_fn(name, age, gender, personality, background): persona = validate_persona({ "name": name, "age": age, "gender": gender, "personality": personality, "background": background, }) if is_persona_empty(persona): logger.info("[App] Persona applied but all fields empty — no persona active.") return persona, "*No persona set.*" logger.info(f"[App] Persona applied: {persona_summary(persona)}") return persona, f"*✓ Active: {persona_summary(persona)}*" def clear_persona_fn(): logger.info("[App] Persona cleared.") # Returns: persona_state, 5 form fields, status return get_empty_persona(), "", "", "", "", "", "*Persona cleared.*" def clear_suggestions_fn(): return [gr.update(visible=False)] * 5 + [""] * 4 def get_suggestions_fn(char_id: str, model_id: str, language: str, persona: dict, request: gr.Request): session_id = session_key(request) logger.info(f"[App] Suggestion request: char='{char_id}', model='{model_id}'") if not HF_TOKEN: logger.warning("[App] Suggestions skipped — HF_TOKEN not set.") return [gr.update(visible=False)] + [gr.update(visible=False)] * 4 + [""] * 4 recent = context_messages(load_conversation(session_id, char_id))[-6:] if not recent: logger.info("[App] Suggestions skipped — no recent messages.") return [gr.update(visible=False)] + [gr.update(visible=False)] * 4 + [""] * 4 try: char = load_character(char_id) client = create_router_client() if model_id.endswith(":featherless-ai") else create_hf_client() suggestions = generate_suggestions(char, recent, client, model_id, language=language, persona=persona) logger.info(f"[App] Suggestions generated: {[s['tone'] for s in suggestions]}") except Exception as e: logger.error(f"[App] Suggestion error: {e}") return [gr.update(visible=False)] + [gr.update(visible=False)] * 4 + [""] * 4 return ( [gr.update(visible=True)] + [gr.update(value=f"{s['emoji']} {s['text']}", visible=True) for s in suggestions] + [s["text"] for s in suggestions] ) def save_conversation_fn(char_id: str, persona: dict, request: gr.Request): """Write a save file and return its path for Gradio to serve as download.""" session_id = session_key(request) logger.info(f"[App] Save requested for char='{char_id}'") try: path = export_save(char_id, persona, session_id) logger.info(f"[App] Save successful → {path}") name = load_character(char_id).get("name", char_id) return gr.update(value=path, visible=True), f"*✓ Save file ready for {name} — click to download.*" except Exception as e: logger.error(f"[App] Save failed: {e}", exc_info=True) return gr.update(visible=False), f"*❌ Save failed: {e}*" def invalidate_save_fn(): """A download is a snapshot; hide it once the conversation context changes.""" return gr.update(value=None, visible=False), "" def load_conversation_fn(file_path, current_char_id: str, request: gr.Request): """ Read an uploaded save file, restore memory, and refresh the UI. Outputs (15): char_id_state, persona_state, p_name, p_age, p_gender, p_personality, p_background, chatbot.chatbot, name_md, role_md, avatar_img, char_selector, load_status """ session_id = session_key(request) no_op = [gr.update()] * 14 + [""] if not file_path: logger.debug("[App] Load called with no file path — skipping.") return no_op logger.info(f"[App] Load requested: {file_path}") try: data = import_save(file_path) data = restore_save(data, session_id) except (ValueError, KeyError) as e: logger.warning(f"[App] Load validation error: {e}") return no_op[:-1] + [f"*❌ {e}*"] except Exception as e: logger.error(f"[App] Load error: {e}", exc_info=True) return no_op[:-1] + [f"*❌ Unexpected error: {type(e).__name__}*"] char_id = data["character_id"] persona = validate_persona(data.get("persona", {})) recent = data.get("conversation", {}).get("recent_messages", []) char = load_character(char_id) name = char.get("name", char_id) role = char.get("identity", {}).get("role", "") avatar = get_avatar_path(char_id) # Restore the complete transcript, including the state used by retry/undo. history = recent return ( char_id, # char_id_state persona, # persona_state persona.get("name", ""), # p_name persona.get("age", ""), # p_age persona.get("gender", ""), # p_gender persona.get("personality", ""), # p_personality persona.get("background", ""), # p_background gr.update(value=history, label=name, avatar_images=(None, avatar)), # chatbot.chatbot f"## {name}", # name_md f"*{role}*" if role else "", # role_md gr.update(value=avatar, visible=avatar is not None), # avatar_img gr.update(value=char_id), # char_selector ui_history(history), ui_history(history), f"*✓ Loaded save for {name}.*", # load_status ) def relationship_markdown(state, language): return "\n\n".join(f"**{label}:** {text}" for label, text in relationship_descriptions(state['relationship'], language)) def memory_choices(state): return [(('📌 ' if m['origin'] == 'manual' else '') + m['content'][:90], m['id']) for m in state['long_term_memory']] def refresh_panels_fn(char_id: str, language: str, selected, request: gr.Request): state = load_conversation(session_key(request), char_id) valid = selected if any(m['id'] == selected for m in state['long_term_memory']) else None return relationship_markdown(state, language), gr.update(choices=memory_choices(state), value=valid) def restore_panels_fn(char_id: str, language: str, request: gr.Request): state = load_conversation(session_key(request), char_id) return (relationship_markdown(state, language), gr.update(choices=memory_choices(state), value=None), state['relationship']['enabled']['romance'], state['relationship']['enabled']['attraction'], '', '') def relationship_options_fn(romance, attraction, char_id: str, language: str, request: gr.Request): state = set_relationship_options(session_key(request), char_id, romance, attraction) return relationship_markdown(state, language) def select_memory_fn(selected, char_id: str, request: gr.Request): state = load_conversation(session_key(request), char_id) return next((m['content'] for m in state['long_term_memory'] if m['id'] == selected), '') def memory_action_fn(action, content, selected, char_id: str, language: str, request: gr.Request): try: state = change_memory(session_key(request), char_id, action, content, selected) except ValueError as exc: return gr.update(), gr.update(), content, str(exc) messages = {'remember': 'Memory saved.', 'edit': 'Memory updated.', 'forget': 'Memory forgotten.'} return (relationship_markdown(state, language), gr.update(choices=memory_choices(state), value=None), '', messages[action]) def edit_last_message_fn(char_id: str, request: gr.Request): state = load_conversation(session_key(request), char_id) history = state['recent_messages'] index = next((i for i in range(len(history) - 1, -1, -1) if history[i]['role'] == 'user'), None) if index is None: raise gr.Error('There is no user message to edit yet.') text = history[index]['content'] prefix = history[:index] sync_history(session_key(request), char_id, prefix) normalized = ui_history(prefix) return prefix, normalized, normalized, gr.update(value=text, interactive=True) # ── Blocks layout ────────────────────────────────────────────────────────────── with gr.Blocks(title=f"Chat with {char_name}") as demo: suggestion_texts = [gr.State("") for _ in range(4)] with gr.Sidebar(): # ── Character selector ─────────────────────────────────────────────── gr.Markdown("### Character library") category_selector = gr.Dropdown( choices=category_names, value=next((c for c in category_names if DEFAULT_CHARACTER in [x[1] for x in char_choices if x[0].startswith(c + " / ")]), None), label="Category", interactive=True, ) category_character_choices = [ (item["name"], item["id"]) for item in all_characters if category_selector.value in item.get("categories", []) ] char_selector = gr.Dropdown( choices=category_character_choices, value=DEFAULT_CHARACTER, label="Character", visible=len(char_choices) > 1, interactive=True, ) # ── Character info ─────────────────────────────────────────────────── name_md = gr.Markdown(f"## {char_name}") role_md = gr.Markdown(f"*{char_role}*" if char_role else "") avatar_img = gr.Image( value=avatar_path, show_label=False, height=220, container=False, visible=avatar_path is not None, ) gr.Markdown("---") # ── Chat settings ──────────────────────────────────────────────────── with gr.Accordion("⚙️ Chat settings", open=True): language_selector = gr.Dropdown( choices=LANGUAGES, value=DEFAULT_LANGUAGE, label="🌐 Language", interactive=True, ) model_selector = gr.Dropdown( choices=MODELS, value=DEFAULT_MODEL, label="🤖 Model", interactive=True, ) translator_toggle = gr.Checkbox( value=False, label="🔄 Use translator model", info="ON: dedicated model translates • OFF: main model handles language directly", interactive=True, ) gr.Markdown("---") # ── Persona form ───────────────────────────────────────────────────── gr.Markdown("### 🎭 Your Persona") gr.Markdown("*Define who you're playing — all fields optional.*") p_name = gr.Textbox(label="Name", placeholder="e.g. Alex") p_age = gr.Textbox(label="Age", placeholder="e.g. 28") p_gender = gr.Textbox(label="Gender", placeholder="e.g. Female") p_personality = gr.Textbox(label="Personality", placeholder="e.g. curious, skeptical") p_background = gr.Textbox( label="Background", lines=2, placeholder="e.g. A journalist investigating a disappearance", ) with gr.Row(): apply_btn = gr.Button("Apply", variant="primary", scale=2) clear_btn = gr.Button("Clear", scale=1) persona_status = gr.Markdown("") gr.Markdown("---") with gr.Accordion("Relationship", open=True): relationship_md = gr.Markdown(relationship_markdown( {'relationship': get_default_relationship()}, DEFAULT_LANGUAGE)) romance_toggle = gr.Checkbox(False, label="Track romantic closeness") attraction_toggle = gr.Checkbox(False, label="Track sexual attraction") gr.Markdown("Each develops independently. Turning an option off clears its recorded developments.") gr.Markdown("Updates periodically with memory summaries, first after about 10 exchanges. Older turns may temporarily leave model context if summarization falls behind; the full chat remains saved.") with gr.Accordion("Memories", open=False): memory_selector = gr.Dropdown(choices=[], label="Saved memories", interactive=True) memory_editor = gr.Textbox(label="Memory", lines=3, max_length=2000, placeholder="Something you want this character to remember") with gr.Row(): remember_btn = gr.Button("Remember") update_memory_btn = gr.Button("Update selected") forget_btn = gr.Button("Forget selected") memory_status = gr.Markdown("") gr.Markdown("Pinned memories are yours to edit. Forget excludes recorded source messages from future context; the visible chat stays intact.") # ── Save / Load ────────────────────────────────────────────────────── with gr.Accordion("💾 Save / Load", open=False): save_btn = gr.Button("Save conversation", variant="secondary") save_file = gr.File(label="Download save file", visible=False, interactive=False) save_status = gr.Markdown("") gr.Markdown("---") load_upload = gr.File( label="Load save file (.json)", file_types=[".json"], file_count="single", ) load_status = gr.Markdown("") # ── Chat + suggestions ───────────────────────────────────────────────────── # Construct here so built-in event chains and cancellation IDs share this Blocks. chatbot = SessionChatInterface( fn=respond, chatbot=gr.Chatbot( value=[{"role": "assistant", "content": greeting}], label=char_name, avatar_images=(None, avatar_path), height=480, ), additional_inputs=[ gr.Slider(minimum=1, maximum=2048, value=400, step=1, label="Max new tokens", render=False), gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature", render=False), gr.Slider(minimum=0.1, maximum=1.0, value=0.95, step=0.05, label="Top-p", render=False), persona_state, # hidden — updated by the sidebar form char_id_state, language_state, model_state, use_translator_state, # hidden — updated by the sidebar toggle ], additional_inputs_accordion=gr.Accordion("⚙️ Settings", open=False, render=False), ) edit_last_btn = gr.Button("Edit last message", size="sm") gr.Markdown("Edit last message removes its old reply. Adjust the text and send to generate a replacement.") panel_timer = gr.Timer(10) suggest_btn = gr.Button("💡 Suggest replies", size="sm", variant="secondary") with gr.Group(visible=False) as suggestion_group: with gr.Row(): s_btns = [ gr.Button("", variant="secondary", size="sm", visible=False) for _ in range(4) ] # ── Events ───────────────────────────────────────────────────────────────── # Sync dropdowns / toggle → states language_selector.change(fn=lambda v: v, inputs=[language_selector], outputs=[language_state]) model_selector.change(fn=lambda v: v, inputs=[model_selector], outputs=[model_state]) translator_toggle.change(fn=lambda v: v, inputs=[translator_toggle], outputs=[use_translator_state]) character_event = char_selector.input( fn=on_character_change, cancels=chatbot.generation_events, inputs=[char_selector], outputs=[char_id_state, name_md, role_md, avatar_img, chatbot.chatbot, chatbot.chatbot_state, chatbot.chatbot_value], ) def characters_in_category(category, current_id): choices = [(item["name"], item["id"]) for item in all_characters if category in item.get("categories", [])] selected = current_id if any(value == current_id for _, value in choices) else (choices[0][1] if choices else None) return gr.update(choices=choices, value=selected) category_selector.change(characters_in_category, [category_selector, char_id_state], [char_selector]) clear_chat_event = chatbot.chatbot.clear( fn=clear_chat_fn, inputs=[char_id_state], outputs=[chatbot.chatbot, chatbot.chatbot_state, chatbot.chatbot_value], cancels=chatbot.generation_events, queue=False, ) chatbot.textbox.stop( fn=stop_chat_fn, inputs=[chatbot.chatbot, char_id_state], outputs=[chatbot.chatbot_state, chatbot.chatbot_value], queue=False, ) chatbot.chatbot_state.change( fn=persist_visible_history, inputs=[chatbot.chatbot_state, char_id_state], outputs=[], queue=False, ) apply_btn.click( fn=apply_persona_fn, inputs=[p_name, p_age, p_gender, p_personality, p_background], outputs=[persona_state, persona_status], ) clear_btn.click( fn=clear_persona_fn, inputs=[], outputs=[persona_state, p_name, p_age, p_gender, p_personality, p_background, persona_status], ) suggestion_event = suggest_btn.click( fn=get_suggestions_fn, inputs=[char_id_state, model_state, language_state, persona_state], outputs=[suggestion_group] + s_btns + suggestion_texts, ) gr.on( triggers=[language_selector.change, apply_btn.click, clear_btn.click, char_selector.input, load_upload.change, chatbot.textbox.submit, chatbot.chatbot.retry, chatbot.chatbot.clear, chatbot.chatbot.undo, edit_last_btn.click], fn=clear_suggestions_fn, inputs=[], outputs=[suggestion_group] + s_btns + suggestion_texts, cancels=[suggestion_event], queue=False, ) for btn, text_state in zip(s_btns, suggestion_texts): btn.click( fn=lambda t: t, inputs=[text_state], outputs=[chatbot.textbox], ) save_btn.click( fn=save_conversation_fn, inputs=[char_id_state, persona_state], outputs=[save_file, save_status], ) gr.on( triggers=[char_selector.input, load_upload.change, chatbot.chatbot_state.change, persona_state.change], fn=invalidate_save_fn, inputs=[], outputs=[save_file, save_status], queue=False, ) load_event = load_upload.change( fn=load_conversation_fn, cancels=chatbot.generation_events, inputs=[load_upload, char_id_state], outputs=[ char_id_state, persona_state, p_name, p_age, p_gender, p_personality, p_background, chatbot.chatbot, name_md, role_md, avatar_img, char_selector, chatbot.chatbot_state, chatbot.chatbot_value, load_status, ], ) panel_outputs = [relationship_md, memory_selector, romance_toggle, attraction_toggle, memory_editor, memory_status] for event in (character_event, clear_chat_event, load_event): event.then(restore_panels_fn, [char_id_state, language_state], panel_outputs) panel_timer.tick(refresh_panels_fn, [char_id_state, language_state, memory_selector], [relationship_md, memory_selector], queue=False, show_progress='hidden') language_selector.change(refresh_panels_fn, [char_id_state, language_selector, memory_selector], [relationship_md, memory_selector], queue=False) gr.on(triggers=[romance_toggle.input, attraction_toggle.input], fn=relationship_options_fn, inputs=[romance_toggle, attraction_toggle, char_id_state, language_state], outputs=[relationship_md]) memory_selector.input(select_memory_fn, [memory_selector, char_id_state], [memory_editor]) for button, action in ((remember_btn, 'remember'), (update_memory_btn, 'edit'), (forget_btn, 'forget')): button.click(memory_action_fn, [gr.State(action), memory_editor, memory_selector, char_id_state, language_state], [relationship_md, memory_selector, memory_editor, memory_status]) edit_last_btn.click(edit_last_message_fn, [char_id_state], [chatbot.chatbot, chatbot.chatbot_state, chatbot.chatbot_value, chatbot.textbox], cancels=chatbot.generation_events).then( restore_panels_fn, [char_id_state, language_state], panel_outputs) if __name__ == "__main__": demo.launch()