Files
sucker.severian.dev/src/app/page.tsx
T
Severian 7677a26f1a Mention the changelog dot in the changelog
The previous commit's edit to the dialog missed its anchor and applied
nowhere, so the entry never mentioned the dot it was describing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 05:07:53 +08:00

1396 lines
59 KiB
TypeScript

"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<string, unknown>;
}
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<Card[]>([]);
// 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<number | null>(
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<any>(null);
const [showFullText, setShowFullText] = useState(false);
const [altGreetingIndexById, setAltGreetingIndexById] = useState<
Record<string, number>
>({});
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<Blob>((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 (
<main className="min-h-screen bg-background text-foreground">
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-YVD6QFSR71"
strategy="afterInteractive"
/>
<Script id="gtag-init" strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-YVD6QFSR71');`}
</Script>
<div className="container mx-auto px-4 py-8">
{/* Mobile Layout */}
<div className="sm:hidden mb-4">
{/* Row 1: Title on left, Hamburger on right */}
<div className="flex justify-between items-start mb-3">
<div>
<h1 className="text-2xl font-bold">Sucker v{APP_VERSION}</h1>
<p className="text-xs text-muted-foreground">
New: a live CSS editor for your profile.
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="relative">
<Menu className="h-4 w-4" />
{!changelogSeen && (
<span
aria-label="New changelog entry"
className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-sky-500 ring-2 ring-background"
/>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setHowToUseDialogOpen(true)}>
How to Use
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMirrorsDialogOpen(true)}>
Mirrors
</DropdownMenuItem>
<DropdownMenuItem onClick={openChangelog}>
Changelog
{!changelogSeen && (
<span className="ml-2 h-2 w-2 rounded-full bg-sky-500" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Row 2: Full-width Refresh button */}
<Button
onClick={fetchCards}
variant="outline"
disabled={isRefreshing}
className="w-full"
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
</div>
{/* Desktop Layout */}
<div className="hidden sm:flex justify-between items-center mb-4">
<div>
<h1 className="text-3xl font-bold">Sucker v{APP_VERSION}</h1>
<p className="text-sm text-muted-foreground">
New: a live CSS editor for your profile.
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => setHowToUseDialogOpen(true)}
variant="outline"
>
How to Use
</Button>
<Button
onClick={() => setMirrorsDialogOpen(true)}
variant="outline"
>
Mirrors
</Button>
<Button
onClick={openChangelog}
variant="outline"
className="relative"
>
Changelog
{!changelogSeen && (
<span
aria-label="New changelog entry"
className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-sky-500 ring-2 ring-background"
/>
)}
</Button>
<Button
onClick={fetchCards}
variant="outline"
disabled={isRefreshing}
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
</div>
</div>
<Separator className="my-4" />
{/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */}
{process.env.NEXT_PUBLIC_DISABLE_DISCORD_BANNER !== "true" && (
<>
{/* Desktop: banners side by side */}
<div className="hidden md:flex gap-4 mb-4 items-stretch">
<DiscordBannerPermanent
inviteLink="https://discord.gg/5jQKkCfHP3"
serverName="Avalon"
description="Avalon is an enchanted creative haven for a growing collective of botmakers and users—part workshop, part myth, part digital forest where strange and beautiful ideas come to life. Enter our little woodland here:"
ctaText="Join Server"
className="flex-1 min-w-0"
/>
<CssEditorBanner className="flex-shrink-0 aspect-[16/10] w-[300px] lg:w-[360px] xl:w-[400px]" />
</div>
{/* Mobile: carousel */}
<BannerCarousel
className="md:hidden mb-4"
slides={[
<DiscordBannerPermanent
key="avalon"
inviteLink="https://discord.gg/5jQKkCfHP3"
serverName="Avalon"
description="Avalon is an enchanted creative haven for a growing collective of botmakers and users—part workshop, part myth, part digital forest where strange and beautiful ideas come to life. Enter our little woodland here:"
ctaText="Join Server"
className="h-full"
/>,
<CssEditorBanner key="css" className="h-full" fit="contain" />,
]}
/>
</>
)}
<div className="max-w-3xl mx-auto space-y-6">
{cards.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
<p>No extractions yet.</p>
</CardContent>
</Card>
) : (
cards.map((card, index) => (
<Card key={index}>
<CardContent className="pt-6">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value={`card-${index}`}>
<AccordionTrigger className="text-xl font-semibold">
<div className="flex items-center gap-2">
{card.initialVersion?.name ||
card.data?.name ||
"Unnamed Card"}
<div className="flex gap-1">
{card.hasVersions && (
<span className="text-sm bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded-full">
v{card.versionCount}
</span>
)}
{card.messageCount && card.messageCount > 1 && (
<span className="text-sm bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded-full">
{card.messageCount} msgs
</span>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div id={`card-${index}`} className="space-y-4 mt-4">
{(card.initialVersion?.description ||
card.data?.description) && (
<Accordion type="single" collapsible>
<AccordionItem value="description">
<AccordionTrigger>Description</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.description ||
card.data.description}
</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.description ||
card.data.description
);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
{(card.initialVersion?.first_mes ||
card.data?.first_mes) && (
<Accordion type="single" collapsible>
<AccordionItem value="first-message">
<AccordionTrigger>
First Message
</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.first_mes ||
card.data.first_mes}
</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.first_mes ||
card.data.first_mes
);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
{card.alternate_greetings &&
card.alternate_greetings.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{`Alternate Greetings (${card.alternate_greetings?.length || 0
})`}</h4>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => {
const greetings =
card.alternate_greetings || [];
if (greetings.length === 0) return;
setAltGreetingIndexById((prev) => {
const current = prev[card.id] ?? 0;
const next =
(current - 1 + greetings.length) %
greetings.length;
return { ...prev, [card.id]: next };
});
}}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
const greetings =
card.alternate_greetings || [];
if (greetings.length === 0) return;
setAltGreetingIndexById((prev) => {
const current = prev[card.id] ?? 0;
const next =
(current + 1) % greetings.length;
return { ...prev, [card.id]: next };
});
}}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{(() => {
const greetings =
card.alternate_greetings || [];
const index =
altGreetingIndexById[card.id] ?? 0;
const current = greetings.length
? greetings[index % greetings.length]
: "";
return (
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{current}
</pre>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (!current) return;
copyToClipboard(current);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
);
})()}
</div>
)}
{(card.initialVersion?.scenario ||
card.data?.scenario) && (
<Accordion type="single" collapsible>
<AccordionItem value="scenario">
<AccordionTrigger>Scenario</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.scenario ||
card.data.scenario}
</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.scenario ||
card.data.scenario
);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
{(card.initialVersion?.mes_example ||
card.data?.mes_example) && (
<Accordion type="single" collapsible>
<AccordionItem value="example-messages">
<AccordionTrigger>
Example Messages
</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.mes_example ||
card.data.mes_example}
</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.mes_example ||
card.data.mes_example
);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
{(card.initialVersion?.personality ||
card.data?.personality) && (
<Accordion type="single" collapsible>
<AccordionItem value="personality">
<AccordionTrigger>Personality</AccordionTrigger>
<AccordionContent>
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.personality ||
card.data.personality}
</pre>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.personality ||
card.data.personality
);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex flex-row space-x-2 mt-6">
<Button
onClick={() => downloadJson(card)}
variant="default"
>
Download JSON
</Button>
{card.hasVersions && (
<>
<Button
onClick={() => viewChanges(card)}
variant="outline"
>
View Changes
</Button>
<Button
onClick={() => downloadChanges(card)}
variant="outline"
>
Download Changes
</Button>
</>
)}
{!card.avatarUrl ? (
<Button
onClick={() => handleOpenDialog(index)}
variant="outline"
>
Fetch Avatar (required for PNG)
</Button>
) : (
<Button
onClick={() => downloadPng(card, `card-${index}`)}
variant="default"
disabled={!card.avatarUrl}
>
Download PNG
</Button>
)}
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
{/* How to Use Dialog */}
<Dialog open={howToUseDialogOpen} onOpenChange={setHowToUseDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>How to Use</DialogTitle>
<DialogDescription>
Follow every instruction here to the letter because it&apos;s all you
need to know and I have no intent of helping you further.
</DialogDescription>
</DialogHeader>
<div className="prose dark:prose-invert max-w-none">
<ol className="list-decimal list-inside space-y-3 text-sm">
<li>
Put <code className="bg-muted px-1 py-0.5 rounded text-yellow-600">{proxyUrl}</code> in
your API settings, any value for model and key.
</li>
<li>
REQUIRED: Set your custom prompt to{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">&lt;.&gt;</code>
</li>
<li>
REQUIRED: Set your persona (or create a new one) with the name{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">&#123;user&#125;</code> and
the description should only have{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">.</code> in it.
</li>
<li>
Save settings and refresh the page. Not this page. <i>That</i>{" "}
page.
</li>
<li>Start a new chat with a character.</li>
<li>
Char name inference is implemented: if you send just a dot:{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">.</code>, sucker will use
the inferred name from the persona tag, or you can send the
character name yourself.
</li>
<li>
Hit the Refresh button here, and the cards should appear here.
</li>
<li>
If you&apos;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{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">
[sucker:conv=conversationId]
</code>{" "}
which you&apos;ll be given when creating a new card.
</li>
<li>
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 &quot;Download Changes&quot;
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.
</li>
<li>
Some advanced scripts and lorebooks only add their entries
when the <i>character</i> says the keyword, not you. Put{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">ECHO:</code>{" "}
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.
</li>
<li>
Download the JSON files or go through a little more effort to
get PNGs instead.
</li>
</ol>
<p className="mt-4 text-sm text-muted-foreground">
Extractions will only last for 10 minutes, after which they&apos;re
discarded. Reloading the page will remove any attached avatars.
I&apos;m not storing shit.
</p>
</div>
</DialogContent>
</Dialog>
{/* Mirrors Dialog */}
<Dialog open={mirrorsDialogOpen} onOpenChange={setMirrorsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sucker Mirrors</DialogTitle>
<DialogDescription>
Sucker goes down sometimes on severian.dev because I use the
server for other stuff. Here&apos;s a full list of existing sucker
instances (thanks to those who signed up for it!):
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<ul className="text-sm flex flex-col gap-2">
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.severian.dev"
target="_blank"
rel="noopener noreferrer"
>
severian.dev
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.trashpanda.land"
target="_blank"
rel="noopener noreferrer"
>
trashpanda.land
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.hitani.me"
target="_blank"
rel="noopener noreferrer"
>
hitani.me
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://succ.portalnexus.link"
target="_blank"
rel="noopener noreferrer"
>
portalnexus.link
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.lemuria.dev"
target="_blank"
rel="noopener noreferrer"
>
lemuria.dev
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://milker.ladyofpa.in/"
target="_blank"
rel="noopener noreferrer"
>
ladyofpa.in
</a>
</li>
</ul>
<p className="text-sm text-muted-foreground pt-4">
If you&apos;re interested in hosting your own sucker instance, lmk
via Discord: @lyseverian, I&apos;ve made the GH repo private for
now. Or send me a message if there&apos;s anything you think that
could be added here, open to suggestions.
</p>
</div>
</DialogContent>
</Dialog>
{/* Changelog Dialog */}
<Dialog open={changelogDialogOpen} onOpenChange={setChangelogDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Changelog</DialogTitle>
<DialogDescription>
Recent updates and changes to Sucker
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div>
<h3 className="font-semibold text-lg mb-2">Sep 2026 - Fixed the dingus error some of you always got</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
If sucker told you to read the directions no matter what you
did, it wasn&apos;t you. Janitor doesn&apos;t send the same
request to everyone: some accounts include an extra blank turn
before the bot&apos;s first message and some don&apos;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.
</p>
<p>
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.
</p>
<p>
The Changelog button also carries a dot now when there is
something here you haven&apos;t read.
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">Sep 2026 - CSS Editor for Janitor profiles</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
We&apos;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.
</p>
<p>
Check it out at{" "}
<a
href="https://tools.from-avalon.com/janitor-profile-maker/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
tools.from-avalon.com/janitor-profile-maker
</a>{" "}
and DM eslezer on Discord if you have any bug or suggestion
for the editor.
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">Aug 2026 - ECHO</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
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.
</p>
<p>
The usual responses don&apos;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&apos;s side of the
chat, those entries fire, and sucker catches whatever they
sneak in. Case doesn&apos;t matter, echo: works too.
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">Mar 2026 - Better error messaging for context size issues</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
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.
</p>
<p>
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.
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">Jan 2026 - Direct image input</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
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.
</p>
<p>
Makes grabbing avatars way faster when you already know the image path.
</p>
<p>
You should also consider joining our new thing, a Discord community server for botmakers:{" "}
<a
href="https://discord.gg/5jQKkCfHP3"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
discord.gg/5jQKkCfHP3
</a>
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">Dec 2025 - A note about fetching avatars</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
The platform you suck from has implemented limited visibility
of metadata for certain content with a particular &apos;obscenity
rating&apos;. This means that in some cases, the Fetch Avatar flow
here will show a 404 - character not found error at the end.
</p>
<p>
Sometimes (but not always), the avatar URL can still be
fetched after a day or two since the bot was published.
</p>
<p>
As of this moment, can&apos;t really find a fix for it, so you&apos;ll
have to download the image yourself and just add the image to
the card someplace else.
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">
Oct 2025 - V2 charcard format, multi-turn support
</h3>
<div className="text-sm text-muted-foreground space-y-2">
<p>
Sucker now tracks changes to character descriptions and
scenarios across multiple messages. Cards with multiple versions
show a version badge and offer a &quot;Download Changes&quot; button to
get the change history.
</p>
<p>
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.
</p>
<p>
Directions are updated below. Make sure you read &apos;em.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isMetadataOpen ? "Enter Avatar Path" : "Fetch Avatar"}
</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>
{isMetadataOpen ? (
<div className="space-y-4">
<Input
placeholder="id.webp"
value={avatarPath}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAvatarPath(e.target.value)
}
/>
<Button onClick={handleFetchAvatar} className="w-full">
Fetch Avatar
</Button>
</div>
) : (
<div className="space-y-4">
<Input
placeholder="URL or id.webp"
value={characterUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCharacterUrl(e.target.value)
}
/>
<p className="text-sm text-muted-foreground">
For character URLs, a new tab will open with metadata. For image
filenames or full image URLs, the avatar will be set directly.
</p>
<Button onClick={handleOpenMetadata} className="w-full">
Fetch Avatar
</Button>
</div>
)}
</DialogContent>
</Dialog>
<Dialog open={changesDialogOpen} onOpenChange={setChangesDialogOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Change History: {selectedChanges?.cardName}
</DialogTitle>
<DialogDescription className="flex items-center justify-between">
<span>
Version history showing changes to description and scenario
fields
</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowFullText(!showFullText)}
>
{showFullText ? "Show Changes Only" : "Show Full Text"}
</Button>
</DialogDescription>
</DialogHeader>
{selectedChanges && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<strong>Total Versions:</strong>{" "}
{selectedChanges.totalVersions}
</div>
<div>
<strong>Current Version:</strong>{" "}
{selectedChanges.currentVersion}
</div>
<div>
<strong>Description Changes:</strong>{" "}
{selectedChanges.summary.descriptionChanges}
</div>
<div>
<strong>Scenario Changes:</strong>{" "}
{selectedChanges.summary.scenarioChanges}
</div>
</div>
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-semibold">Version History</h3>
{selectedChanges.versions.map((version: any, index: number) => (
<div key={version.version} className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h4 className="font-semibold">
Version {version.version} ({version.changeType})
</h4>
<div className="text-sm text-muted-foreground">
{new Date(version.timestamp).toLocaleString()}
{version.messageCount &&
` • Message ${version.messageCount}`}
</div>
</div>
{version.changes.description && (
<div className="mb-3">
<h5 className="font-medium text-sm mb-1">
Description Change:
</h5>
{version.changeType === "initial" ? (
<div className="bg-blue-50 dark:bg-blue-950 p-2 rounded text-sm">
<strong>Initial Content:</strong>{" "}
{version.changes.description.new}
</div>
) : (
<div className="space-y-2">
{version.addedText?.description && (
<div className="bg-green-50 dark:bg-green-950 p-2 rounded text-sm">
<div className="flex justify-between items-start">
<div>
<strong>Added:</strong>{" "}
{version.addedText.description}
</div>
<Button
variant="ghost"
size="icon"
className="ml-2 h-6 w-6"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(
version.addedText.description
);
}}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{version.removedText?.description && (
<div className="bg-red-50 dark:bg-red-950 p-2 rounded text-sm">
<strong>Removed:</strong>{" "}
{version.removedText.description}
</div>
)}
{showFullText && (
<div className="space-y-1 mt-2 pt-2 border-t">
<div className="bg-gray-50 dark:bg-gray-950 p-2 rounded text-xs">
<strong>Full Old:</strong>{" "}
{version.changes.description.old}
</div>
<div className="bg-gray-50 dark:bg-gray-950 p-2 rounded text-xs">
<strong>Full New:</strong>{" "}
{version.changes.description.new}
</div>
</div>
)}
</div>
)}
</div>
)}
{version.changes.scenario && (
<div>
<h5 className="font-medium text-sm mb-1">
Scenario Change:
</h5>
{version.changeType === "initial" ? (
<div className="bg-blue-50 dark:bg-blue-950 p-2 rounded text-sm">
<strong>Initial Content:</strong>{" "}
{version.changes.scenario.new}
</div>
) : (
<div className="space-y-2">
{version.addedText?.scenario && (
<div className="bg-green-50 dark:bg-green-950 p-2 rounded text-sm">
<div className="flex justify-between items-start">
<div>
<strong>Added:</strong>{" "}
{version.addedText.scenario}
</div>
<Button
variant="ghost"
size="icon"
className="ml-2 h-6 w-6"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(
version.addedText.scenario
);
}}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
{version.removedText?.scenario && (
<div className="bg-red-50 dark:bg-red-950 p-2 rounded text-sm">
<strong>Removed:</strong>{" "}
{version.removedText.scenario}
</div>
)}
{showFullText && (
<div className="space-y-1 mt-2 pt-2 border-t">
<div className="bg-gray-50 dark:bg-gray-950 p-2 rounded text-xs">
<strong>Full Old:</strong>{" "}
{version.changes.scenario.old}
</div>
<div className="bg-gray-50 dark:bg-gray-950 p-2 rounded text-xs">
<strong>Full New:</strong>{" "}
{version.changes.scenario.new}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
</main>
);
}