Zack3D commited on
Commit
c889244
Β·
verified Β·
1 Parent(s): 2bbcdfd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +537 -108
app.py CHANGED
@@ -1,13 +1,13 @@
1
  #!/usr/bin/env python3
2
  """
3
- SillyTavern Character‑Card Generator β€” version 2.0.2Β (AprΒ 2025)
4
  ──────────────────────────────────────────────────────────────
5
- β€’ 2.0.1 accidentally stubbed‑out the UI; 2.0.2 restores the full layout.
6
- β€’ Keeps the Gradio‑schema crash fix & `share=True` launch params.
 
7
  """
8
 
9
  from __future__ import annotations
10
-
11
  import json, sys, uuid
12
  from dataclasses import dataclass
13
  from functools import cached_property
@@ -18,147 +18,576 @@ import gradio as gr
18
  from PIL import Image
19
  from PIL.PngImagePlugin import PngInfo
20
 
21
- __version__ = "2.0.2"
 
 
 
 
 
22
 
23
  # ─── Model lists ───────────────────────────────────────────────────────────
24
  CLAUDE_MODELS = [
25
- "claude-3-opus-20240229","claude-3-sonnet-20240229","claude-3-haiku-20240307",
26
- "claude-3-5-sonnet-20240620","claude-3-5-sonnet-20241022","claude-3-5-haiku-20241022",
27
- "claude-3-7-sonnet-20250219",
 
 
 
 
28
  ]
29
  OPENAI_MODELS = [
30
- "o3","o3-mini","o4-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano",
31
- "gpt-4o","gpt-4o-mini","gpt-4","gpt-4-32k","gpt-4-0125-preview",
32
- "gpt-4-turbo-preview","gpt-4-1106-preview","gpt-3.5-turbo",
 
 
 
 
 
 
 
 
 
 
 
33
  ]
34
  ALL_MODELS = CLAUDE_MODELS + OPENAI_MODELS
35
- DEFAULT_ANTHROPIC_ENDPOINT="https://api.anthropic.com"
36
- DEFAULT_OPENAI_ENDPOINT="https://api.openai.com/v1"
37
 
38
  # ─── API wrapper ───────────────────────────────────────────────────────────
39
- JsonDict = Dict[str,Any]
40
  try:
41
- from anthropic import Anthropic,APITimeoutError as AnthropicTimeout
42
  except ImportError:
43
- Anthropic=None
44
  try:
45
- from openai import OpenAI,APITimeoutError as OpenAITimeout
46
  except ImportError:
47
- OpenAI=None
 
48
 
49
  @dataclass
50
  class APIConfig:
51
- endpoint:str; api_key:str; model:str
52
- temperature:float=0.7; top_p:float=0.9; thinking:bool=False
 
 
 
 
 
53
  @cached_property
54
  def provider(self):
55
  return "anthropic" if self.model in CLAUDE_MODELS else "openai"
 
56
  @cached_property
57
  def sdk(self):
