"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 { Copy, ChevronLeft, ChevronRight, Menu, } from "lucide-react"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { DiscordBannerPermanent } from "@/components/ui/discord-banner-permanent"; import Script from "next/script"; interface CardDataV2 { name: string; first_mes: string; alternate_greetings?: string[]; description: string; personality: string; mes_example: string; scenario: string; creator?: string; creator_notes?: string; system_prompt?: string; post_history_instructions?: string; tags?: string[]; character_version?: string; extensions?: Record; } interface Card { id: string; data: CardDataV2; trackingName?: string; spec?: string; spec_version?: string; avatarUrl?: string; hasVersions?: boolean; versionCount?: number; messageCount?: number; alternate_greetings?: string[]; initialVersion?: CardDataV2; } export default function Home() { 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 [changesDialogOpen, setChangesDialogOpen] = useState(false); const [selectedChanges, setSelectedChanges] = useState(null); const [showFullText, setShowFullText] = useState(false); const [altGreetingIndexById, setAltGreetingIndexById] = useState< Record >({}); const [mirrorsDialogOpen, setMirrorsDialogOpen] = useState(false); const [changelogDialogOpen, setChangelogDialogOpen] = useState(false); const [howToUseDialogOpen, setHowToUseDialogOpen] = useState(false); const [proxyUrl, setProxyUrl] = useState( "https://sucker.severian.dev/api/proxy" ); const [pageSource, setPageSource] = useState(""); const [metadataJson, setMetadataJson] = useState(null); 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(); }, []); useEffect(() => { if (typeof window !== "undefined") { const origin = window.location.origin; setProxyUrl(`${origin}/api/proxy`); } }, []); const downloadJson = (card: Card) => { // Use initial version for download, or current version if no initial version available const chosen = card.initialVersion || card.data; const downloadData = { data: { name: chosen.name, first_mes: chosen.first_mes, alternate_greetings: chosen.alternate_greetings || [], description: chosen.description, personality: chosen.personality, mes_example: chosen.mes_example, scenario: chosen.scenario, creator: (chosen as any).creator || "", creator_notes: (chosen as any).creator_notes || "", system_prompt: (chosen as any).system_prompt || "", post_history_instructions: (chosen as any).post_history_instructions || "", tags: (chosen as any).tags || [], character_version: (chosen as any).character_version || "1", extensions: (chosen as any).extensions || {}, }, spec: card.spec || "chara_card_v2", spec_version: card.spec_version || "2.0", }; const element = document.createElement("a"); const file = new Blob([JSON.stringify(downloadData, null, 2)], { type: "application/json", }); element.href = URL.createObjectURL(file); element.download = `${(card.initialVersion?.name || card.data.name).replace( /[^a-zA-Z0-9\-_]/g, "_" )}.json`; document.body.appendChild(element); element.click(); document.body.removeChild(element); }; const downloadChanges = async (card: Card) => { try { const response = await fetch(`/api/proxy?changes=true&cardId=${card.id}`); if (!response.ok) { throw new Error("Failed to fetch changes"); } const changesData = await response.json(); const element = document.createElement("a"); const file = new Blob([JSON.stringify(changesData, null, 2)], { type: "application/json", }); element.href = URL.createObjectURL(file); element.download = `${( card.initialVersion?.name || card.data.name ).replace(/[^a-zA-Z0-9\-_]/g, "_")}_changes.json`; document.body.appendChild(element); element.click(); document.body.removeChild(element); } catch (error) { console.error("Error downloading changes:", error); alert( "Failed to download changes. The card may not have version history." ); } }; const viewChanges = async (card: Card) => { try { const response = await fetch(`/api/proxy?changes=true&cardId=${card.id}`); if (!response.ok) { throw new Error("Failed to fetch changes"); } const changesData = await response.json(); setSelectedChanges(changesData); setShowFullText(false); // Reset to diff view by default setChangesDialogOpen(true); } catch (error) { console.error("Error fetching changes:", error); alert("Failed to fetch changes. The card may not have version history."); } }; 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(); // Fallback if metadataJson hasn't been fetched for this card const safeMetadata = metadataJson || {}; const creator = (safeMetadata.creator_name || (card.data as any).creator || "Unknown") + (safeMetadata.creator_verified ? " ✅" : ""); // Tag Parsing const normalTags = (safeMetadata.tags || (card.data as any).tags || []).map((t: any) => t.name); const customTags = safeMetadata.custom_tags || []; const allTagsArray = [...normalTags, ...customTags]; // Use initial version for PNG embedding, or current version if no initial version available const chosen = card.initialVersion || card.data; const pngData = { data: { name: chosen.name, first_mes: chosen.first_mes, alternate_greetings: chosen.alternate_greetings || [], description: chosen.description, personality: chosen.personality, mes_example: chosen.mes_example, scenario: chosen.scenario, creator: creator, creator_notes: safeMetadata.description || (chosen as any).creator_notes || "", system_prompt: (chosen as any).system_prompt || "", post_history_instructions: (chosen as any).post_history_instructions || "", tags: allTagsArray, character_version: safeMetadata.name || (chosen as any).character_version || "1", extensions: (chosen as any).extensions || {}, }, spec: card.spec || "chara_card_v2", spec_version: card.spec_version || "2.0", }; const cardData = JSON.stringify(pngData); const newImageData = Png.Generate(arrayBuffer, cardData); const newFileName = `${(card.initialVersion?.name || card.data.name).replace( /[^a-zA-Z0-9\-_]/g, "_" ) || "character" }.png`; const newFile = new File([new Uint8Array(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 = () => { // Check if the input is a character metadata URL (janitorai.com/characters/...) const isCharacterUrl = /janitorai\.com\/characters\//.test(characterUrl); if (isCharacterUrl) { // Open html source, then show second input window.open(`view-source:${characterUrl}`, "_blank"); setIsMetadataOpen(true); return; } }; const handleFetchAvatar = async () => { if (selectedCardIndex === null) return; try { const storeKey = "Sk--a:a-a--characterStore"; // Define the anchor points we are looking for const prefix = 'window.mbxM.push(JSON.parse("'; const suffix = '"));'; // Locate the script content const startIndex = pageSource.indexOf(prefix); if (startIndex === -1) { throw new Error("Could not find character data in the page source."); } // Move index to the start of the actual JSON string const jsonStartIndex = startIndex + prefix.length; // Find the end of the statement const jsonEndIndex = pageSource.indexOf(suffix, jsonStartIndex); if (jsonEndIndex === -1) { throw new Error("Could not find closing tag for JSON content."); } // Extract the escaped string const escapedJsonString = pageSource.substring(jsonStartIndex, jsonEndIndex); // Wrap it in quotes so it gets treated as a string containing JSON const rawJsonString = JSON.parse(`"${escapedJsonString}"`); console.log(rawJsonString); // Parse the actual JSON data into an object const data = JSON.parse(rawJsonString); // Return the specific character data const char = data[storeKey].character; setMetadataJson(char); const avatarUrl = `https://ella.janitorai.com/bot-avatars/${char.avatar}`; const updatedCards = [...cards]; updatedCards[selectedCardIndex] = { ...updatedCards[selectedCardIndex], avatarUrl, }; setCards(updatedCards); setDialogOpen(false); } catch (error) { console.error("Error fetching avatar:", error); } }; const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); }; return (
{/* Mobile Layout */}
{/* Row 1: Title on left, Hamburger on right */}

