"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 } 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; hasVersions?: boolean; versionCount?: number; messageCount?: number; initialVersion?: { name: string; first_mes: string; description: string; personality: string; mes_example: string; scenario: 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 [changesDialogOpen, setChangesDialogOpen] = useState(false); const [selectedChanges, setSelectedChanges] = useState(null); const [showFullText, setShowFullText] = 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) => { // Use initial version for download, or current version if no initial version available const downloadData = card.initialVersion ? { name: card.initialVersion.name, first_mes: card.initialVersion.first_mes, description: card.initialVersion.description, personality: card.initialVersion.personality, mes_example: card.initialVersion.mes_example, scenario: card.initialVersion.scenario, } : { name: card.name, first_mes: card.first_mes, description: card.description, personality: card.personality, mes_example: card.mes_example, scenario: card.scenario, }; 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.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.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 pngData = card.initialVersion ? { name: card.initialVersion.name, first_mes: card.initialVersion.first_mes, description: card.initialVersion.description, personality: card.initialVersion.personality, mes_example: card.initialVersion.mes_example, scenario: card.initialVersion.scenario, } : { name: card.name, first_mes: card.first_mes, description: card.description, personality: card.personality, mes_example: card.mes_example, scenario: card.scenario, }; const cardData = JSON.stringify(pngData); const newImageData = Png.Generate(arrayBuffer, cardData); const newFileName = `${ card.name.replace(/[^a-zA-Z0-9\-_]/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); } }; const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); }; return (

Sucker v2.0

Now with multimessage support! Tracks changes to character descriptions and scenarios across multiple extractions.

New: Multimessage 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.

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. 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 or multiple.
  6. You can either send a dot to let sucker make a best guess about the char name, or send the char name yourself and it'll be used instead.
  7. Hit the Refresh button here, and the cards should appear here.
  8. 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.

New: If you send multiple messages with the same character name, 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.

{cards.length === 0 ? (

No extractions yet.

) : ( cards.map((card, index) => (
{card.name || "Unnamed Card"}
{card.hasVersions && ( v{card.versionCount} )} {card.messageCount && card.messageCount > 1 && ( {card.messageCount} msgs )}
{(card.initialVersion?.description || card.description) && ( Description
{card.initialVersion?.description || card.description}
)} {(card.initialVersion?.first_mes || card.first_mes) && ( First Message
{card.initialVersion?.first_mes || card.first_mes}
)} {(card.initialVersion?.scenario || card.scenario) && ( Scenario
{card.initialVersion?.scenario || card.scenario}
)} {(card.initialVersion?.mes_example || card.mes_example) && ( Example Messages
{card.initialVersion?.mes_example || card.mes_example}
)} {(card.initialVersion?.personality || card.personality) && ( Personality
{card.initialVersion?.personality || card.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}
)}
)}
)}
))}
)}
); }