58
- if self.provider=="anthropic":
59
- if not Anthropic: raise RuntimeError("install anthropic-python")
60
- return Anthropic(api_key=self.api_key,base_url=self.endpoint)
61
- if not OpenAI: raise RuntimeError("install openai-python")
62
- return OpenAI(api_key=self.api_key,base_url=self.endpoint)
63
- def chat(self,user:str,system:str="",max_tokens:int=4096)->str:
64
- if self.provider=="anthropic":
65
- args=dict(model=self.model,system=system,messages=[{"role":"user","content":user}],max_tokens=max_tokens,temperature=self.temperature,top_p=self.top_p)
66
- if self.thinking: args["vision"]="detailed"
67
- return self.sdk.messages.create(**args).content[0].text
68
- args=dict(model=self.model,messages=[["system",system],["user",user]],max_tokens=max_tokens,temperature=self.temperature,top_p=self.top_p)
69
- if self.thinking: args["reasoning_mode"]="enhanced"
70
- return self.sdk.chat.completions.create(**args).choices[0].message.content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  # ─── card helpers ──────────────────────────────────────────────────────────
73
- CARD_REQUIRED={"char_name","char_persona","world_scenario","char_greeting","example_dialogue","description"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- def extract_card_json(txt:str)->Tuple[str|None,JsonDict|None]:
 
76
  try:
77
- body=txt.replace("```json","",1).replace("```","",1).strip()
78
- raw=body[body.find("{"):body.rfind("}")+1]
79
- data=json.loads(raw)
80
- data.update(name=data["char_name"],personality=data["char_persona"],scenario=data["world_scenario"],first_mes=data["char_greeting"])
81
- return (json.dumps(data,indent=2),data) if CARD_REQUIRED<=data.keys() else (None,None)
82
- except Exception:
83
- return None,None
84
-
85
- def inject_card_into_png(img_path:str,card:Union[str,JsonDict]):
86
- card=json.loads(card) if isinstance(card,str) else card
87
- img=Image.open(img_path)
88
- w,h=img.size; ratio=400/600
89
- img=img.crop(((w-int(h*ratio))//2,0,(w+int(h*ratio))//2,h)) if w/h>ratio else img.crop((0,(h-int(w/ratio))//2,w,(h+int(w/ratio))//2))
90
- img=img.resize((400,600),Image.LANCZOS)
91
- meta=PngInfo(); meta.add_text("chara",json.dumps(card).encode().hex())
92
- out=Path(__file__).with_name("outputs"); out.mkdir(exist_ok=True)
93
- dest=out/f"{card['name']}_{uuid.uuid4()}.png"
94
- img.save(dest,"PNG",pnginfo=meta); return dest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  # ─── Gradio UI ─────────────────────────────────────────────────────────────
97
 
98
  def build_ui():
99
  with gr.Blocks(title=f"SillyTavern Card Gen {__version__}") as demo:
100
- gr.Markdown(f"### πŸƒ SillyTavern Character Generator v{__version__}")
 
101
 
102
- with gr.Tab("JSON Generate"):
103
  with gr.Row():
104
- with gr.Column():
105
- endpoint=gr.Textbox(label="Endpoint",value=DEFAULT_ANTHROPIC_ENDPOINT)
106
- api_key=gr.Textbox(label="API Key",type="password")
107
- model_dd=gr.Dropdown(ALL_MODELS,label="Model")
108
- thinking=gr.Checkbox(label="Thinking mode")
109
- temp=gr.Slider(0,1,0.7,label="Temperature")
110
- topp=gr.Slider(0,1,0.9,label="Top‑P")
111
- prompt=gr.Textbox(lines=5,label="Prompt")
112
- gen=gr.Button("Generate JSON")
113
- with gr.Column():
114
- raw_out=gr.Textbox(label="Raw output")
115
- json_out=gr.Textbox(label="Card JSON")
116
- json_file=gr.File(label="Download .json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  with gr.Row():
118
- img_model=gr.Dropdown(["SDXL","midjourney"],label="Image model",value="SDXL")
119
- gen_img_prompt=gr.Button("Generate image prompt")
120
- img_prompt_out=gr.Textbox(label="Image prompt")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
- with gr.Tab("PNG Inject"):
123
- gr.Markdown("Upload an image and JSON to embed")
124
  with gr.Row():
125
- img_up=gr.Image(type="filepath")
126
- json_text=gr.Textbox(label="JSON")
127
- json_up=gr.File(label="Or upload .json",file_types=[".json"])
128
- inject_btn=gr.Button("Inject")
129
- png_out=gr.File(label="PNG with card")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
- # ── callbacks ───────────────────────────────────────────────────
 
132
  def choose_endpoint(k):
133
- return DEFAULT_ANTHROPIC_ENDPOINT if k.startswith("sk-ant-") else DEFAULT_OPENAI_ENDPOINT if k.startswith("sk-") else DEFAULT_ANTHROPIC_ENDPOINT
134
- api_key.change(choose_endpoint,api_key,endpoint)
135
-
136
- def generate_json(ep,k,m,think,t,p,user):
137
- cfg=APIConfig(ep.strip(),k.strip(),m,t,p,think)
138
- sys_prompt=Path(__file__).with_name("json.txt").read_text() if Path(__file__).with_name("json.txt").exists() else ""
139
- out=cfg.chat(user,sys_prompt)
140
- raw,parsed=extract_card_json(out)
141
- if raw and parsed:
142
- outdir=Path(__file__).with_name("outputs"); outdir.mkdir(exist_ok=True)
143
- fp=outdir/f"{parsed['name']}_{uuid.uuid4()}.json"; fp.write_text(raw)
144
- return out,raw,str(fp)
145
- return out,"",None
146
- gen.click(generate_json,[endpoint,api_key,model_dd,thinking,temp,topp,prompt],[raw_out,json_out,json_file])
147
-
148
- def gen_img(ep,k,m,raw,image_model):
149
- cfg=APIConfig(ep.strip(),k.strip(),m)
150
- sys_prompt=Path(__file__).with_name(f"{image_model}.txt").read_text() if Path(__file__).with_name(f"{image_model}.txt").exists() else ""
151
- return cfg.chat(raw,sys_prompt)
152
- gen_img_prompt.click(gen_img,[endpoint,api_key,model_dd,raw_out,img_model],img_prompt_out)
153
-
154
- def inject(image,json_str,json_path):
155
- if json_path: json_str=Path(json_path).read_text()
156
- return str(inject_card_into_png(image,json_str)) if image and json_str else None
157
- inject_btn.click(inject,[img_up,json_text,json_up],png_out)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
  return demo
160
 
161
- # ─── main ─────────────────────────────────────────────────────────��─────────
162
- if __name__=="__main__":
163
- print(f"== SillyTavern Card Gen {__version__} ==")
164
- build_ui().launch(share=True,show_api=False)
 
 
 
 
 
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
+ SillyTavern Character‑Card Generator β€” version 2.0.3Β (AprΒ 2025)
4
  ──────────────────────────────────────────────────────────────
5
+ β€’ Added helpful placeholder text for all text inputs so first‑time users
6
+ immediately know what to type or paste.
7
+ β€’ No behavioural changes beyond UI polish.
8
  """
9
 
10
  from __future__ import annotations
 
11
  import json, sys, uuid
12
  from dataclasses import dataclass
13
  from functools import cached_property
 
18
  from PIL import Image
19
  from PIL.PngImagePlugin import PngInfo
20
 
21
+ __version__ = "2.0.3"
22
+ MIN_GRADIO = (4, 44, 1)
23
+ if tuple(map(int, gr.__version__.split("."))) < MIN_GRADIO:
24
+ sys.exit(
25
+ f"gradio>={'/'.join(map(str, MIN_GRADIO))} required β€” found {gr.__version__}"
26
+ )
27
 
28
  # ─── Model lists ───────────────────────────────────────────────────────────
29
  CLAUDE_MODELS = [
30
+ "claude-3-opus-20240229",
31
+ "claude-3-sonnet-20240229",
32
+ "claude-3-haiku-20240307",
33
+ "claude-3-5-sonnet-20240620",
34
+ "claude-3-5-sonnet-20241022", # Hypothetical future model
35
+ "claude-3-5-haiku-20241022", # Hypothetical future model
36
+ "claude-3-7-sonnet-20250219", # Hypothetical future model
37
  ]
38
  OPENAI_MODELS = [
39
+ "o3", # Hypothetical future model
40
+ "o3-mini", # Hypothetical future model
41
+ "o4-mini", # Hypothetical future model
42
+ "gpt-4.1", # Hypothetical future model
43
+ "gpt-4.1-mini", # Hypothetical future model
44
+ "gpt-4.1-nano", # Hypothetical future model
45
+ "gpt-4o",
46
+ "gpt-4o-mini",
47
+ "gpt-4",
48
+ "gpt-4-32k",
49
+ "gpt-4-0125-preview",
50
+ "gpt-4-turbo-preview",
51
+ "gpt-4-1106-preview",
52
+ "gpt-3.5-turbo",
53
  ]
54
  ALL_MODELS = CLAUDE_MODELS + OPENAI_MODELS
55
+ DEFAULT_ANTHROPIC_ENDPOINT = "https://api.anthropic.com"
56
+ DEFAULT_OPENAI_ENDPOINT = "https://api.openai.com/v1"
57
 
58
  # ─── API wrapper ───────────────────────────────────────────────────────────
59
+ JsonDict = Dict[str, Any]
60
  try:
61
+ from anthropic import Anthropic, APITimeoutError as AnthropicTimeout
62
  except ImportError:
63
+ Anthropic = None
64
  try:
65
+ from openai import OpenAI, APITimeoutError as OpenAITimeout
66
  except ImportError:
67
+ OpenAI = None
68
+
69
 
70
  @dataclass
71
  class APIConfig:
72
+ endpoint: str
73
+ api_key: str
74
+ model: str
75
+ temperature: float = 0.7
76
+ top_p: float = 0.9
77
+ thinking: bool = False
78
+
79
  @cached_property
80
  def provider(self):
81
  return "anthropic" if self.model in CLAUDE_MODELS else "openai"
82
+
83
  @cached_property
84
  def sdk(self):
85
+ if not self.api_key:
86
+ raise gr.Error("API Key is required.")
87
+ if not self.model:
88
+ raise gr.Error("Model selection is required.")
89
+
90
+ try:
91
+ if self.provider == "anthropic":
92
+ if not Anthropic:
93
+ raise RuntimeError("Anthropic SDK not installed. Run: pip install anthropic")
94
+ return Anthropic(api_key=self.api_key, base_url=self.endpoint)
95
+ else: # openai
96
+ if not OpenAI:
97
+ raise RuntimeError("OpenAI SDK not installed. Run: pip install openai")
98
+ return OpenAI(api_key=self.api_key, base_url=self.endpoint)
99
+ except Exception as e:
100
+ raise gr.Error(f"Failed to initialize API client: {e}")
101
+
102
+ def chat(self, user: str, system: str = "", max_tokens: int = 4096) -> str:
103
+ try:
104
+ if self.provider == "anthropic":
105
+ args = dict(
106
+ model=self.model,
107
+ system=system,
108
+ messages=[{"role": "user", "content": user}],
109
+ max_tokens=max_tokens,
110
+ temperature=self.temperature,
111
+ top_p=self.top_p,
112
+ )
113
+ # Note: Anthropic doesn't have a direct 'thinking' or 'vision' parameter
114
+ # for text generation in the way described. This might be a placeholder
115
+ # or intended for a different API structure. Assuming standard text chat.
116
+ # if self.thinking:
117
+ # args["vision"] = "detailed" # This is not a standard Anthropic param for messages API
118
+ response = self.sdk.messages.create(**args)
119
+ if response.content and isinstance(response.content, list):
120
+ return response.content[0].text
121
+ else:
122
+ raise gr.Error("Unexpected response format from Anthropic API.")
123
+
124
+ else: # openai
125
+ messages = []
126
+ if system:
127
+ messages.append({"role": "system", "content": system})
128
+ messages.append({"role": "user", "content": user})
129
+
130
+ args = dict(
131
+ model=self.model,
132
+ messages=messages,
133
+ max_tokens=max_tokens,
134
+ temperature=self.temperature,
135
+ top_p=self.top_p,
136
+ )
137
+ # Note: OpenAI doesn't have a direct 'reasoning_mode' parameter
138
+ # for chat completions. This might be a placeholder or intended for
139
+ # a different API structure. Assuming standard chat completion.
140
+ # if self.thinking:
141
+ # args["reasoning_mode"] = "enhanced" # Not a standard OpenAI param
142
+ response = self.sdk.chat.completions.create(**args)
143
+ if response.choices:
144
+ return response.choices[0].message.content
145
+ else:
146
+ raise gr.Error("No response choices received from OpenAI API.")
147
+
148
+ except (AnthropicTimeout, OpenAITimeout) as e:
149
+ raise gr.Error(f"API request timed out: {e}")
150
+ except Exception as e:
151
+ # Provide more specific error feedback if possible
152
+ err_msg = f"API Error ({self.provider}): {e}"
153
+ if "authentication" in str(e).lower():
154
+ err_msg = "API Error: Authentication failed. Check your API Key and Endpoint."
155
+ elif "rate limit" in str(e).lower():
156
+ err_msg = "API Error: Rate limit exceeded. Please wait and try again."
157
+ elif "not found" in str(e).lower() and "model" in str(e).lower():
158
+ err_msg = f"API Error: Model '{self.model}' not found or unavailable at '{self.endpoint}'."
159
+
160
+ raise gr.Error(err_msg)
161
+
162
 
163
  # ─── card helpers ──────────────────────────────────────────────────────────
164
+ CARD_REQUIRED = {
165
+ "char_name",
166
+ "char_persona",
167
+ "world_scenario",
168
+ "char_greeting",
169
+ "example_dialogue",
170
+ # "description", # Note: SillyTavern uses 'description', but the prompt generates it. Let's keep it flexible.
171
+ }
172
+ CARD_RENAMES = {
173
+ "char_name": "name",
174
+ "char_persona": "personality",
175
+ "world_scenario": "scenario",
176
+ "char_greeting": "first_mes",
177
+ "example_dialogue": "mes_example",
178
+ # description maps directly to description
179
+ }
180
 
181
+ def extract_card_json(txt: str) -> Tuple[str | None, JsonDict | None]:
182
+ """Extracts JSON block, validates required keys, and renames keys for SillyTavern."""
183
  try:
184
+ # Find the JSON block, allowing for potential markdown fences
185
+ json_start = txt.find("{")
186
+ json_end = txt.rfind("}")
187
+ if json_start == -1 or json_end == -1 or json_end < json_start:
188
+ gr.Warning("Could not find JSON block in the LLM output.")
189
+ return None, None
190
+
191
+ raw_json_str = txt[json_start : json_end + 1]
192
+ data = json.loads(raw_json_str)
193
+
194
+ # Validate required keys generated by the LLM
195
+ missing_keys = CARD_REQUIRED - data.keys()
196
+ if missing_keys:
197
+ gr.Warning(f"LLM output missing required keys: {', '.join(missing_keys)}")
198
+ return None, None
199
+
200
+ # Rename keys for SillyTavern format and add the original description
201
+ st_data = {st_key: data[orig_key] for orig_key, st_key in CARD_RENAMES.items()}
202
+ if "description" in data:
203
+ st_data["description"] = data["description"] # Add description if present
204
+ else:
205
+ gr.Warning("LLM output missing 'description' key. Card might be incomplete.")
206
+ st_data["description"] = "" # Add empty description if missing
207
+
208
+ # Add spec field if not present (though usually not generated by LLM)
209
+ if "spec" not in st_data:
210
+ st_data["spec"] = "chara_card_v2"
211
+ if "spec_version" not in st_data:
212
+ st_data["spec_version"] = "2.0" # Or the appropriate version
213
+
214
+ # Ensure essential fields are present after rename
215
+ final_required = {"name", "personality", "scenario", "first_mes", "mes_example", "description"}
216
+ if not final_required <= st_data.keys():
217
+ gr.Warning(f"Internal Error: Failed to map required keys. Check CARD_RENAMES.")
218
+ return None, None
219
+
220
+ # Return formatted JSON string and the dictionary
221
+ formatted_json = json.dumps(st_data, indent=2)
222
+ return formatted_json, st_data
223
+
224
+ except json.JSONDecodeError:
225
+ gr.Warning("Failed to parse JSON from the LLM output.")
226
+ return None, None
227
+ except Exception as e:
228
+ gr.Warning(f"Error processing LLM output: {e}")
229
+ return None, None
230
+
231
+
232
+ def inject_card_into_png(img_path: str, card_data: Union[str, JsonDict]) -> Path:
233
+ """Embeds card JSON into PNG metadata, resizes, and saves."""
234
+ if not img_path:
235
+ raise gr.Error("Input image not provided.")
236
+
237
+ try:
238
+ if isinstance(card_data, str):
239
+ card = json.loads(card_data)
240
+ else:
241
+ card = card_data # Assume it's already a dict
242
+
243
+ if not isinstance(card, dict) or "name" not in card:
244
+ raise gr.Error("Invalid or incomplete card JSON provided.")
245
+
246
+ except json.JSONDecodeError:
247
+ raise gr.Error("Invalid JSON format in the provided text.")
248
+ except Exception as e:
249
+ raise gr.Error(f"Error processing card data: {e}")
250
+
251
+ try:
252
+ img = Image.open(img_path)
253
+ img = img.convert("RGB") # Ensure consistent format
254
+
255
+ # Resize logic (optional, depends on desired output)
256
+ w, h = img.size
257
+ target_w, target_h = 400, 600 # Example target size
258
+ target_ratio = target_w / target_h
259
+ img_ratio = w / h
260
+
261
+ if abs(img_ratio - target_ratio) > 0.01: # Only crop/resize if aspect ratio differs significantly
262
+ if img_ratio > target_ratio: # Wider than target: crop sides
263
+ new_w = int(h * target_ratio)
264
+ left = (w - new_w) // 2
265
+ right = left + new_w
266
+ img = img.crop((left, 0, right, h))
267
+ else: # Taller than target: crop top/bottom
268
+ new_h = int(w / target_ratio)
269
+ top = (h - new_h) // 2
270
+ bottom = top + new_h
271
+ img = img.crop((0, top, w, bottom))
272
+
273
+ img = img.resize((target_w, target_h), Image.LANCZOS)
274
+
275
+ # Prepare metadata
276
+ meta = PngInfo()
277
+ # Encode JSON string to bytes, then to hex for safety in metadata
278
+ meta.add_text("chara", json.dumps(card, ensure_ascii=False).encode('utf-8').hex())
279
+
280
+ # Prepare output directory and filename
281
+ out_dir = Path(__file__).parent / "outputs"
282
+ out_dir.mkdir(parents=True, exist_ok=True)
283
+ # Sanitize character name for filename
284
+ char_name_safe = "".join(c for c in card.get('name', 'character') if c.isalnum() or c in (' ', '_', '-')).rstrip()
285
+ dest = out_dir / f"{char_name_safe}_{uuid.uuid4().hex[:8]}.png"
286
+
287
+ # Save image with metadata
288
+ img.save(dest, "PNG", pnginfo=meta)
289
+ gr.Info(f"Card successfully embedded into {dest.name}")
290
+ return dest
291
+
292
+ except FileNotFoundError:
293
+ raise gr.Error(f"Input image file not found: {img_path}")
294
+ except Exception as e:
295
+ raise gr.Error(f"Error processing image or saving PNG: {e}")
296
+
297
 
298
  # ─── Gradio UI ─────────────────────────────────────────────────────────────
299
 
300
  def build_ui():
301
  with gr.Blocks(title=f"SillyTavern Card Gen {__version__}") as demo:
302
+ gr.Markdown(f"## πŸƒ SillyTavern Character Card Generator v{__version__}")
303
+ gr.Markdown("Create character cards for SillyTavern using LLMs.")
304
 
305
+ with gr.Tab("Step 1: Generate Card JSON"):
306
  with gr.Row():
307
+ with gr.Column(scale=1):
308
+ gr.Markdown("#### LLM Configuration")
309
+ endpoint = gr.Textbox(
310
+ label="API Endpoint",
311
+ value=DEFAULT_ANTHROPIC_ENDPOINT,
312
+ placeholder="LLM API base URL (e.g., https://api.anthropic.com)",
313
+ info="Automatically updates based on API Key prefix (sk-ant- vs sk-)."
314
+ )
315
+ api_key = gr.Textbox(
316
+ label="API Key",
317
+ type="password",
318
+ placeholder="Paste your sk-ant-... or sk-... key here",
319
+ )
320
+ model_dd = gr.Dropdown(
321
+ ALL_MODELS,
322
+ label="Model",
323
+ info="Select the LLM to use for generation.",
324
+ value=CLAUDE_MODELS[0] # Default to a common Claude model
325
+ )
326
+ thinking = gr.Checkbox(
327
+ label="Thinking mode (deeper reasoning)",
328
+ value=False,
329
+ info="May enable enhanced reasoning modes (experimental, model-dependent)."
330
+ )
331
+ with gr.Accordion("Advanced Settings", open=False):
332
+ temp = gr.Slider(0, 1, 0.7, label="Temperature", info="Controls randomness. Lower is more deterministic.")
333
+ topp = gr.Slider(0, 1, 0.9, label="Top‑P", info="Nucleus sampling. Considers tokens comprising the top P probability mass.")
334
+
335
+ with gr.Column(scale=2):
336
+ gr.Markdown("#### Character Definition")
337
+ prompt = gr.Textbox(
338
+ lines=8,
339
+ label="Character Description Prompt",
340
+ placeholder="Describe the character you want to create in detail. Include:\n"
341
+ "- Appearance (hair, eyes, clothing, distinguishing features)\n"
342
+ "- Personality (traits, quirks, likes, dislikes, motivations)\n"
343
+ "- Backstory (origins, key life events, relationships)\n"
344
+ "- Setting/Scenario (where and when the interaction takes place)\n"
345
+ "- Any specific details relevant to their speech or behavior.",
346
+ info="Provide a rich description for the LLM to generate the card fields."
347
+ )
348
+ gen = gr.Button("Generate JSON Card", variant="primary")
349
+
350
  with gr.Row():
351
+ with gr.Column(scale=1):
352
+ gr.Markdown("#### LLM Output")
353
+ raw_out = gr.Textbox(
354
+ label="Raw LLM Output",
355
+ lines=15,
356
+ show_copy_button=True,
357
+ placeholder="The full response from the language model will appear here.",
358
+ info="Contains the generated JSON block and potentially other text."
359
+ )
360
+ with gr.Column(scale=1):
361
+ gr.Markdown("#### Processed Card")
362
+ json_out = gr.Textbox(
363
+ label="Extracted SillyTavern JSON",
364
+ lines=15,
365
+ show_copy_button=True,
366
+ placeholder="The extracted and formatted JSON for SillyTavern will appear here.",
367
+ info="This is the data that will be embedded in the PNG."
368
+ )
369
+ json_file = gr.File(label="Download .json Card", file_count="single", interactive=False)
370
+
371
+ with gr.Accordion("Step 1b: Generate Image Prompt (Optional)", open=False):
372
+ with gr.Row():
373
+ img_model = gr.Dropdown(
374
+ ["SDXL", "Midjourney"], # Simplified names
375
+ label="Target Image Model",
376
+ value="SDXL",
377
+ info="Optimize the image prompt for this AI model.",
378
+ )
379
+ gen_img_prompt = gr.Button("Generate Image Prompt from Card")
380
+ img_prompt_out = gr.Textbox(
381
+ label="Generated Image Prompt",
382
+ show_copy_button=True,
383
+ placeholder="An image generation prompt based on the card details will appear here.",
384
+ info="Copy this prompt into your preferred image generation tool."
385
+ )
386
 
387
+ with gr.Tab("Step 2: Inject JSON into PNG"):
388
+ gr.Markdown("Upload your character image and the generated JSON (or paste/upload it) to create the final PNG card.")
389
  with gr.Row():
390
+ with gr.Column():
391
+ img_up = gr.Image(type="filepath", label="Upload Character Image", sources=["upload", "clipboard"])
392
+ with gr.Column():
393
+ # Option 1: Use JSON from Step 1
394
+ gr.Markdown("Use JSON generated in Step 1 (automatically filled if generated).")
395
+ json_text_from_step1 = gr.Textbox(
396
+ label="Card JSON (from Step 1 or paste here)",
397
+ lines=8,
398
+ placeholder="Paste the SillyTavern JSON here if you didn't generate it in Step 1, or if you want to override it.",
399
+ info="This field is automatically populated from Step 1's 'Extracted SillyTavern JSON'."
400
+ )
401
+ # Option 2: Upload JSON file
402
+ json_up = gr.File(
403
+ label="...or Upload .json File",
404
+ file_count="single",
405
+ file_types=[".json"],
406
+ info="Upload a previously saved .json card file."
407
+ )
408
+ inject_btn = gr.Button("Embed JSON & Create PNG Card", variant="primary")
409
+ png_out = gr.File(label="Download PNG Card", file_count="single", interactive=False)
410
+ png_preview = gr.Image(label="PNG Card Preview", interactive=False, width=200, height=300)
411
 
412
+
413
+ # ── Callbacks Wiring ───────────────────────────────────────────
414
  def choose_endpoint(k):
415
+ """Automatically suggest endpoint based on API key prefix."""
416
+ if isinstance(k, str):
417
+ if k.startswith("sk-ant-"):
418
+ return DEFAULT_ANTHROPIC_ENDPOINT
419
+ elif k.startswith("sk-"):
420
+ return DEFAULT_OPENAI_ENDPOINT
421
+ # Default or if key is empty/invalid prefix
422
+ return DEFAULT_ANTHROPIC_ENDPOINT
423
+
424
+ api_key.change(choose_endpoint, inputs=api_key, outputs=endpoint, show_progress=False)
425
+
426
+ def generate_json_card(ep, k, m, think, t, p, user_prompt):
427
+ """Handles the JSON generation button click."""
428
+ if not user_prompt:
429
+ raise gr.Error("Character Description Prompt cannot be empty.")
430
+ if not k:
431
+ raise gr.Error("API Key is required.")
432
+ if not m:
433
+ raise gr.Error("Model must be selected.")
434
+
435
+ try:
436
+ cfg = APIConfig(ep.strip(), k.strip(), m, t, p, think)
437
+
438
+ # Load the system prompt for JSON generation
439
+ sys_prompt_path = Path(__file__).parent / "json.txt"
440
+ if not sys_prompt_path.exists():
441
+ # Fallback or default prompt if file is missing
442
+ gr.Warning("System prompt file 'json.txt' not found. Using a basic prompt.")
443
+ 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:
444
+ - char_name: The character's name.
445
+ - char_persona: A detailed description of the character's personality, motivations, and mannerisms.
446
+ - world_scenario: The setting or context where the user interacts with the character.
447
+ - char_greeting: The character's first message to the user.
448
+ - example_dialogue: Example dialogue demonstrating the character's speech patterns and personality. Use {{user}} and {{char}} placeholders.
449
+ - description: A general description covering appearance and backstory.
450
+
451
+ Output ONLY the JSON object, enclosed in ```json ... ```."""
452
+ else:
453
+ sys_prompt = sys_prompt_path.read_text(encoding='utf-8')
454
+
455
+ raw_output = cfg.chat(user_prompt, sys_prompt)
456
+ extracted_json_str, parsed_data = extract_card_json(raw_output)
457
+
458
+ if extracted_json_str and parsed_data:
459
+ # Create a downloadable JSON file
460
+ outdir = Path(__file__).parent / "outputs"
461
+ outdir.mkdir(parents=True, exist_ok=True)
462
+ # Sanitize name for filename
463
+ char_name_safe = "".join(c for c in parsed_data.get('name', 'character') if c.isalnum() or c in (' ', '_', '-')).rstrip()
464
+ json_filename = outdir / f"{char_name_safe}_{uuid.uuid4().hex[:8]}.json"
465
+ json_filename.write_text(extracted_json_str, encoding='utf-8')
466
+ gr.Info("JSON card generated successfully.")
467
+ # Update outputs: raw output, extracted JSON, downloadable file, and populate Step 2 input
468
+ return raw_output, extracted_json_str, gr.File(value=str(json_filename), visible=True), extracted_json_str
469
+ else:
470
+ gr.Warning("Failed to extract valid JSON from LLM output. Check 'Raw LLM Output' for details.")
471
+ # Update outputs, clearing JSON fields and file
472
+ return raw_output, "", gr.File(value=None, visible=False), ""
473
+
474
+ except gr.Error as e: # Catch Gradio-specific errors (like API init failures)
475
+ raise e # Re-raise to display the error message in the UI
476
+ except Exception as e:
477
+ gr.Error(f"An unexpected error occurred during JSON generation: {e}")
478
+ return f"Error: {e}", "", gr.File(value=None, visible=False), "" # Show error in raw output
479
+
480
+ gen.click(
481
+ generate_json_card,
482
+ inputs=[endpoint, api_key, model_dd, thinking, temp, topp, prompt],
483
+ outputs=[raw_out, json_out, json_file, json_text_from_step1], # Update Step 2 input too
484
+ api_name="generate_json"
485
+ )
486
+
487
+ def generate_image_prompt(ep, k, m, card_json_str, image_gen_model):
488
+ """Handles the image prompt generation button click."""
489
+ if not card_json_str:
490
+ raise gr.Error("Cannot generate image prompt without valid Card JSON.")
491
+ if not k:
492
+ raise gr.Error("API Key is required for image prompt generation.")
493
+ if not m:
494
+ raise gr.Error("Model must be selected for image prompt generation.")
495
+
496
+ try:
497
+ # Use a cheaper/faster model if available, or the selected one
498
+ # For simplicity, we use the same config as JSON gen for now
499
+ cfg = APIConfig(ep.strip(), k.strip(), m)
500
+
501
+ # Load the appropriate system prompt based on the target image model
502
+ prompt_filename = f"{image_gen_model.lower()}.txt"
503
+ sys_prompt_path = Path(__file__).parent / prompt_filename
504
+ if not sys_prompt_path.exists():
505
+ gr.Warning(f"System prompt file '{prompt_filename}' not found. Using a generic image prompt.")
506
+ 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"
507
+ else:
508
+ sys_prompt = sys_prompt_path.read_text(encoding='utf-8') + "\nCharacter JSON:\n"
509
+
510
+ # Construct user prompt for the LLM
511
+ user_img_prompt = f"{sys_prompt}{card_json_str}"
512
+
513
+ img_prompt = cfg.chat(user_img_prompt, max_tokens=200) # Limit token count for prompts
514
+ gr.Info("Image prompt generated.")
515
+ return img_prompt.strip()
516
+
517
+ except gr.Error as e:
518
+ raise e
519
+ except Exception as e:
520
+ gr.Error(f"An unexpected error occurred during image prompt generation: {e}")
521
+ return f"Error generating prompt: {e}"
522
+
523
+ gen_img_prompt.click(
524
+ generate_image_prompt,
525
+ inputs=[endpoint, api_key, model_dd, json_out, img_model], # Use generated JSON output
526
+ outputs=[img_prompt_out],
527
+ api_name="generate_image_prompt"
528
+ )
529
+
530
+ def handle_json_upload(json_file_obj, current_json_text):
531
+ """Reads uploaded JSON file and updates the text box, overriding text if file is provided."""
532
+ if json_file_obj is not None:
533
+ try:
534
+ json_path = Path(json_file_obj.name)
535
+ content = json_path.read_text(encoding='utf-8')
536
+ # Validate if it's proper JSON before updating
537
+ json.loads(content)
538
+ gr.Info(f"Loaded JSON from {json_path.name}")
539
+ return content
540
+ except json.JSONDecodeError:
541
+ gr.Warning("Uploaded file is not valid JSON. Keeping existing text.")
542
+ return current_json_text
543
+ except Exception as e:
544
+ gr.Warning(f"Error reading uploaded JSON file: {e}. Keeping existing text.")
545
+ return current_json_text
546
+ # If no file is uploaded, keep the existing text (which might be from Step 1)
547
+ return current_json_text
548
+
549
+ # When a JSON file is uploaded, update the text box
550
+ json_up.upload(
551
+ handle_json_upload,
552
+ inputs=[json_up, json_text_from_step1],
553
+ outputs=[json_text_from_step1]
554
+ )
555
+
556
+ def inject_card(img_filepath, json_str):
557
+ """Handles the PNG injection button click."""
558
+ if not img_filepath:
559
+ raise gr.Error("Please upload a character image first.")
560
+ if not json_str:
561
+ raise gr.Error("Card JSON is missing. Generate it in Step 1 or paste/upload it.")
562
+
563
+ try:
564
+ # The helper function handles JSON parsing and validation
565
+ output_png_path = inject_card_into_png(img_filepath, json_str)
566
+ # Return path for download and preview
567
+ return gr.File(value=str(output_png_path), visible=True), gr.Image(value=str(output_png_path), visible=True)
568
+ except gr.Error as e: # Catch errors from inject_card_into_png
569
+ raise e
570
+ except Exception as e:
571
+ gr.Error(f"An unexpected error occurred during PNG injection: {e}")
572
+ return gr.File(value=None, visible=False), gr.Image(value=None, visible=False) # Clear outputs on error
573
+
574
+ inject_btn.click(
575
+ inject_card,
576
+ inputs=[img_up, json_text_from_step1], # Use the text box content
577
+ outputs=[png_out, png_preview],
578
+ api_name="inject_card"
579
+ )
580
 
581
  return demo
582
 
583
+ # --- Main execution ---
584
+ if __name__ == "__main__":
585
+ # Create dummy prompt files if they don't exist
586
+ prompt_dir = Path(__file__).parent
587
+
588
+ # Create outputs directory
589
+ (prompt_dir / "outputs").mkdir(exist_ok=True)
590
+
591
+ # Build and launch the Gradio interface
592
+ app = build_ui()
593
+ app.launch()