5 Commits

Author SHA1 Message Date
Severian 225c2e3c4f chore: 2.3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvRuentLsG1UGpy9gmNWvR
2026-08-04 01:22:09 +08:00
Iseult a66a46fc4a fix: revise dingus error message to surface context size issue; update changelog 2026-03-29 06:00:21 +08:00
Iseult d42d13e7f0 Add ladyofpa.in mirror, update Avalon banner copy, bump member count to 295 2026-02-28 20:13:34 +08:00
Severian b252df5591 Merge pull request #9 from leri-a/master
Adding restart line to docker-compose.
2026-02-04 14:07:01 +08:00
Ema P. 75a16b6ece Adding restart line to docker-compose. 2026-02-03 15:48:24 -05:00
7 changed files with 952 additions and 879 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
# Disable Discord community banner (optional) # Disable Discord community banner (optional)
# Uncomment the line below to hide the Discord banner # Uncomment the line below to hide the Discord banner
NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true # NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true
-5
View File
@@ -1,9 +1,4 @@
.idea/
bun.lock
.env
node_modules node_modules
.next .next
.env.local .env.local
.env*.local .env*.local
.env
+1
View File
@@ -17,6 +17,7 @@ You can also build and run Sucker as a Docker container with `docker compose bui
### Changelog ### Changelog
- 2.3: ECHO. Put ECHO: anywhere in a message and sucker repeats whatever comes after it in the reply — for triggering char-side script and lorebook keywords.
- 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards - 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards
- 2.1: updated deps, note about image fetching, list of mirrors - 2.1: updated deps, note about image fetching, list of mirrors
- 2.0: from Tui: Multimessage support! Tracks changes to character descriptions and scenarios across multiple extractions. Shows version badges, message counts, and provides detailed change history viewer. - 2.0: from Tui: Multimessage support! Tracks changes to character descriptions and scenarios across multiple extractions. Shows version badges, message counts, and provides detailed change history viewer.
+1
View File
@@ -2,5 +2,6 @@ services:
web: web:
build: . build: .
image: sucker image: sucker
restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
+22 -1
View File
@@ -93,6 +93,15 @@ function parseConversationToken(content: string | undefined | null): string | nu
return null; return null;
} }
function parseEchoText(content: string | undefined | null): string | null {
if (!content) return null;
const match = content.match(/ECHO:\s*([\s\S]+)/i);
if (match && match[1].trim()) {
return match[1].trim();
}
return null;
}
function removePersonaTags(content: string): string { function removePersonaTags(content: string): string {
let result = content; let result = content;
const openingMatch = result.match(/<[^<>]+?\s*'s\s+Persona>/i); const openingMatch = result.match(/<[^<>]+?\s*'s\s+Persona>/i);
@@ -487,6 +496,18 @@ export async function POST(request: NextRequest) {
responseMessage = `New character "${extracted.data.name}" created (v1).${tokenNote}`; responseMessage = `New character "${extracted.data.name}" created (v1).${tokenNote}`;
} }
// Echo back anything the user sent after an "ECHO:" marker in their
// latest message, appended after the normal status message
const lastUserMessage =
[...body.messages]
.reverse()
.find((m: { role?: string }) => m?.role === "user") ??
body.messages[body.messages.length - 1];
const echoText = parseEchoText(lastUserMessage?.content);
if (echoText) {
responseMessage += `\n\n${echoText}`;
}
cleanupExpiredCards(); cleanupExpiredCards();
// Return SSE response if requested, otherwise JSON // Return SSE response if requested, otherwise JSON
@@ -528,7 +549,7 @@ export async function POST(request: NextRequest) {
console.error("Error processing request:", error); console.error("Error processing request:", error);
const errorMessage = const errorMessage =
"You dingus, read the directions on sucker before trying again."; "You dingus, read the directions on sucker before trying again. If you're trying to suck a bot with more tokens than your current context limit, try bumping your Context Size setting up on Generation Settings.";
// Check if this was a streaming request // Check if this was a streaming request
const acceptHeader = request.headers.get("accept"); const acceptHeader = request.headers.get("accept");
+122 -67
View File
@@ -89,9 +89,6 @@ export default function Home() {
"https://sucker.severian.dev/api/proxy" "https://sucker.severian.dev/api/proxy"
); );
const [pageSource, setPageSource] = useState("");
const [metadataJson, setMetadataJson] = useState<any>(null);
const fetchCards = async () => { const fetchCards = async () => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
@@ -236,15 +233,6 @@ export default function Home() {
const arrayBuffer = await pngBlob.arrayBuffer(); 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 // Use initial version for PNG embedding, or current version if no initial version available
const chosen = card.initialVersion || card.data; const chosen = card.initialVersion || card.data;
const pngData = { const pngData = {
@@ -256,13 +244,13 @@ export default function Home() {
personality: chosen.personality, personality: chosen.personality,
mes_example: chosen.mes_example, mes_example: chosen.mes_example,
scenario: chosen.scenario, scenario: chosen.scenario,
creator: creator, creator: (chosen as any).creator || "",
creator_notes: safeMetadata.description || (chosen as any).creator_notes || "", creator_notes: (chosen as any).creator_notes || "",
system_prompt: (chosen as any).system_prompt || "", system_prompt: (chosen as any).system_prompt || "",
post_history_instructions: post_history_instructions:
(chosen as any).post_history_instructions || "", (chosen as any).post_history_instructions || "",
tags: allTagsArray, tags: (chosen as any).tags || [],
character_version: safeMetadata.name || (chosen as any).character_version || "1", character_version: (chosen as any).character_version || "1",
extensions: (chosen as any).extensions || {}, extensions: (chosen as any).extensions || {},
}, },
spec: card.spec || "chara_card_v2", spec: card.spec || "chara_card_v2",
@@ -307,9 +295,39 @@ export default function Home() {
const isCharacterUrl = /janitorai\.com\/characters\//.test(characterUrl); const isCharacterUrl = /janitorai\.com\/characters\//.test(characterUrl);
if (isCharacterUrl) { if (isCharacterUrl) {
// Open html source, then show second input // Extract character ID and open metadata page, then show second input
window.open(`view-source:${characterUrl}`, "_blank"); 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); 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; return;
} }
}; };
@@ -318,42 +336,7 @@ export default function Home() {
if (selectedCardIndex === null) return; if (selectedCardIndex === null) return;
try { try {
const storeKey = "Sk--a:a-a--characterStore"; const avatarUrl = `https://ella.janitorai.com/bot-avatars/${avatarPath}`;
// 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]; const updatedCards = [...cards];
updatedCards[selectedCardIndex] = { updatedCards[selectedCardIndex] = {
...updatedCards[selectedCardIndex], ...updatedCards[selectedCardIndex],
@@ -388,9 +371,9 @@ export default function Home() {
{/* Row 1: Title on left, Hamburger on right */} {/* Row 1: Title on left, Hamburger on right */}
<div className="flex justify-between items-start mb-3"> <div className="flex justify-between items-start mb-3">
<div> <div>
<h1 className="text-2xl font-bold">Sucker v2.2</h1> <h1 className="text-2xl font-bold">Sucker v2.3</h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Consider joining Avalon! ECHO: Consider joining Avalon!
</p> </p>
</div> </div>
<DropdownMenu> <DropdownMenu>
@@ -426,9 +409,9 @@ export default function Home() {
{/* Desktop Layout */} {/* Desktop Layout */}
<div className="hidden sm:flex justify-between items-center mb-4"> <div className="hidden sm:flex justify-between items-center mb-4">
<div> <div>
<h1 className="text-3xl font-bold">Sucker v2.2</h1> <h1 className="text-3xl font-bold">Sucker v2.3</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Consider joining Avalon! ECHO: Consider joining Avalon!
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -467,7 +450,7 @@ export default function Home() {
<DiscordBannerPermanent <DiscordBannerPermanent
inviteLink="https://discord.gg/5jQKkCfHP3" inviteLink="https://discord.gg/5jQKkCfHP3"
serverName="Avalon" serverName="Avalon"
description="Looking for somewhere more... interesting? Avalon's an enchanted collective of botmakers who refuse to be boring 🌿 Come play with us~ (18+ only, darling)" 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" ctaText="Join Server"
/> />
)} )}
@@ -759,7 +742,7 @@ export default function Home() {
onClick={() => handleOpenDialog(index)} onClick={() => handleOpenDialog(index)}
variant="outline" variant="outline"
> >
Fetch Metadata Fetch Avatar (required for PNG)
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -767,7 +750,7 @@ export default function Home() {
variant="default" variant="default"
disabled={!card.avatarUrl} disabled={!card.avatarUrl}
> >
Download Complete Card Download PNG
</Button> </Button>
)} )}
</div> </div>
@@ -837,6 +820,14 @@ export default function Home() {
moment, but you can use the changes detected to modify the moment, but you can use the changes detected to modify the
character card yourself post-export. character card yourself post-export.
</li> </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> <li>
Download the JSON files or go through a little more effort to Download the JSON files or go through a little more effort to
get PNGs instead. get PNGs instead.
@@ -914,6 +905,16 @@ export default function Home() {
lemuria.dev lemuria.dev
</a> </a>
</li> </li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://milker.ladyofpa.in/"
target="_blank"
rel="noopener noreferrer"
>
ladyofpa.in
</a>
</li>
</ul> </ul>
<p className="text-sm text-muted-foreground pt-4"> <p className="text-sm text-muted-foreground pt-4">
If you&apos;re interested in hosting your own sucker instance, lmk If you&apos;re interested in hosting your own sucker instance, lmk
@@ -935,6 +936,42 @@ export default function Home() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-6"> <div className="space-y-6">
<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> <div>
<h3 className="font-semibold text-lg mb-2">Jan 2026 - Direct image input</h3> <h3 className="font-semibold text-lg mb-2">Jan 2026 - Direct image input</h3>
<div className="space-y-2 text-sm text-muted-foreground"> <div className="space-y-2 text-sm text-muted-foreground">
@@ -1010,25 +1047,43 @@ export default function Home() {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isMetadataOpen ? "Enter Page Source Code" : "Fetch Metadata"} {isMetadataOpen ? "Enter Avatar Path" : "Fetch Avatar"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{isMetadataOpen ? "Paste the entire source code here." : "Enter a character URL (janitorai.com/characters/...) to open page source."} {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> </DialogDescription>
</DialogHeader> </DialogHeader>
{isMetadataOpen ? ( {isMetadataOpen ? (
<div className="space-y-4"> <div className="space-y-4">
<Input placeholder="<!DOCTYPE html><html..." value={pageSource} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPageSource(e.target.value)} /> <Input
placeholder="id.webp"
value={avatarPath}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setAvatarPath(e.target.value)
}
/>
<Button onClick={handleFetchAvatar} className="w-full"> <Button onClick={handleFetchAvatar} className="w-full">
Fetch Metadata Fetch Avatar
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<Input placeholder="URL" value={characterUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCharacterUrl(e.target.value)} /> <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"> <Button onClick={handleOpenMetadata} className="w-full">
Fetch Metadata Fetch Avatar
</Button> </Button>
</div> </div>
)} )}
@@ -12,7 +12,7 @@ interface DiscordBannerPermanentProps {
export function DiscordBannerPermanent({ export function DiscordBannerPermanent({
inviteLink, inviteLink,
serverName = "Avalon", serverName = "Avalon",
description = "Looking for somewhere more... interesting? Avalon's an enchanted collective of botmakers who refuse to be boring 🌿 Come play with us~ (18+ only, darling)", 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", ctaText = "Join Server",
}: DiscordBannerPermanentProps) { }: DiscordBannerPermanentProps) {
return ( return (
@@ -69,7 +69,7 @@ export function DiscordBannerPermanent({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-white font-semibold text-base mb-0.5">{serverName}</div> <div className="text-white font-semibold text-base mb-0.5">{serverName}</div>
<div className="text-[#b9bbbe] text-sm space-y-0.5"> <div className="text-[#b9bbbe] text-sm space-y-0.5">
<div className="font-medium">67 members</div> <div className="font-medium">295 members</div>
<a <a
href="https://from-avalon.com" href="https://from-avalon.com"
target="_blank" target="_blank"