Sucker v2.2

Consider joining Avalon!

setHowToUseDialogOpen(true)}> How to Use setMirrorsDialogOpen(true)}> Mirrors setChangelogDialogOpen(true)}> Changelog
{/* Row 2: Full-width Refresh button */}
{/* Desktop Layout */}

Sucker v2.2

Consider joining Avalon!

{/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */} {process.env.NEXT_PUBLIC_DISABLE_DISCORD_BANNER !== "true" && ( )}
{cards.length === 0 ? (

No extractions yet.

) : ( cards.map((card, index) => (
{card.initialVersion?.name || card.data?.name || "Unnamed Card"}
{card.hasVersions && ( v{card.versionCount} )} {card.messageCount && card.messageCount > 1 && ( {card.messageCount} msgs )}
{(card.initialVersion?.description || card.data?.description) && ( Description
                                        {card.initialVersion?.description ||
                                            card.data.description}
                                      
)} {(card.initialVersion?.first_mes || card.data?.first_mes) && ( First Message
                                        {card.initialVersion?.first_mes ||
                                            card.data.first_mes}
                                      
)} {card.alternate_greetings && card.alternate_greetings.length > 0 && (

{`Alternate Greetings (${card.alternate_greetings?.length || 0 })`}

{(() => { const greetings = card.alternate_greetings || []; const index = altGreetingIndexById[card.id] ?? 0; const current = greetings.length ? greetings[index % greetings.length] : ""; return (
                                        {current}
                                      
); })()}
)} {(card.initialVersion?.scenario || card.data?.scenario) && ( Scenario
                                        {card.initialVersion?.scenario ||
                                            card.data.scenario}
                                      
)} {(card.initialVersion?.mes_example || card.data?.mes_example) && ( Example Messages
                                        {card.initialVersion?.mes_example ||
                                            card.data.mes_example}
                                      
)} {(card.initialVersion?.personality || card.data?.personality) && ( Personality
                                        {card.initialVersion?.personality ||
                                            card.data.personality}
                                      
)}
{card.hasVersions && ( <> )} {!card.avatarUrl ? ( ) : ( )}
)) )}
{/* How to Use Dialog */} 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 {proxyUrl} in your API settings, any value for model and key.
  2. REQUIRED: Set your custom prompt to{" "} <.>
  3. REQUIRED: Set your persona (or create a new one) with the name{" "} {user} and the description should only have{" "} . in it.
  4. Save settings and refresh the page. Not this page. That{" "} page.
  5. Start a new chat with a character.
  6. Char name inference is implemented: if you send just a dot:{" "} ., sucker will use the inferred name from the persona tag, or you can send the character name yourself.
  7. Hit the Refresh button here, and the cards should appear here.
  8. If you're interested in capturing alternate greetings, start a new chat and send the conversation ID as first message instead of the character name. The format is{" "} [sucker:conv=conversationId] {" "} which you'll be given when creating a new card.
  9. You can also send more messages with possible keywords to trigger scripts/lorebooks. Sucker will track changes to the description and scenario fields. Cards with multiple versions will show a version badge and offer a "Download Changes" button to get a detailed change history with timestamps. Unfortunately, lorebook creation is out of scope at the moment, but you can use the changes detected to modify the character card yourself post-export.
  10. 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.

