| | |
| | """ |
| | SillyTavern CharacterβCard Generator β version 2.0.3Β (AprΒ 2025) |
| | ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| | β’ Added helpful placeholder text for all text inputs so firstβtime users |
| | immediately know what to type or paste. |
| | β’ No behavioural changes beyond UI polish. |
| | """ |
| |
|
| | from __future__ import annotations |
| | import json, sys, uuid |
| | from dataclasses import dataclass |
| | from functools import cached_property |
| | from pathlib import Path |
| | from typing import Any, Dict, List, Tuple, Union |
| |
|
| | import gradio as gr |
| | from PIL import Image |
| | from PIL.PngImagePlugin import PngInfo |
| |
|
| | __version__ = "2.0.3" |
| | MIN_GRADIO = (4, 44, 1) |
| | if tuple(map(int, gr.__version__.split("."))) < MIN_GRADIO: |
| | sys.exit( |
| | f"gradio>={'/'.join(map(str, MIN_GRADIO))} required β found {gr.__version__}" |
| | ) |
| |
|
| | |
| | CLAUDE_MODELS = [ |
| | "claude-3-opus-20240229", |
| | "claude-3-sonnet-20240229", |
| | "claude-3-haiku-20240307", |
| | "claude-3-5-sonnet-20240620", |
| | "claude-3-5-sonnet-20241022", |
| | "claude-3-5-haiku-20241022", |
| | "claude-3-7-sonnet-20250219", |
| | ] |
| | OPENAI_MODELS = [ |
| | "o3", |
| | "o3-mini", |
| | "o4-mini", |
| | "gpt-4.1", |
| | "gpt-4.1-mini", |
| | "gpt-4.1-nano", |
| | "gpt-4o", |
| | "gpt-4o-mini", |
| | "gpt-4", |
| | "gpt-4-32k", |
| | "gpt-4-0125-preview", |
| | "gpt-4-turbo-preview", |
| | "gpt-4-1106-preview", |
| | "gpt-3.5-turbo", |
| | ] |
| | ALL_MODELS = CLAUDE_MODELS + OPENAI_MODELS |
| | DEFAULT_ANTHROPIC_ENDPOINT = "https://api.anthropic.com" |
| | DEFAULT_OPENAI_ENDPOINT = "https://api.openai.com/v1" |
| |
|
| | |
| | JsonDict = Dict[str, Any] |
| | try: |
| | from anthropic import Anthropic, APITimeoutError as AnthropicTimeout |
| | except ImportError: |
| | Anthropic = None |
| | try: |
| | from openai import OpenAI, APITimeoutError as OpenAITimeout |
| | except ImportError: |
| | OpenAI = None |
| |
|
| |
|
| | @dataclass |
| | class APIConfig: |
| | endpoint: str |
| | api_key: str |
| | model: str |
| | temperature: float = 0.7 |
| | top_p: float = 0.9 |
| | thinking: bool = False |
| |
|
| | @cached_property |
| | def provider(self): |
| | return "anthropic" if self.model in CLAUDE_MODELS else "openai" |
| |
|
| | @cached_property |
| | def sdk(self): |
| | if not self.api_key: |
| | raise gr.Error("API Key is required.") |
| | if not self.model: |
| | raise gr.Error("Model selection is required.") |
| |
|
| | try: |
| | if self.provider == "anthropic": |
| | if not Anthropic: |
| | raise RuntimeError("Anthropic SDK not installed. Run: pip install anthropic") |
| | return Anthropic(api_key=self.api_key, base_url=self.endpoint) |
| | else: |
| | if not OpenAI: |
| | raise RuntimeError("OpenAI SDK not installed. Run: pip install openai") |
| | return OpenAI(api_key=self.api_key, base_url=self.endpoint) |
| | except Exception as e: |
| | raise gr.Error(f"Failed to initialize API client: {e}") |
| |
|
| | def chat(self, user: str, system: str = "", max_tokens: int = 4096) -> str: |
| | try: |
| | if self.provider == "anthropic": |
| | args = dict( |
| | model=self.model, |
| | system=system, |
| | messages=[{"role": "user", "content": user}], |
| | max_tokens=max_tokens, |
| | temperature=self.temperature, |
| | top_p=self.top_p, |
| | ) |
| | |
| | |
| | |
| | |
| | |
| | response = self.sdk.messages.create(**args) |
| | if response.content and isinstance(response.content, list): |
| | return response.content[0].text |
| | else: |
| | raise gr.Error("Unexpected response format from Anthropic API.") |
| |
|
| | else: |
| | messages = [] |
| | if system: |
| | messages.append({"role": "system", "content": system}) |
| | messages.append({"role": "user", "content": user}) |
| |
|
| | args = dict( |
| | model=self.model, |
| | messages=messages, |
| | max_tokens=max_tokens, |
| | temperature=self.temperature, |
| | top_p=self.top_p, |
| | ) |
| | |
| | |
| | |
| | |
| | |
| | response = self.sdk.chat.completions.create(**args) |
| | if response.choices: |
| | return response.choices[0].message.content |
| | else: |
| | raise gr.Error("No response choices received from OpenAI API.") |
| |
|
| | except (AnthropicTimeout, OpenAITimeout) as e: |
| | raise gr.Error(f"API request timed out: {e}") |
| | except Exception as e: |
| | |
| | err_msg = f"API Error ({self.provider}): {e}" |
| | if "authentication" in str(e).lower(): |
| | err_msg = "API Error: Authentication failed. Check your API Key and Endpoint." |
| | elif "rate limit" in str(e).lower(): |
| | err_msg = "API Error: Rate limit exceeded. Please wait and try again." |
| | elif "not found" in str(e).lower() and "model" in str(e).lower(): |
| | err_msg = f"API Error: Model '{self.model}' not found or unavailable at '{self.endpoint}'." |
| |
|
| | raise gr.Error(err_msg) |
| |
|
| |
|
| | |
| | CARD_REQUIRED = { |
| | "char_name", |
| | "char_persona", |
| | "world_scenario", |
| | "char_greeting", |
| | "example_dialogue", |
| | |
| | } |
| | CARD_RENAMES = { |
| | "char_name": "name", |
| | "char_persona": "personality", |
| | "world_scenario": "scenario", |
| | "char_greeting": "first_mes", |
| | "example_dialogue": "mes_example", |
| | |
| | } |
| |
|
| | def extract_card_json(txt: str) -> Tuple[str | None, JsonDict | None]: |
| | """Extracts JSON block, validates required keys, and renames keys for SillyTavern.""" |
| | try: |
| | |
| | json_start = txt.find("{") |
| | json_end = txt.rfind("}") |
| | if json_start == -1 or json_end == -1 or json_end < json_start: |
| | gr.Warning("Could not find JSON block in the LLM output.") |
| | return None, None |
| |
|
| | raw_json_str = txt[json_start : json_end + 1] |
| | data = json.loads(raw_json_str) |
| |
|
| | |
| | missing_keys = CARD_REQUIRED - data.keys() |
| | if missing_keys: |
| | gr.Warning(f"LLM output missing required keys: {', '.join(missing_keys)}") |
| | return None, None |
| |
|
| | |
| | st_data = {st_key: data[orig_key] for orig_key, st_key in CARD_RENAMES.items()} |
| | if "description" in data: |
| | st_data["description"] = data["description"] |
| | else: |
| | gr.Warning("LLM output missing 'description' key. Card might be incomplete.") |
| | st_data["description"] = "" |
| |
|
| | |
| | if "spec" not in st_data: |
| | st_data["spec"] = "chara_card_v2" |
| | if "spec_version" not in st_data: |
| | st_data["spec_version"] = "2.0" |
| |
|
| | |
| | final_required = {"name", "personality", "scenario", "first_mes", "mes_example", "description"} |
| | if not final_required <= st_data.keys(): |
| | gr.Warning(f"Internal Error: Failed to map required keys. Check CARD_RENAMES.") |
| | return None, None |
| |
|
| | |
| | formatted_json = json.dumps(st_data, indent=2) |
| | return formatted_json, st_data |
| |
|
| | except json.JSONDecodeError: |
| | gr.Warning("Failed to parse JSON from the LLM output.") |
| | return None, None |
| | except Exception as e: |
| | gr.Warning(f"Error processing LLM output: {e}") |
| | return None, None |
| |
|
| |
|
| | def inject_card_into_png(img_path: str, card_data: Union[str, JsonDict]) -> Path: |
| | """Embeds card JSON into PNG metadata, resizes, and saves.""" |
| | if not img_path: |
| | raise gr.Error("Input image not provided.") |
| |
|
| | try: |
| | if isinstance(card_data, str): |
| | card = json.loads(card_data) |
| | else: |
| | card = card_data |
| |
|
| | if not isinstance(card, dict) or "name" not in card: |
| | raise gr.Error("Invalid or incomplete card JSON provided.") |
| |
|
| | except json.JSONDecodeError: |
| | raise gr.Error("Invalid JSON format in the provided text.") |
| | except Exception as e: |
| | raise gr.Error(f"Error processing card data: {e}") |
| |
|
| | try: |
| | img = Image.open(img_path) |
| | img = img.convert("RGB") |
| |
|
| | |
| | w, h = img.size |
| | target_w, target_h = 400, 600 |
| | target_ratio = target_w / target_h |
| | img_ratio = w / h |
| |
|
| | if abs(img_ratio - target_ratio) > 0.01: |
| | if img_ratio > target_ratio: |
| | new_w = int(h * target_ratio) |
| | left = (w - new_w) // 2 |
| | right = left + new_w |
| | img = img.crop((left, 0, right, h)) |
| | else: |
| | new_h = int(w / target_ratio) |
| | top = (h - new_h) // 2 |
| | bottom = top + new_h |
| | img = img.crop((0, top, w, bottom)) |
| |
|
| | img = img.resize((target_w, target_h), Image.LANCZOS) |
| |
|
| | |
| | meta = PngInfo() |
| | |
| | meta.add_text("chara", json.dumps(card, ensure_ascii=False).encode('utf-8').hex()) |
| |
|
| | |
| | out_dir = Path(__file__).parent / "outputs" |
| | out_dir.mkdir(parents=True, exist_ok=True) |
| | |
| | char_name_safe = "".join(c for c in card.get('name', 'character') if c.isalnum() or c in (' ', '_', '-')).rstrip() |
| | dest = out_dir / f"{char_name_safe}_{uuid.uuid4().hex[:8]}.png" |
| |
|
| | |
| | img.save(dest, "PNG", pnginfo=meta) |
| | gr.Info(f"Card successfully embedded into {dest.name}") |
| | return dest |
| |
|
| | except FileNotFoundError: |
| | raise gr.Error(f"Input image file not found: {img_path}") |
| | except Exception as e: |
| | raise gr.Error(f"Error processing image or saving PNG: {e}") |
| |
|
| |
|
| | |
| |
|
| | def build_ui(): |
| | with gr.Blocks(title=f"SillyTavern Card Gen {__version__}") as demo: |
| | gr.Markdown(f"## π SillyTavern Character Card Generator v{__version__}") |
| | gr.Markdown("Create character cards for SillyTavern using LLMs.") |
| |
|
| | with gr.Tab("Step 1: Generate Card JSON"): |
| | with gr.Row(): |
| | with gr.Column(scale=1): |
| | gr.Markdown("#### LLM Configuration") |
| | endpoint = gr.Textbox( |
| | label="API Endpoint", |
| | value=DEFAULT_ANTHROPIC_ENDPOINT, |
| | placeholder="LLM API base URL (e.g., https://api.anthropic.com)", |
| | info="Automatically updates based on API Key prefix (sk-ant- vs sk-)." |
| | ) |
| | api_key = gr.Textbox( |
| | label="API Key", |
| | type="password", |
| | placeholder="Paste your sk-ant-... or sk-... key here", |
| | ) |
| | model_dd = gr.Dropdown( |
| | ALL_MODELS, |
| | label="Model", |
| | info="Select the LLM to use for generation.", |
| | value=CLAUDE_MODELS[0] |
| | ) |
| | thinking = gr.Checkbox( |
| | label="Thinking mode (deeper reasoning)", |
| | value=False, |
| | info="May enable enhanced reasoning modes (experimental, model-dependent)." |
| | ) |
| | with gr.Accordion("Advanced Settings", open=False): |
| | temp = gr.Slider(0, 1, 0.7, label="Temperature", info="Controls randomness. Lower is more deterministic.") |
| | topp = gr.Slider(0, 1, 0.9, label="TopβP", info="Nucleus sampling. Considers tokens comprising the top P probability mass.") |
| |
|
| | with gr.Column(scale=2): |
| | gr.Markdown("#### Character Definition") |
| | prompt = gr.Textbox( |
| | lines=8, |
| | label="Character Description Prompt", |
| | placeholder="Describe the character you want to create in detail. Include:\n" |
| | "- Appearance (hair, eyes, clothing, distinguishing features)\n" |
| | "- Personality (traits, quirks, likes, dislikes, motivations)\n" |
| | "- Backstory (origins, key life events, relationships)\n" |
| | "- Setting/Scenario (where and when the interaction takes place)\n" |
| | "- Any specific details relevant to their speech or behavior.", |
| | info="Provide a rich description for the LLM to generate the card fields." |
| | ) |
| | gen = gr.Button("Generate JSON Card", variant="primary") |
| |
|
| | with gr.Row(): |
| | with gr.Column(scale=1): |
| | gr.Markdown("#### LLM Output") |
| | raw_out = gr.Textbox( |
| | label="Raw LLM Output", |
| | lines=15, |
| | show_copy_button=True, |
| | placeholder="The full response from the language model will appear here.", |
| | info="Contains the generated JSON block and potentially other text." |
| | ) |
| | with gr.Column(scale=1): |
| | gr.Markdown("#### Processed Card") |
| | json_out = gr.Textbox( |
| | label="Extracted SillyTavern JSON", |
| | lines=15, |
| | show_copy_button=True, |
| | placeholder="The extracted and formatted JSON for SillyTavern will appear here.", |
| | info="This is the data that will be embedded in the PNG." |
| | ) |
| | json_file = gr.File(label="Download .json Card", file_count="single", interactive=False) |
| |
|
| | with gr.Accordion("Step 1b: Generate Image Prompt (Optional)", open=False): |
| | with gr.Row(): |
| | img_model = gr.Dropdown( |
| | ["SDXL", "Midjourney"], |
| | label="Target Image Model", |
| | value="SDXL", |
| | info="Optimize the image prompt for this AI model.", |
| | ) |
| | gen_img_prompt = gr.Button("Generate Image Prompt from Card") |
| | img_prompt_out = gr.Textbox( |
| | label="Generated Image Prompt", |
| | show_copy_button=True, |
| | placeholder="An image generation prompt based on the card details will appear here.", |
| | info="Copy this prompt into your preferred image generation tool." |
| | ) |
| |
|
| | with gr.Tab("Step 2: Inject JSON into PNG"): |
| | gr.Markdown("Upload your character image and the generated JSON (or paste/upload it) to create the final PNG card.") |
| | with gr.Row(): |
| | with gr.Column(): |
| | img_up = gr.Image(type="filepath", label="Upload Character Image", sources=["upload", "clipboard"]) |
| | with gr.Column(): |
| | |
| | gr.Markdown("Use JSON generated in Step 1 (automatically filled if generated).") |
| | json_text_from_step1 = gr.Textbox( |
| | label="Card JSON (from Step 1 or paste here)", |
| | lines=8, |
| | placeholder="Paste the SillyTavern JSON here if you didn't generate it in Step 1, or if you want to override it.", |
| | info="This field is automatically populated from Step 1's 'Extracted SillyTavern JSON'." |
| | ) |
| | |
| | json_up = gr.File( |
| | label="...or Upload .json File", |
| | file_count="single", |
| | file_types=[".json"], |
| | info="Upload a previously saved .json card file." |
| | ) |
| | inject_btn = gr.Button("Embed JSON & Create PNG Card", variant="primary") |
| | png_out = gr.File(label="Download PNG Card", file_count="single", interactive=False) |
| | png_preview = gr.Image(label="PNG Card Preview", interactive=False, width=200, height=300) |
| |
|
| |
|
| | |
| | def choose_endpoint(k): |
| | """Automatically suggest endpoint based on API key prefix.""" |
| | if isinstance(k, str): |
| | if k.startswith("sk-ant-"): |
| | return DEFAULT_ANTHROPIC_ENDPOINT |
| | elif k.startswith("sk-"): |
| | return DEFAULT_OPENAI_ENDPOINT |
| | |
| | return DEFAULT_ANTHROPIC_ENDPOINT |
| |
|
| | api_key.change(choose_endpoint, inputs=api_key, outputs=endpoint, show_progress=False) |
| |
|
| | def generate_json_card(ep, k, m, think, t, p, user_prompt): |
| | """Handles the JSON generation button click.""" |
| | if not user_prompt: |
| | raise gr.Error("Character Description Prompt cannot be empty.") |
| | if not k: |
| | raise gr.Error("API Key is required.") |
| | if not m: |
| | raise gr.Error("Model must be selected.") |
| |
|
| | try: |
| | cfg = APIConfig(ep.strip(), k.strip(), m, t, p, think) |
| |
|
| | |
| | sys_prompt_path = Path(__file__).parent / "json.txt" |
| | if not sys_prompt_path.exists(): |
| | |
| | gr.Warning("System prompt file 'json.txt' not found. Using a basic prompt.") |
| | sys_prompt = """You are an AI assistant tasked with creating character data for SillyTavern in JSON format. Based on the user's description, generate a JSON object containing the following keys: |
| | - char_name: The character's name. |
| | - char_persona: A detailed description of the character's personality, motivations, and mannerisms. |
| | - world_scenario: The setting or context where the user interacts with the character. |
| | - char_greeting: The character's first message to the user. |
| | - example_dialogue: Example dialogue demonstrating the character's speech patterns and personality. Use {{user}} and {{char}} placeholders. |
| | - description: A general description covering appearance and backstory. |
| | |
| | Output ONLY the JSON object, enclosed in ```json ... ```.""" |
| | else: |
| | sys_prompt = sys_prompt_path.read_text(encoding='utf-8') |
| |
|
| | raw_output = cfg.chat(user_prompt, sys_prompt) |
| | extracted_json_str, parsed_data = extract_card_json(raw_output) |
| |
|
| | if extracted_json_str and parsed_data: |
| | |
| | outdir = Path(__file__).parent / "outputs" |
| | outdir.mkdir(parents=True, exist_ok=True) |
| | |
| | char_name_safe = "".join(c for c in parsed_data.get('name', 'character') if c.isalnum() or c in (' ', '_', '-')).rstrip() |
| | json_filename = outdir / f"{char_name_safe}_{uuid.uuid4().hex[:8]}.json" |
| | json_filename.write_text(extracted_json_str, encoding='utf-8') |
| | gr.Info("JSON card generated successfully.") |
| | |
| | return raw_output, extracted_json_str, gr.File(value=str(json_filename), visible=True), extracted_json_str |
| | else: |
| | gr.Warning("Failed to extract valid JSON from LLM output. Check 'Raw LLM Output' for details.") |
| | |
| | return raw_output, "", gr.File(value=None, visible=False), "" |
| |
|
| | except gr.Error as e: |
| | raise e |
| | except Exception as e: |
| | gr.Error(f"An unexpected error occurred during JSON generation: {e}") |
| | return f"Error: {e}", "", gr.File(value=None, visible=False), "" |
| |
|
| | gen.click( |
| | generate_json_card, |
| | inputs=[endpoint, api_key, model_dd, thinking, temp, topp, prompt], |
| | outputs=[raw_out, json_out, json_file, json_text_from_step1], |
| | api_name="generate_json" |
| | ) |
| |
|
| | def generate_image_prompt(ep, k, m, card_json_str, image_gen_model): |
| | """Handles the image prompt generation button click.""" |
| | if not card_json_str: |
| | raise gr.Error("Cannot generate image prompt without valid Card JSON.") |
| | if not k: |
| | raise gr.Error("API Key is required for image prompt generation.") |
| | if not m: |
| | raise gr.Error("Model must be selected for image prompt generation.") |
| |
|
| | try: |
| | |
| | |
| | cfg = APIConfig(ep.strip(), k.strip(), m) |
| |
|
| | |
| | prompt_filename = f"{image_gen_model.lower()}.txt" |
| | sys_prompt_path = Path(__file__).parent / prompt_filename |
| | if not sys_prompt_path.exists(): |
| | gr.Warning(f"System prompt file '{prompt_filename}' not found. Using a generic image prompt.") |
| | sys_prompt = f"Based on the following character JSON data, create a concise and effective image generation prompt suitable for an AI image generator like {image_gen_model}. Focus on visual details like appearance, clothing, and setting. Character JSON:\n" |
| | else: |
| | sys_prompt = sys_prompt_path.read_text(encoding='utf-8') + "\nCharacter JSON:\n" |
| |
|
| | |
| | user_img_prompt = f"{sys_prompt}{card_json_str}" |
| |
|
| | img_prompt = cfg.chat(user_img_prompt, max_tokens=200) |
| | gr.Info("Image prompt generated.") |
| | return img_prompt.strip() |
| |
|
| | except gr.Error as e: |
| | raise e |
| | except Exception as e: |
| | gr.Error(f"An unexpected error occurred during image prompt generation: {e}") |
| | return f"Error generating prompt: {e}" |
| |
|
| | gen_img_prompt.click( |
| | generate_image_prompt, |
| | inputs=[endpoint, api_key, model_dd, json_out, img_model], |
| | outputs=[img_prompt_out], |
| | api_name="generate_image_prompt" |
| | ) |
| |
|
| | def handle_json_upload(json_file_obj, current_json_text): |
| | """Reads uploaded JSON file and updates the text box, overriding text if file is provided.""" |
| | if json_file_obj is not None: |
| | try: |
| | json_path = Path(json_file_obj.name) |
| | content = json_path.read_text(encoding='utf-8') |
| | |
| | json.loads(content) |
| | gr.Info(f"Loaded JSON from {json_path.name}") |
| | return content |
| | except json.JSONDecodeError: |
| | gr.Warning("Uploaded file is not valid JSON. Keeping existing text.") |
| | return current_json_text |
| | except Exception as e: |
| | gr.Warning(f"Error reading uploaded JSON file: {e}. Keeping existing text.") |
| | return current_json_text |
| | |
| | return current_json_text |
| |
|
| | |
| | json_up.upload( |
| | handle_json_upload, |
| | inputs=[json_up, json_text_from_step1], |
| | outputs=[json_text_from_step1] |
| | ) |
| |
|
| | def inject_card(img_filepath, json_str): |
| | """Handles the PNG injection button click.""" |
| | if not img_filepath: |
| | raise gr.Error("Please upload a character image first.") |
| | if not json_str: |
| | raise gr.Error("Card JSON is missing. Generate it in Step 1 or paste/upload it.") |
| |
|
| | try: |
| | |
| | output_png_path = inject_card_into_png(img_filepath, json_str) |
| | |
| | return gr.File(value=str(output_png_path), visible=True), gr.Image(value=str(output_png_path), visible=True) |
| | except gr.Error as e: |
| | raise e |
| | except Exception as e: |
| | gr.Error(f"An unexpected error occurred during PNG injection: {e}") |
| | return gr.File(value=None, visible=False), gr.Image(value=None, visible=False) |
| |
|
| | inject_btn.click( |
| | inject_card, |
| | inputs=[img_up, json_text_from_step1], |
| | outputs=[png_out, png_preview], |
| | api_name="inject_card" |
| | ) |
| |
|
| | return demo |
| |
|
| | |
| | if __name__ == "__main__": |
| | |
| | prompt_dir = Path(__file__).parent |
| |
|
| | |
| | (prompt_dir / "outputs").mkdir(exist_ok=True) |
| |
|
| | |
| | app = build_ui() |
| | app.launch() |
| |
|