5 Commits

Author SHA1 Message Date
Eslezer 64013ea5ea Bump version to 2.4 and add CSS editor changelog entry
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 00:15:44 +10:00
Eslezer d7c8a9a008 Add CSS editor banner and update Avalon member count
- Bump Avalon member count to 885
- Add looping, muted video banner linking to the new Janitor profile CSS editor
- Desktop: show the Avalon and CSS banners side by side
- Mobile: show them in a carousel (random start, 8s auto-advance, arrows/dots)
- Paint video frames to a canvas so browsers like Opera GX don't overlay
  pop-out controls on the banner

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:51:12 +10:00
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
7 changed files with 330 additions and 14 deletions
+2
View File
@@ -17,6 +17,8 @@ You can also build and run Sucker as a Docker container with `docker compose bui
### Changelog ### Changelog
- 2.4: CSS editor for Janitor profiles. New banner links to the live-preview editor at tools.from-avalon.com/janitor-profile-maker.
- 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.
Binary file not shown.
+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");
+116 -10
View File
@@ -32,6 +32,8 @@ import {
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { DiscordBannerPermanent } from "@/components/ui/discord-banner-permanent"; 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"; import Script from "next/script";
interface CardDataV2 { interface CardDataV2 {
@@ -371,9 +373,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.4</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>
@@ -409,9 +411,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.4</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">
@@ -447,12 +449,35 @@ export default function Home() {
{/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */} {/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */}
{process.env.NEXT_PUBLIC_DISABLE_DISCORD_BANNER !== "true" && ( {process.env.NEXT_PUBLIC_DISABLE_DISCORD_BANNER !== "true" && (
<DiscordBannerPermanent <>
inviteLink="https://discord.gg/5jQKkCfHP3" {/* Desktop: banners side by side */}
serverName="Avalon" <div className="hidden md:flex gap-4 mb-4 items-stretch">
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)" <DiscordBannerPermanent
ctaText="Join Server" 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"> <div className="max-w-3xl mx-auto space-y-6">
@@ -820,6 +845,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.
@@ -897,6 +930,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
@@ -918,6 +961,69 @@ 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">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> <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">
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useState, useEffect, ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface BannerCarouselProps {
slides: ReactNode[];
intervalMs?: number;
className?: string;
}
export function BannerCarousel({
slides,
intervalMs = 8000,
className = "",
}: BannerCarouselProps) {
const [index, setIndex] = useState(0);
// Pick a random starting slide on the client to avoid hydration mismatch
useEffect(() => {
setIndex(Math.floor(Math.random() * slides.length));
}, [slides.length]);
// Re-keyed on index so a manual change restarts the countdown
useEffect(() => {
const id = setTimeout(
() => setIndex((i) => (i + 1) % slides.length),
intervalMs
);
return () => clearTimeout(id);
}, [index, intervalMs, slides.length]);
const go = (delta: number) =>
setIndex((i) => (i + delta + slides.length) % slides.length);
return (
<div className={`relative ${className}`}>
{/* Slides stacked in one grid cell so the container fits the tallest */}
<div className="grid">
{slides.map((slide, i) => (
<div
key={i}
className={`[grid-area:1/1] transition-opacity duration-500 ${
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
aria-hidden={i !== index}
>
{slide}
</div>
))}
</div>
<button
type="button"
onClick={() => go(-1)}
aria-label="Previous banner"
className="absolute left-1.5 top-1/2 -translate-y-1/2 rounded-full bg-black/50 hover:bg-black/70 text-white p-1 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => go(1)}
aria-label="Next banner"
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full bg-black/50 hover:bg-black/70 text-white p-1 transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5">
{slides.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setIndex(i)}
aria-label={`Show banner ${i + 1}`}
className={`h-1.5 rounded-full transition-all ${
i === index ? "w-4 bg-white" : "w-1.5 bg-white/50"
}`}
/>
))}
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useRef } from "react";
interface CssEditorBannerProps {
href?: string;
className?: string;
/** "contain" letterboxes the video so its caption is never cropped */
fit?: "cover" | "contain";
}
export function CssEditorBanner({
href = "https://tools.from-avalon.com/janitor-profile-maker/",
className = "",
fit = "cover",
}: CssEditorBannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Browsers like Opera GX overlay pop-out controls on large <video> elements.
// The real video stays 1px and invisible; its frames are painted onto a canvas.
useEffect(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!video || !canvas || !ctx) return;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(canvas.clientWidth * dpr);
canvas.height = Math.round(canvas.clientHeight * dpr);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
let frame = 0;
const draw = () => {
const vw = video.videoWidth;
const vh = video.videoHeight;
const cw = canvas.width;
const ch = canvas.height;
if (vw && vh && cw && ch) {
const scale =
fit === "contain"
? Math.min(cw / vw, ch / vh)
: Math.max(cw / vw, ch / vh);
const w = vw * scale;
const h = vh * scale;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(video, (cw - w) / 2, (ch - h) / 2, w, h);
}
frame = requestAnimationFrame(draw);
};
frame = requestAnimationFrame(draw);
// Browsers may pause muted videos they consider hidden; keep it looping.
const resume = () => {
if (video.paused && document.visibilityState === "visible") {
video.play().catch(() => {});
}
};
resume();
video.addEventListener("pause", resume);
document.addEventListener("visibilitychange", resume);
const keepAlive = setInterval(resume, 1000);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
clearInterval(keepAlive);
video.removeEventListener("pause", resume);
document.removeEventListener("visibilitychange", resume);
};
}, [fit]);
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label="Open the Janitor profile CSS editor"
className={`block relative rounded-lg overflow-hidden shadow-lg bg-black ${className}`}
>
<video
ref={videoRef}
src="/avalon/css-banner.mp4"
autoPlay
loop
muted
playsInline
disablePictureInPicture
preload="auto"
aria-hidden
className="absolute top-0 left-0 w-px h-px opacity-[0.01] pointer-events-none"
/>
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
</a>
);
}
@@ -7,16 +7,18 @@ interface DiscordBannerPermanentProps {
serverName?: string; serverName?: string;
description?: string; description?: string;
ctaText?: string; ctaText?: string;
className?: string;
} }
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",
className = "mb-4",
}: DiscordBannerPermanentProps) { }: DiscordBannerPermanentProps) {
return ( return (
<div className="relative rounded-lg overflow-hidden shadow-lg mb-4 bg-[#36393f]"> <div className={`relative rounded-lg overflow-hidden shadow-lg bg-[#36393f] ${className}`}>
{/* Background Image */} {/* Background Image */}
<div className="absolute inset-0"> <div className="absolute inset-0">
<Image <Image
@@ -69,7 +71,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">885 members</div>
<a <a
href="https://from-avalon.com" href="https://from-avalon.com"
target="_blank" target="_blank"