{/* Mirrors Dialog */} Sucker Mirrors Sucker goes down sometimes on severian.dev because I use the server for other stuff. Here's a full list of existing sucker instances (thanks to those who signed up for it!):

If you're interested in hosting your own sucker instance, lmk via Discord: @lyseverian, I've made the GH repo private for now. Or send me a message if there's anything you think that could be added here, open to suggestions.

{/* Changelog Dialog */} Changelog Recent updates and changes to Sucker

Jan 2026 - Direct image input

You can now paste webp filenames (like id.webp) or full image URLs directly into the avatar field without having to open the metadata tab first.

Makes grabbing avatars way faster when you already know the image path.

You should also consider joining our new thing, a Discord community server for botmakers:{" "} discord.gg/5jQKkCfHP3

Dec 2025 - A note about fetching avatars

The platform you suck from has implemented limited visibility of metadata for certain content with a particular 'obscenity rating'. This means that in some cases, the Fetch Avatar flow here will show a 404 - character not found error at the end.

Sometimes (but not always), the avatar URL can still be fetched after a day or two since the bot was published.

As of this moment, can't really find a fix for it, so you'll have to download the image yourself and just add the image to the card someplace else.

Oct 2025 - V2 charcard format, multi-turn support

Sucker now tracks changes to character descriptions and scenarios across multiple messages. Cards with multiple versions show a version badge and offer a "Download Changes" button to get the change history.

Alternate greetings are also supported. Sucker will provide you with a conversation ID that you can use to start off a new chat when capturing alternate greetings, send it as first message instead of the character name.

Directions are updated below. Make sure you read 'em.

{isMetadataOpen ? "Enter Page Source Code" : "Fetch Metadata"} {isMetadataOpen ? "Paste the entire source code here." : "Enter a character URL (janitorai.com/characters/...) to open page source."} {isMetadataOpen ? (
) => setPageSource(e.target.value)} />
) : (
) => setCharacterUrl(e.target.value)} />
)}
Change History: {selectedChanges?.cardName} Version history showing changes to description and scenario fields {selectedChanges && (
Total Versions:{" "} {selectedChanges.totalVersions}
Current Version:{" "} {selectedChanges.currentVersion}
Description Changes:{" "} {selectedChanges.summary.descriptionChanges}
Scenario Changes:{" "} {selectedChanges.summary.scenarioChanges}

Version History

{selectedChanges.versions.map((version: any, index: number) => (

Version {version.version} ({version.changeType})

{new Date(version.timestamp).toLocaleString()} {version.messageCount && ` • Message ${version.messageCount}`}
{version.changes.description && (
Description Change:
{version.changeType === "initial" ? (
Initial Content:{" "} {version.changes.description.new}
) : (
{version.addedText?.description && (
Added:{" "} {version.addedText.description}
)} {version.removedText?.description && (
Removed:{" "} {version.removedText.description}
)} {showFullText && (
Full Old:{" "} {version.changes.description.old}
Full New:{" "} {version.changes.description.new}
)}
)}
)} {version.changes.scenario && (
Scenario Change:
{version.changeType === "initial" ? (
Initial Content:{" "} {version.changes.scenario.new}
) : (
{version.addedText?.scenario && (
Added:{" "} {version.addedText.scenario}
)} {version.removedText?.scenario && (
Removed:{" "} {version.removedText.scenario}
)} {showFullText && (
Full Old:{" "} {version.changes.scenario.old}
Full New:{" "} {version.changes.scenario.new}
)}
)}
)}
))}
)}
); }