"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 { CssEditorBanner } from "@/components/ui/css-editor-banner"; import { BannerCarousel } from "@/components/ui/banner-carousel"; 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; } /** Bumped once per release; the changelog dot keys off it. */ const APP_VERSION = "2.5"; const CHANGELOG_SEEN_KEY = "sucker:changelog-seen"; export default function Home() { const [cards, setCards] = useState([]); // The changelog dot. Starts hidden so the server and the first client render // agree; the effect below turns it on for anyone who has not opened the // dialog since this version shipped. const [changelogSeen, setChangelogSeen] = useState(true); const [dialogOpen, setDialogOpen] = useState(false); useEffect(() => { try { setChangelogSeen(localStorage.getItem(CHANGELOG_SEEN_KEY) === APP_VERSION); } catch { // Private mode or blocked storage: just leave the dot off. } }, []); /** Open the changelog and remember that this version has been read. */ const openChangelog = () => { setChangelogDialogOpen(true); setChangelogSeen(true); try { localStorage.setItem(CHANGELOG_SEEN_KEY, APP_VERSION); } catch { // Nothing to do; the dot simply comes back next time. } }; 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 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(); // 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: (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 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) { // Extract character ID and open metadata page, then show second input 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); } return; } // Check if the input is a direct image link (webp filename or full image URL) const isImagePath = /\.(webp|png|jpg|jpeg|gif)(\?.*)?$/i.test(characterUrl); const isFullImageUrl = (characterUrl.startsWith("http://") || characterUrl.startsWith("https://")) && isImagePath; const isWebpFilename = /^[\w-]+\.(webp|png|jpg|jpeg|gif)$/i.test(characterUrl); if (isFullImageUrl || isWebpFilename) { // Directly set the avatar URL without opening metadata if (selectedCardIndex === null) return; const avatarUrl = isFullImageUrl ? characterUrl : `https://ella.janitorai.com/bot-avatars/${characterUrl}`; const updatedCards = [...cards]; updatedCards[selectedCardIndex] = { ...updatedCards[selectedCardIndex], avatarUrl, }; setCards(updatedCards); setDialogOpen(false); return; } }; 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); } }; const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); }; return (
{/* Mobile Layout */}
{/* Row 1: Title on left, Hamburger on right */}

Sucker v{APP_VERSION}

New: a live CSS editor for your profile.

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

Sucker v{APP_VERSION}

New: a live CSS editor for your profile.

{/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */} {process.env.NEXT_PUBLIC_DISABLE_DISCORD_BANNER !== "true" && ( <> {/* Desktop: banners side by side */}
{/* Mobile: carousel */} , , ]} /> )}
{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. Some advanced scripts and lorebooks only add their entries when the character says the keyword, not you. Put{" "} ECHO:{" "} in a message and everything after it gets repeated at the end of the reply — instant character-side keywords, and sucker tracks whatever gets added.
  11. 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

Sep 2026 - Fixed the dingus error some of you always got

If sucker told you to read the directions no matter what you did, it wasn't you. Janitor doesn't send the same request to everyone: some accounts include an extra blank turn before the bot's first message and some don't. Sucker was counting positions in that request instead of reading what each part was, so for anyone on the shorter shape it read the wrong pieces and fell over.

It now finds each part by what it is. Both shapes work, and longer conversations stay linked to the right card. If sucker never worked for you, give it another go.

The Changelog button also carries a dot now when there is something here you haven't read.

Sep 2026 - CSS Editor for Janitor profiles

We'd like to introduce a CSS editor we have been working on for Janitor profiles. It comes loaded with some simple presets, and we plan to include more in the future, be it ours or provided by the community. You can see changes occur in live time and it should accurately represent the behaviour of the CSS if used in your Janitor profile.

Check it out at{" "} tools.from-avalon.com/janitor-profile-maker {" "} and DM eslezer on Discord if you have any bug or suggestion for the editor.

Aug 2026 - ECHO

Sucker can parrot now. Send a message with ECHO: in it, and everything after that gets repeated back at the end of the reply, exactly as you typed it.

The usual responses don't change at all — the echoed part just tags along after them. The point: some of the fancier scripts and lorebooks on the platform you suck from only add their entries when the character says the keyword, not you. ECHO plants your words on the character's side of the chat, those entries fire, and sucker catches whatever they sneak in. Case doesn't matter, echo: works too.

Mar 2026 - Better error messaging for context size issues

Identified an issue where attempting to pull bots with token counts greater than your configured context size would result in a dingus error with no useful explanation.

Revised the error message to surface this more accurately. It now tells you to bump your Context Size in Generation Settings if you suspect this is the cause.

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 Avatar Path" : "Fetch Avatar"} {isMetadataOpen ? "Look for the avatar field in the opened tab and paste the value here." : "Enter a character URL (janitorai.com/characters/...) to open metadata, or paste an image filename (id.webp) or full image URL directly."} {isMetadataOpen ? (
) => setAvatarPath(e.target.value) } />
) : (
) => setCharacterUrl(e.target.value) } />

For character URLs, a new tab will open with metadata. For image filenames or full image URLs, the avatar will be set directly.

)}
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}
)}
)}
)}
))}
)}
); }