1 Commits

Author SHA1 Message Date
SoniaNvm 3bc20ea40d attempt at metadata grabbing 2026-02-02 02:27:09 -08:00
15 changed files with 903 additions and 1853 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,4 +1,9 @@
.idea/
bun.lock
.env
node_modules node_modules
.next .next
.env.local .env.local
.env*.local .env*.local
.env
-3
View File
@@ -17,9 +17,6 @@ You can also build and run Sucker as a Docker container with `docker compose bui
### Changelog ### Changelog
- 2.5: Real error messages instead of one blanket "dingus" reply; unexplained failures now ask you to DM @lyseverian. Unread dot on the Changelog button. Fixed extraction for users whose Janitor sends a request without the blank turn before the first message — sucker read the parts by position rather than by role, so for them it read the wrong ones and errored. Now resolved by role, with tests for both shapes.
- 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.
-1
View File
@@ -2,6 +2,5 @@ services:
web: web:
build: . build: .
image: sucker image: sucker
restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
+1 -2
View File
@@ -6,8 +6,7 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint"
"test": "node --experimental-strip-types --test \"tests/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
Binary file not shown.
+27 -64
View File
@@ -1,17 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import {
selectMessageSlots,
tokenCandidates,
type Message,
} from "@/lib/messages";
import {
NO_CARD,
NO_GREETING,
TOO_FEW_MESSAGES,
diagnoseSetup,
userFacingMessage,
} from "@/lib/errors";
interface CardVersion { interface CardVersion {
version: number; version: number;
@@ -76,6 +64,10 @@ function cleanupExpiredCards() {
); );
} }
interface Message {
content: string;
}
// Extracted shape used during POST handling // Extracted shape used during POST handling
interface ExtractedCard { interface ExtractedCard {
trackingName: string; trackingName: string;
@@ -101,15 +93,6 @@ 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);
@@ -126,18 +109,9 @@ function removePersonaTags(content: string): string {
} }
function extractCardData(messages: Message[]): ExtractedCard { function extractCardData(messages: Message[]): ExtractedCard {
const { system, greeting, nameSlot } = selectMessageSlots(messages); const first_mes = messages[2].content.replace(/{user}/g, "{{user}}");
if (!system) { const nameContent = messages[3].content;
throw NO_CARD;
}
if (!greeting) {
throw NO_GREETING;
}
const first_mes = greeting.replace(/{user}/g, "{{user}}");
const nameContent = nameSlot ?? "";
// If the name slot is actually a token, ignore it for naming purposes // If the name slot is actually a token, ignore it for naming purposes
const tokenInNameSlot = parseConversationToken(nameContent); const tokenInNameSlot = parseConversationToken(nameContent);
const lastColonIndex = nameContent.lastIndexOf(": "); const lastColonIndex = nameContent.lastIndexOf(": ");
@@ -147,7 +121,7 @@ function extractCardData(messages: Message[]): ExtractedCard {
? nameContent.substring(lastColonIndex + 2).trim() ? nameContent.substring(lastColonIndex + 2).trim()
: ""; : "";
let content = system.replace(/{user}/g, "{{user}}"); let content = messages[0].content.replace(/{user}/g, "{{user}}");
const inferredName = extractPersonaName(content); const inferredName = extractPersonaName(content);
content = removePersonaTags(content); content = removePersonaTags(content);
@@ -165,11 +139,11 @@ function extractCardData(messages: Message[]): ExtractedCard {
`Name extraction: user="${nameFromUser}", inferred="${inferredName}", tracking="${cleanTrackingName}", display="${displayName}"` `Name extraction: user="${nameFromUser}", inferred="${inferredName}", tracking="${cleanTrackingName}", display="${displayName}"`
); );
// The two markers are the two REQUIRED setup steps; the diagnosis names if (
// the one that is wrong, or says the card was cut short. !content.includes("<.>") ||
const setupProblem = diagnoseSetup(content); !content.includes("<UserPersona>.</UserPersona>")
if (setupProblem) { ) {
throw setupProblem; throw new Error("Required substrings not found");
} }
content = content.replace("<.>", ""); content = content.replace("<.>", "");
@@ -336,19 +310,18 @@ export async function POST(request: NextRequest) {
}); });
} }
// Decided from the body, and read again in the catch below: an error must
// go back the same way a success would, or the client never shows it.
let isStreamingRequest = false;
try { try {
const body = await request.json(); const body = await request.json();
isStreamingRequest = body.stream === true; const isStreamingRequest = body.stream === true;
if (!body.messages || body.messages.length < 2) { if (!body.messages || body.messages.length < 2) {
if (isStreamingRequest) { if (isStreamingRequest) {
return createSSEErrorResponse(userFacingMessage(TOO_FEW_MESSAGES)); return createSSEErrorResponse(
"Missing messages or insufficient message count"
);
} }
return NextResponse.json( return NextResponse.json(
{ error: userFacingMessage(TOO_FEW_MESSAGES) }, { error: "Missing messages or insufficient message count" },
{ {
status: 400, status: 400,
headers: { headers: {
@@ -358,14 +331,12 @@ export async function POST(request: NextRequest) {
); );
} }
// The token may be in what the user just typed, or in sucker's own first // Parse potential token from messages[3] (user) or messages[4] (assistant prior reply)
// reply from earlier in the conversation. Search every message rather than const tokenCandidateUser: string | undefined = body.messages?.[3]?.content;
// a fixed pair of slots: only the first reply carries a token, so a long const tokenCandidateAssistant: string | undefined = body.messages?.[4]?.content;
// chat would otherwise lose the link and duplicate the card.
const providedConversationId = const providedConversationId =
tokenCandidates(body.messages) parseConversationToken(tokenCandidateUser || undefined) ||
.map((c) => parseConversationToken(c)) parseConversationToken(tokenCandidateAssistant || undefined);
.find((id): id is string => Boolean(id)) ?? null;
let existingCard: StoredCard | null = null; let existingCard: StoredCard | null = null;
let linkedByToken = false; let linkedByToken = false;
@@ -516,13 +487,6 @@ 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 echoText = parseEchoText(selectMessageSlots(body.messages).lastUser);
if (echoText) {
responseMessage += `\n\n${echoText}`;
}
cleanupExpiredCards(); cleanupExpiredCards();
// Return SSE response if requested, otherwise JSON // Return SSE response if requested, otherwise JSON
@@ -563,15 +527,14 @@ export async function POST(request: NextRequest) {
} catch (error) { } catch (error) {
console.error("Error processing request:", error); console.error("Error processing request:", error);
const errorMessage = userFacingMessage(error); const errorMessage =
"You dingus, read the directions on sucker before trying again.";
// The body already said whether the client wanted a stream; the Accept // Check if this was a streaming request
// header is only a fallback for when the body could not be read at all.
const acceptHeader = request.headers.get("accept"); const acceptHeader = request.headers.get("accept");
const streaming = const isStreamingRequest = acceptHeader?.includes("text/event-stream");
isStreamingRequest || (acceptHeader?.includes("text/event-stream") ?? false);
if (streaming) { if (isStreamingRequest) {
return createSSEErrorResponse(errorMessage); return createSSEErrorResponse(errorMessage);
} }
+70 -250
View File
@@ -32,8 +32,6 @@ 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 {
@@ -67,36 +65,9 @@ interface Card {
initialVersion?: CardDataV2; 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() { export default function Home() {
const [cards, setCards] = useState<Card[]>([]); 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); 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>( const [selectedCardIndex, setSelectedCardIndex] = useState<number | null>(
null null
); );
@@ -118,6 +89,9 @@ 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);
@@ -262,6 +236,15 @@ 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 = {
@@ -273,13 +256,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: (chosen as any).creator || "", creator: creator,
creator_notes: (chosen as any).creator_notes || "", creator_notes: safeMetadata.description || (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: (chosen as any).tags || [], tags: allTagsArray,
character_version: (chosen as any).character_version || "1", character_version: safeMetadata.name || (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",
@@ -324,39 +307,9 @@ export default function Home() {
const isCharacterUrl = /janitorai\.com\/characters\//.test(characterUrl); const isCharacterUrl = /janitorai\.com\/characters\//.test(characterUrl);
if (isCharacterUrl) { if (isCharacterUrl) {
// Extract character ID and open metadata page, then show second input // Open html source, then show second input
const match = characterUrl.match(/characters\/([\w-]+)/); window.open(`view-source:${characterUrl}`, "_blank");
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;
} }
}; };
@@ -365,7 +318,42 @@ export default function Home() {
if (selectedCardIndex === null) return; if (selectedCardIndex === null) return;
try { try {
const avatarUrl = `https://ella.janitorai.com/bot-avatars/${avatarPath}`; const storeKey = "Sk--a:a-a--characterStore";
// 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],
@@ -400,21 +388,15 @@ 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 v{APP_VERSION}</h1> <h1 className="text-2xl font-bold">Sucker v2.2</h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
New: a live CSS editor for your profile. Consider joining Avalon!
</p> </p>
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="relative"> <Button variant="outline" size="icon">
<Menu className="h-4 w-4" /> <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> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@@ -424,11 +406,8 @@ export default function Home() {
<DropdownMenuItem onClick={() => setMirrorsDialogOpen(true)}> <DropdownMenuItem onClick={() => setMirrorsDialogOpen(true)}>
Mirrors Mirrors
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={openChangelog}> <DropdownMenuItem onClick={() => setChangelogDialogOpen(true)}>
Changelog Changelog
{!changelogSeen && (
<span className="ml-2 h-2 w-2 rounded-full bg-sky-500" />
)}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -447,9 +426,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 v{APP_VERSION}</h1> <h1 className="text-3xl font-bold">Sucker v2.2</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
New: a live CSS editor for your profile. Consider joining Avalon!
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -466,17 +445,10 @@ export default function Home() {
Mirrors Mirrors
</Button> </Button>
<Button <Button
onClick={openChangelog} onClick={() => setChangelogDialogOpen(true)}
variant="outline" variant="outline"
className="relative"
> >
Changelog 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>
<Button <Button
onClick={fetchCards} onClick={fetchCards}
@@ -492,35 +464,12 @@ 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" && (
<>
{/* Desktop: banners side by side */}
<div className="hidden md:flex gap-4 mb-4 items-stretch">
<DiscordBannerPermanent <DiscordBannerPermanent
inviteLink="https://discord.gg/5jQKkCfHP3" inviteLink="https://discord.gg/5jQKkCfHP3"
serverName="Avalon" 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:" 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)"
ctaText="Join Server" 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">
@@ -810,7 +759,7 @@ export default function Home() {
onClick={() => handleOpenDialog(index)} onClick={() => handleOpenDialog(index)}
variant="outline" variant="outline"
> >
Fetch Avatar (required for PNG) Fetch Metadata
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -818,7 +767,7 @@ export default function Home() {
variant="default" variant="default"
disabled={!card.avatarUrl} disabled={!card.avatarUrl}
> >
Download PNG Download Complete Card
</Button> </Button>
)} )}
</div> </div>
@@ -888,14 +837,6 @@ 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.
@@ -973,16 +914,6 @@ 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
@@ -1004,99 +935,6 @@ 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 - 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>
<p>
And when something does go wrong, sucker now tells you what
actually happened instead of calling you a dingus. If it hits
something it cannot explain, it says so and asks you to DM
@lyseverian on Discord — those are the ones worth hearing about.
</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> <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">
@@ -1172,43 +1010,25 @@ export default function Home() {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isMetadataOpen ? "Enter Avatar Path" : "Fetch Avatar"} {isMetadataOpen ? "Enter Page Source Code" : "Fetch Metadata"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{isMetadataOpen {isMetadataOpen ? "Paste the entire source code here." : "Enter a character URL (janitorai.com/characters/...) to open page source."}
? "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 <Input placeholder="<!DOCTYPE html><html..." value={pageSource} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPageSource(e.target.value)} />
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 Avatar Fetch Metadata
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<Input <Input placeholder="URL" value={characterUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCharacterUrl(e.target.value)} />
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 Avatar Fetch Metadata
</Button> </Button>
</div> </div>
)} )}
-85
View File
@@ -1,85 +0,0 @@
"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
@@ -1,100 +0,0 @@
"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,18 +7,16 @@ 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 = "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:", 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)",
ctaText = "Join Server", ctaText = "Join Server",
className = "mb-4",
}: DiscordBannerPermanentProps) { }: DiscordBannerPermanentProps) {
return ( return (
<div className={`relative rounded-lg overflow-hidden shadow-lg bg-[#36393f] ${className}`}> <div className="relative rounded-lg overflow-hidden shadow-lg mb-4 bg-[#36393f]">
{/* Background Image */} {/* Background Image */}
<div className="absolute inset-0"> <div className="absolute inset-0">
<Image <Image
@@ -71,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">885 members</div> <div className="font-medium">67 members</div>
<a <a
href="https://from-avalon.com" href="https://from-avalon.com"
target="_blank" target="_blank"
-161
View File
@@ -1,161 +0,0 @@
/**
* Telling people what actually went wrong.
*
* Every failure used to produce one message: "You dingus, read the directions
* on sucker before trying again", with a note about context size. For a
* truncated card that was fair. For anything else it blamed the user for
* something they could not have done anything about.
*
* The split that matters is not explained-vs-unexplained, it is
* **fixable-vs-not**:
*
* - A setup step or a truncated card is something a user can act on;
* those messages are shown verbatim, and name the step.
* - Everything else — a request with no card in it, no greeting, nothing
* at all — cannot happen through Janitor, and if it does the user has no
* way to cause or fix it. Telling them to "open the chat properly" would
* be the old message wearing a new coat. Those say plainly that it is not
* their fault and ask them to report it, carrying a short tag so the
* report identifies which assumption broke.
*/
/** Where to send someone when sucker is at fault. */
export const CONTACT = "DM @lyseverian on Discord";
/** A failure the person who hit it can actually do something about. */
export class SuckerError extends Error {
constructor(message: string) {
super(message);
this.name = "SuckerError";
}
}
/**
* A request shaped in a way Janitor does not produce. Not the user's fault
* and not theirs to fix; `detail` exists so their report says which
* assumption broke, not so they can act on it.
*/
export class ImpossibleRequest extends Error {
readonly detail: string;
constructor(detail: string) {
super(`impossible request: ${detail}`);
this.name = "ImpossibleRequest";
this.detail = detail;
}
}
/**
* Both are checked by name as well as by identity: Next can bundle a module
* into more than one chunk, and a mismatched class would quietly send every
* classified error down the wrong path.
*/
const named = (error: unknown, name: string): boolean =>
typeof error === "object" &&
error !== null &&
(error as { name?: unknown }).name === name;
export function isSuckerError(error: unknown): error is SuckerError {
return error instanceof SuckerError || named(error, "SuckerError");
}
export function isImpossibleRequest(error: unknown): error is ImpossibleRequest {
return error instanceof ImpossibleRequest || named(error, "ImpossibleRequest");
}
export function userFacingMessage(error: unknown): string {
if (isSuckerError(error)) return error.message;
const detail = isImpossibleRequest(error) ? ` (${error.detail})` : "";
return (
"Something went wrong that sucker has no explanation for. This one is " +
"not your fault and there is nothing for you to fix — " +
`please ${CONTACT} and say what you were pulling, because unreported ones ` +
`do not get fixed.${detail}`
);
}
const PERSONA_OPEN = "<UserPersona>";
const PERSONA_CLOSE = "</UserPersona>";
/** What the persona marker says, or null if it is absent or cut off mid-tag. */
function personaDescription(content: string): string | null {
const open = content.indexOf(PERSONA_OPEN);
if (open === -1) return null;
const close = content.indexOf(PERSONA_CLOSE, open + PERSONA_OPEN.length);
if (close === -1) return null;
return content.slice(open + PERSONA_OPEN.length, close);
}
/** A short, single-line quote of what the user actually set. */
function quoted(text: string): string {
if (text.trim() === "") return "empty";
const flat = text.replace(/\s+/g, " ").trim();
return `"${flat.length > 40 ? flat.slice(0, 40) + "…" : flat}"`;
}
/**
* Reads the two markers the two REQUIRED setup steps produce and says which
* step is wrong — or, when a marker is simply gone, that the card was
* probably cut short. Returns null when both are as they should be.
*
* `<.>` is the custom prompt and sits at the very start of the system
* message, so if it is missing the prompt is not set: truncation cannot
* remove the first three characters. `<UserPersona>…</UserPersona>` is the
* persona description and sits after the card, so:
*
* - present with something other than "." inside → the description is
* wrong. Certain, and the message quotes what it currently says.
* - absent, or cut off mid-tag → either the persona is not set or Janitor
* truncated the card because the bot is bigger than the context size.
* Truncation is the common one, so it leads.
*/
export function diagnoseSetup(content: string): SuckerError | null {
const hasPrompt = content.includes("<.>");
const persona = personaDescription(content);
const personaOk = persona === ".";
if (hasPrompt && personaOk) return null;
const stillStuck = `Still stuck: ${CONTACT}.`;
if (!hasPrompt) {
let also = "";
if (persona === null) {
also =
" Your persona did not come through either: it needs the name " +
"{{user}} and a description of just a dot.";
} else if (!personaOk) {
also = ` Your persona description is ${quoted(persona)} as well — it has to be just a dot.`;
}
return new SuckerError(
"Sucker could not read that card: your custom prompt is not coming " +
"through as <.> — set it in your API settings." +
also +
` Save, then refresh Janitor, not sucker. ${stillStuck}`
);
}
if (persona !== null) {
// Present but not "." — the one case that is certain.
return new SuckerError(
`Sucker could not read that card: your persona description is ${quoted(persona)} — ` +
"it has to be just a dot, with the name {{user}}. Save, then refresh " +
`Janitor, not sucker. ${stillStuck}`
);
}
// Prompt is fine and the persona marker is gone entirely: the end of the
// card is missing, and the usual reason is that Janitor cut it short.
return new SuckerError(
"Sucker could not read that card: the end of it is missing. That usually " +
"means the bot is bigger than your context size and Janitor cut it " +
"short — raise Context Size in Generation Settings and try again. If it " +
"is already generous, check your persona: name {{user}}, description " +
`just a dot, then refresh Janitor. ${stillStuck}`
);
}
// None of these can happen through Janitor. They exist so that if one ever
// does, the reply is honest about whose problem it is and the report says
// which assumption broke.
export const NO_CARD = new ImpossibleRequest("no system message in the request");
export const NO_GREETING = new ImpossibleRequest("no greeting in the request");
export const TOO_FEW_MESSAGES = new ImpossibleRequest("fewer than two messages in the request");
-117
View File
@@ -1,117 +0,0 @@
/**
* Locating the parts of a JanitorAI request.
*
* JanitorAI does not send a fixed number of messages. Most clients put a
* throwaway "." user turn between the card and the greeting; some do not.
* Sucker used to read fixed indices, so on the shorter shape it read the name
* slot as the greeting and the slot after that did not exist at all — a
* TypeError that reached the user as the "you dingus" message, blaming them
* for something they had no control over.
*
* So nothing here depends on a position. What is actually stable, in both
* shapes and however long the conversation runs:
*
* - the card is the system turn;
* - the greeting is the FIRST assistant turn;
* - the name is the first user turn AFTER the greeting — the one the user
* typed to start, which stays put as more turns are appended;
* - what the user typed most recently is the LAST user turn.
*
* Roles are compared case-insensitively and content is flattened to text
* first, because neither is guaranteed: a client may send "Assistant", or
* content as OpenAI's multipart array rather than a plain string. Getting
* either wrong looks exactly like the bug above.
*
* If JanitorAI changes the shape again, this is the only file that should
* need to know.
*/
export interface Message {
role?: string;
content?: unknown;
}
export interface MessageSlots {
/** The character card. */
system: string | undefined;
/** The character's opening message. */
greeting: string | undefined;
/** The name — or conversation token — the user typed to start the chat. */
nameSlot: string | undefined;
/** Whatever the user typed most recently. */
lastUser: string | undefined;
}
/** "Assistant", " user " and "USER" all have to count. */
function roleOf(message: Message | undefined): string {
return typeof message?.role === "string" ? message.role.trim().toLowerCase() : "";
}
/**
* Content is usually a string, but the OpenAI shape allows an array of parts
* ({ type: "text", text: "…" }). Anything else — null, a number, a missing
* key — flattens to "" so it is simply skipped rather than throwing.
*/
export function textOf(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") return part;
const text = (part as { text?: unknown } | null)?.text;
return typeof text === "string" ? text : "";
})
.join("");
}
return "";
}
/** Messages with usable text, paired with their normalised role. */
function usable(messages: Message[] | undefined): { role: string; text: string }[] {
return (Array.isArray(messages) ? messages : [])
.map((m) => ({ role: roleOf(m), text: textOf(m?.content) }))
.filter((m) => m.text.length > 0);
}
export function selectMessageSlots(messages: Message[] | undefined): MessageSlots {
const list = usable(messages);
const greetingIndex = list.findIndex((m) => m.role === "assistant");
const users = list.filter((m) => m.role === "user");
return {
// A first message with no role at all is taken as the card, which is
// where it has always been. A request that has roles but no system turn
// is left with no card, so it is reported rather than misdiagnosed.
system: (list.find((m) => m.role === "system") ?? (list[0]?.role === "" ? list[0] : undefined))?.text,
greeting: greetingIndex === -1 ? undefined : list[greetingIndex].text,
nameSlot:
greetingIndex === -1
? undefined
: list.slice(greetingIndex + 1).find((m) => m.role === "user")?.text,
lastUser: users[users.length - 1]?.text,
};
}
/**
* Message contents to search for a conversation token, in conversation order,
* starting after the greeting.
*
* A legitimate token lives in exactly one of two places, both after the
* greeting: the first user turn, when a chat was started with one to capture
* an alternate greeting, or sucker's own first reply, which issued it. Every
* later reply says "updated" or "unchanged" with no token in it, so a long
* conversation has to be searched back to its start or the link is lost and a
* duplicate card gets created.
*
* The card and the greeting are deliberately excluded. A card built from a
* sucker-proxied chat could carry an old token in its text, and a lookup on
* that would fail forever — the card would never be extractable.
* Conversation order also means the token a chat was started with always
* wins over one pasted later, so a chat cannot flip between cards.
*/
export function tokenCandidates(messages: Message[] | undefined): string[] {
const list = usable(messages);
const greetingIndex = list.findIndex((m) => m.role === "assistant");
if (greetingIndex === -1) return [];
return list.slice(greetingIndex + 1).map((m) => m.text);
}
-119
View File
@@ -1,119 +0,0 @@
/**
* Which failures a user can act on, which just need reporting, and whether
* the setup diagnosis names the right step.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import {
CONTACT,
ImpossibleRequest,
NO_CARD,
NO_GREETING,
SuckerError,
TOO_FEW_MESSAGES,
diagnoseSetup,
isImpossibleRequest,
isSuckerError,
userFacingMessage,
} from "../src/lib/errors.ts";
const CARD = "<Gaius's Persona>a tired innkeeper</Gaius's Persona>";
const ok = (prompt: string, persona: string) => `${prompt}\n${CARD}\n${persona}`;
test("a correctly set up card has nothing to diagnose", () => {
assert.equal(diagnoseSetup(ok("<.>", "<UserPersona>.</UserPersona>")), null);
});
test("a missing custom prompt is named with certainty, not blamed on truncation", () => {
const msg = diagnoseSetup(ok("", "<UserPersona>.</UserPersona>"))!.message;
assert.match(msg, /custom prompt is not coming through as <\.>/);
assert.ok(!/cut it short|Context Size/.test(msg), "truncation cannot remove the first three characters");
assert.ok(!/persona/i.test(msg), "the persona is fine and must not be mentioned");
});
test("a wrong persona description is quoted back, so there is nothing to guess", () => {
const msg = diagnoseSetup(ok("<.>", "<UserPersona>I am a knight</UserPersona>"))!.message;
assert.match(msg, /persona description is "I am a knight"/);
assert.match(msg, /just a dot/);
assert.ok(!/Context Size/.test(msg), "this case is certain; truncation is not the story");
});
test("an empty persona description says so", () => {
assert.match(diagnoseSetup(ok("<.>", "<UserPersona></UserPersona>"))!.message, /description is empty/);
});
test("a long persona description is trimmed to one readable line", () => {
const long = "x".repeat(80) + "\n\n y";
const msg = diagnoseSetup(ok("<.>", `<UserPersona>${long}</UserPersona>`))!.message;
assert.match(msg, /"x{40}…"/);
assert.ok(!msg.includes("\n"), "no raw newlines from user content");
});
test("a persona marker that is gone leads with truncation, the common cause", () => {
const msg = diagnoseSetup(ok("<.>", ""))!.message;
assert.match(msg, /end of it is missing/);
assert.ok(msg.indexOf("Context Size") < msg.indexOf("persona"), "truncation first, persona second");
});
test("a persona marker cut off mid-tag is truncation, not a wrong description", () => {
const msg = diagnoseSetup(ok("<.>", "<UserPersona>.</UserPe"))!.message;
assert.match(msg, /Context Size/);
assert.ok(!/description is/.test(msg));
});
test("both settings wrong at once are both named", () => {
const msg = diagnoseSetup(ok("", "<UserPersona>knight</UserPersona>"))!.message;
assert.match(msg, /custom prompt is not coming through/);
assert.match(msg, /description is "knight" as well/);
const neither = diagnoseSetup(ok("", ""))!.message;
assert.match(neither, /custom prompt is not coming through/);
assert.match(neither, /persona did not come through either/);
});
test("every setup message is shown verbatim and says where to go when stuck", () => {
for (const card of [ok("", "<UserPersona>.</UserPersona>"), ok("<.>", "<UserPersona>x</UserPersona>"), ok("<.>", ""), ok("", "")]) {
const e = diagnoseSetup(card)!;
assert.ok(isSuckerError(e));
assert.equal(userFacingMessage(e), e.message);
assert.ok(e.message.includes(CONTACT));
}
});
test("shapes Janitor cannot produce never tell the user to fix them", () => {
for (const e of [NO_CARD, NO_GREETING, TOO_FEW_MESSAGES]) {
const msg = userFacingMessage(e);
assert.match(msg, /not your fault and there is nothing for you to fix/i);
assert.ok(msg.includes(CONTACT), "must say where to report it");
assert.ok(!/open the chat|try again|point sucker|nothing was saved/i.test(msg), `overclaim or advice leaked into: ${msg}`);
}
});
test("the report carries which assumption broke, so it is useful", () => {
assert.match(userFacingMessage(NO_GREETING), /\(no greeting in the request\)/);
assert.match(userFacingMessage(NO_CARD), /\(no system message in the request\)/);
assert.match(userFacingMessage(TOO_FEW_MESSAGES), /\(fewer than two messages/);
});
test("a genuine bug reports itself without leaking internals", () => {
const msg = userFacingMessage(new TypeError("Cannot read properties of undefined"));
assert.match(msg, /not your fault/i);
assert.ok(msg.includes(CONTACT));
assert.ok(!msg.includes("Cannot read properties"));
assert.ok(!/\($/.test(msg.trim()), "no empty detail parenthesis");
});
test("nobody is called a dingus", () => {
for (const e of [diagnoseSetup("nothing")!, NO_CARD, NO_GREETING, TOO_FEW_MESSAGES, new Error("x")]) {
assert.ok(!/dingus/i.test(userFacingMessage(e)));
}
});
test("classified across module boundaries, not just by identity", () => {
assert.ok(isSuckerError(new SuckerError("x")));
assert.ok(isSuckerError({ name: "SuckerError", message: "x" }));
assert.ok(isImpossibleRequest(new ImpossibleRequest("x")));
assert.ok(isImpossibleRequest({ name: "ImpossibleRequest", detail: "x" }));
assert.ok(!isSuckerError(new Error("x")));
assert.ok(!isImpossibleRequest(new SuckerError("x")));
assert.ok(!isSuckerError(null));
});
-149
View File
@@ -1,149 +0,0 @@
/**
* Every request shape we have actually seen, plus the malformed ones a client
* could plausibly send. Run with `npm test` — no framework, node's own runner.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { selectMessageSlots, tokenCandidates, textOf } from "../src/lib/messages.ts";
const CARD =
"<.>\n<Gaius's Persona>a tired innkeeper</Gaius's Persona><UserPersona>.</UserPersona>";
const GREETING = "The clink of bottles is the only greeting you get.";
const NAME = "{{user}}: Gaius";
const TOKEN = "[sucker:conv=abc123]";
/** What most clients send: a throwaway "." user turn before the greeting. */
const withProbe = [
{ role: "system", content: CARD },
{ role: "user", content: "." },
{ role: "assistant", content: GREETING },
{ role: "user", content: NAME },
];
/** What some clients send: no "." turn, so every index shifts by one. */
const withoutProbe = [
{ role: "system", content: CARD },
{ role: "assistant", content: GREETING },
{ role: "user", content: NAME },
];
/** Three turns in. Only sucker's FIRST reply carries the token. */
const continued = [
...withProbe,
{ role: "assistant", content: `New character "Gaius" created (v1). ${TOKEN}` },
{ role: "user", content: "{{user}}: keep going" },
{ role: "assistant", content: "Character data unchanged (v1, message 2)." },
{ role: "user", content: "{{user}}: and again" },
];
test("both shapes resolve to the same slots", () => {
for (const [label, messages] of [
["with probe", withProbe],
["without probe", withoutProbe],
] as const) {
const slots = selectMessageSlots(messages);
assert.equal(slots.system, CARD, `${label}: card`);
assert.equal(slots.greeting, GREETING, `${label}: greeting`);
assert.equal(slots.nameSlot, NAME, `${label}: name`);
}
});
test("the greeting and the name stay put as a conversation grows", () => {
const slots = selectMessageSlots(continued);
assert.equal(slots.greeting, GREETING);
// Not "keep going" or "and again": the name is what started the chat.
assert.equal(slots.nameSlot, NAME);
assert.equal(slots.lastUser, "{{user}}: and again");
});
test("the token is still found once later replies stop carrying it", () => {
const found = tokenCandidates(continued).find((c) => c.includes("sucker:conv="));
assert.ok(found, "a token must still be discoverable three turns in");
assert.match(found!, /\[sucker:conv=abc123\]/);
});
test("the token a chat was started with outranks one pasted later, so a chat cannot flip between cards", () => {
const candidates = tokenCandidates([
...withProbe,
{ role: "assistant", content: `created (v1). ${TOKEN}` },
{ role: "user", content: "{{user}}: [sucker:conv=zzz999]" },
]);
const first = candidates.find((c) => c.includes("sucker:conv="));
assert.match(first!, /abc123/);
});
test("a token in the card or the greeting is never a candidate", () => {
const stale = "[sucker:conv=stale00]";
const candidates = tokenCandidates([
{ role: "system", content: `${CARD} made with sucker: ${stale}` },
{ role: "user", content: "." },
{ role: "assistant", content: `${GREETING} ${stale}` },
{ role: "user", content: NAME },
]);
assert.ok(!candidates.some((c) => c.includes(stale)), "an expired token baked into a card would make it unextractable forever");
assert.deepEqual(candidates, [NAME]);
});
test("roles are matched whatever their casing", () => {
const slots = selectMessageSlots([
{ role: "System", content: CARD },
{ role: " Assistant ", content: GREETING },
{ role: "USER", content: NAME },
]);
assert.equal(slots.system, CARD);
assert.equal(slots.greeting, GREETING);
assert.equal(slots.nameSlot, NAME);
});
test("content sent as OpenAI's multipart array is read as text", () => {
assert.equal(textOf([{ type: "text", text: "one " }, { type: "text", text: "two" }]), "one two");
const slots = selectMessageSlots([
{ role: "system", content: [{ type: "text", text: CARD }] },
{ role: "assistant", content: [{ type: "text", text: GREETING }] },
{ role: "user", content: NAME },
]);
assert.equal(slots.system, CARD);
assert.equal(slots.greeting, GREETING);
});
test("messages with no usable content are skipped, not treated as slots", () => {
const slots = selectMessageSlots([
{ role: "system", content: CARD },
{ role: "assistant", content: null },
{ role: "assistant" },
{ role: "assistant", content: GREETING },
{ role: "user", content: NAME },
]);
// The empty assistant turns must not be mistaken for the greeting.
assert.equal(slots.greeting, GREETING);
assert.equal(slots.nameSlot, NAME);
});
test("a request with roles but no system turn has no card, rather than a misdiagnosed one", () => {
const slots = selectMessageSlots([
{ role: "assistant", content: GREETING },
{ role: "user", content: NAME },
]);
// Taking the greeting as the card would produce a "custom prompt not set"
// message for a request the user could never have fixed.
assert.equal(slots.system, undefined);
assert.equal(slots.greeting, GREETING);
});
test("a card with no role on the system turn still resolves", () => {
const slots = selectMessageSlots([
{ content: CARD },
{ role: "assistant", content: GREETING },
{ role: "user", content: NAME },
]);
assert.equal(slots.system, CARD);
});
test("nothing throws on empty or malformed input", () => {
for (const input of [[], undefined, [{}, { role: "user" }], [null], [{ content: 42 }]]) {
assert.doesNotThrow(() => selectMessageSlots(input as never));
assert.doesNotThrow(() => tokenCandidates(input as never));
}
assert.equal(selectMessageSlots([]).greeting, undefined);
assert.deepEqual(tokenCandidates(undefined), []);
});