"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, Copy, ChevronLeft, ChevronRight, } from "lucide-react"; import { CollapsibleContent, Collapsible, CollapsibleTrigger, } from "@/components/ui/collapsible"; 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 [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 [changesDialogOpen, setChangesDialogOpen] = useState(false); const [selectedChanges, setSelectedChanges] = useState(null); const [showFullText, setShowFullText] = useState(false); const [altGreetingIndexById, setAltGreetingIndexById] = useState< Record >({}); 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 = () => { 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); } }; const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); }; return (

Sucker v2.0

A couple of updates, see below.

V2 charcard format, multi-turn support for scripts/lorebooks, alternate greetings.

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.
If you're interested in hosting your own sucker instance, lmk via Discord: @lyseverian, I've made the GH repo private for now.

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.

{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 ? ( ) : ( )}
)) )}
{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.

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