Spaces:
Running
Running
Download app.py from cereeenn120/CharacterChatBot: direct link, hf CLI and curl.
- Browser
- Download file 35.1 kB
-
https://huggingface.co/spaces/cereeenn120/CharacterChatBot/resolve/main/app.py
- Command line
-
hf download hf://spaces/cereeenn120/CharacterChatBot/app.py
-
curl -L -o app.py https://huggingface.co/spaces/cereeenn120/CharacterChatBot/resolve/main/app.py
35.1 kB
| 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() | |