"use client"; import { useState, useEffect } from "react"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Png } from "@/lib/png"; import { ChevronUp, ChevronDown } from "lucide-react"; import { CollapsibleContent, Collapsible, CollapsibleTrigger, } from "@/components/ui/collapsible"; interface Card { id: string; name: string; first_mes: string; description: string; personality: string; mes_example: string; scenario: string; avatarUrl?: string; } export default function Home() { const [isInstructionsOpen, setIsInstructionsOpen] = useState(false); const [cards, setCards] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); const [selectedCardIndex, setSelectedCardIndex] = useState( null ); const [characterUrl, setCharacterUrl] = useState(""); const [avatarPath, setAvatarPath] = useState(""); const [isMetadataOpen, setIsMetadataOpen] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const fetchCards = async () => { try { setIsRefreshing(true); const response = await fetch("/api/proxy"); const data = await response.json(); if (data.cards) { setCards((prevCards) => { return data.cards.map((newCard: Card) => ({ ...newCard, avatarUrl: prevCards.find((c) => c.id === newCard.id)?.avatarUrl || newCard.avatarUrl, })); }); } } catch (error) { console.error("Error fetching cards:", error); } finally { setIsRefreshing(false); } }; useEffect(() => { fetchCards(); }, []); const downloadJson = (card: Card) => { const element = document.createElement("a"); const file = new Blob([JSON.stringify(card, null, 2)], { type: "application/json", }); element.href = URL.createObjectURL(file); element.download = `${card.name.replace(/\s+/g, "_")}.json`; document.body.appendChild(element); element.click(); document.body.removeChild(element); }; const downloadPng = async (card: Card, cardId: string) => { if (!card.avatarUrl) return; try { const img = new Image(); img.src = `/api/proxy/image?url=${encodeURIComponent(card.avatarUrl)}`; await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; }); const canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not get canvas context"); ctx.drawImage(img, 0, 0); const pngBlob = await new Promise((resolve) => { canvas.toBlob((blob) => { if (blob) resolve(blob); else throw new Error("Could not convert to PNG"); }, "image/png"); }); const arrayBuffer = await pngBlob.arrayBuffer(); const cardData = JSON.stringify({ name: card.name, first_mes: card.first_mes, description: card.description, personality: card.personality, mes_example: card.mes_example, scenario: card.scenario, }); const newImageData = Png.Generate(arrayBuffer, cardData); const newFileName = `${ card.name.replace(/\s+/g, "_") || "character" }.png`; const newFile = new File([newImageData], newFileName, { type: "image/png", }); const link = URL.createObjectURL(newFile); const a = document.createElement("a"); a.download = newFileName; a.href = link; a.click(); URL.revokeObjectURL(link); } catch (error) { console.error("Error generating PNG:", error); alert("Couldn't export this character card, sorry."); } }; const handleOpenDialog = (index: number) => { setSelectedCardIndex(index); setDialogOpen(true); setCharacterUrl(""); setAvatarPath(""); setIsMetadataOpen(false); }; const handleOpenMetadata = () => { const match = characterUrl.match(/characters\/([\w-]+)/); if (match && match[1]) { const characterId = match[1].split("_")[0]; window.open( `https://janitorai.com/hampter/characters/${characterId}`, "_blank" ); setIsMetadataOpen(true); } }; const handleFetchAvatar = async () => { if (selectedCardIndex === null) return; try { const avatarUrl = `https://ella.janitorai.com/bot-avatars/${avatarPath}`; const updatedCards = [...cards]; updatedCards[selectedCardIndex] = { ...updatedCards[selectedCardIndex], avatarUrl, }; setCards(updatedCards); setDialogOpen(false); } catch (error) { console.error("Error fetching avatar:", error); } }; return (

Sucker v1.5

User persona name substitution fixed, let me know how it goes.

How to Use

Follow every instruction here to the letter because it's all you need to know and I have no intent of helping you further.

  1. Put https://sucker.severian.dev/api/proxy in your API settings, any value for model and key.
  2. Remove your custom prompt - otherwise, it'll get inserted into cards, on the example message section.  No need for this anymore. At least the new prompt system has it separate now.
  3. Save settings and refresh the page. Not this page. That{" "} page.
  4. Start a new chat with a character or multiple. Send any message.
  5. Hit the Refresh button here, and the cards should appear here.
  6. Download the JSON files or go through a little more effort to get PNGs instead.

Extractions will only last for 10 minutes, after which they're discarded. Reloading the page will remove any attached avatars. I'm not storing shit.

{cards.length === 0 ? (

No extractions yet.

) : ( cards.map((card, index) => ( {card.name || "Unnamed Card"}
{card.description && ( Description {card.description} )} {card.first_mes && ( First Message {card.first_mes} )} {card.scenario && ( Scenario {card.scenario} )} {card.mes_example && ( Example Messages {card.mes_example} )}
{!card.avatarUrl ? ( ) : ( )}
)) )}
{isMetadataOpen ? "Enter Avatar Path" : "Enter Character URL"} {isMetadataOpen ? "Look for the avatar field in the opened tab and paste the value here." : "Enter the Janitor character URL (https://janitorai.com/characters/...)."} {isMetadataOpen ? (
) => setAvatarPath(e.target.value) } />
) : (
) => setCharacterUrl(e.target.value) } />

Upon clicking this button, a new tab will open with the character's metadata. Look for the avatar field and copy the value before returning to this page.

)}
); }