32 Commits

Author SHA1 Message Date
Severian 7569fb8e82 Diagnose setup by what is certain, and deliver errors the way the client asked
Review of the branch found the persona diagnosis was mostly truncation
wearing a name tag. The <.> marker sits at the start of the system
message and the persona marker after the card, so a card Janitor cut
short arrives with the prompt present and the persona gone — and was
told its persona was wrong, with truncation as an afterthought. The
original message was only ever about context size, which is the best
evidence there is about what actually happens.

diagnoseSetup() now says only what the markers make certain. A missing
<.> is a missing prompt, since truncation cannot remove the first three
characters. A persona marker that is present but not "." is a wrong
description, quoted back so there is nothing to guess. A persona marker
that is gone, or cut off mid-tag, leads with truncation and mentions the
persona second.

The error path decided "streaming?" from the Accept header while the
success path used body.stream, so a client that sent stream:true without
that header got JSON back for every error and never displayed it. The
flag is hoisted above the try; the header is only a fallback for when
the body could not be read.

Token search is restricted to after the greeting, in order. A card built
from a sucker-proxied chat could carry an expired token in its text, and
a lookup on it would fail forever. Conversation order also means the
token a chat was started with always wins over one pasted later. The
system fallback now takes a role-less first message only; a request with
roles but no system turn is reported as such rather than misdiagnosed as
a missing prompt. The echo feature reads the parser's lastUser instead of
its own case-sensitive lookup. "Nothing was saved" is dropped from the
unknown-error message, since it is not guaranteed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 06:46:52 +08:00
Severian a8b3669c17 Name the setting that did not come through
The instructions mark exactly two steps REQUIRED, and the two markers
sucker looks for are those two steps. So a failure can say which one is
missing — the custom prompt, the persona, or both — instead of sending
someone back to re-read a page they have already followed.

Truncation causes the same symptom, so it is still named, but second:
it only applies once the settings really are set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 06:06:36 +08:00
Severian bf74a85fec Stop giving advice for situations nobody can cause
The previous commit split failures into explained and unexplained, but the
split that matters is fixable and not. A request with no card, no greeting,
or nothing in it cannot come out of Janitor. Telling someone to "open the
chat until the bot's first message shows" is the dingus message wearing a
new coat: instructions for something they did not do and cannot undo.

Only the truncated card is a user's to fix, so only it is shown verbatim,
with the context-size advice. The rest are ImpossibleRequest now: the reply
says plainly that it is not their fault and there is nothing for them to
fix, and asks them to report it, carrying a short tag so the report says
which assumption broke rather than expecting the user to explain it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 06:00:23 +08:00
Severian 94d43dbb9d Say what actually went wrong
Every failure produced the same reply: read the directions, and check your
context size. For a card Janitor truncated that was fair. For a request
shape the user never chose, or for a bug in sucker, it blamed them for
something they could not have done anything about — and sent them back to
directions they had already followed.

Failures that can be explained now explain themselves: no card in the
request, no greeting in the chat, a card that arrived truncated (which
keeps the context-size advice, since that is where it belongs), and a
request with nothing in it. Anything else is a bug nobody anticipated, so
the reply says as much, says it is not the user's fault, and asks them to
DM @lyseverian on Discord — an unexplained failure nobody reports never
gets fixed. Internals are never echoed back.

SuckerError is recognised by name as well as by identity, because Next can
bundle a module into more than one chunk and a mismatched class would
quietly send every explained error down the unknown-bug path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 05:15:01 +08:00
Severian 7677a26f1a Mention the changelog dot in the changelog
The previous commit's edit to the dialog missed its anchor and applied
nowhere, so the entry never mentioned the dot it was describing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 05:07:53 +08:00
Severian b479460804 Harden the parsing, and mark unread changelog entries
Stress-testing the beta with request shapes we had not tried turned up two
more ways to hit the same class of bug. Roles were compared exactly, so a
client sending "Assistant" instead of "assistant" fell straight into the
dingus error. And content was assumed to be a string, so OpenAI's multipart
array form did the same. Roles are now compared case-insensitively and
content is flattened to text first; messages with no usable text are
skipped rather than mistaken for a slot. The slots are plain strings now,
so no caller can reach past them to a raw field again.

Separately: the Changelog button carries a dot when the current release
has not been opened yet, on the desktop button, the mobile menu item, and
the hamburger that hides it. The marker is the version in localStorage, so
every release brings it back on its own and reading it once clears it. The
version is a single constant now rather than being written in two places.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 05:07:19 +08:00
Severian ec2a1c672c Keep the platform's name out of the tagline
"New: a live CSS editor for your profile." The banner beside it already
names the platform, and so does the changelog entry; the line under the
version does not need to. "Live" earns its place there instead — the
preview is the thing that makes the editor worth opening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 04:31:08 +08:00
Severian 7444043bbe Release 2.5
Version heading bumped in both layouts, and the tagline under it now
points at the CSS editor instead of ECHO — the banner beside it is
advertising the editor, so the two now say the same thing.

Changelog entry is written for the people who hit this: they saw the
dingus error every time and had no way to know it was not their fault.
It says what happened in their terms and tells them to try again.

README's version log gets the matching line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 04:28:41 +08:00
Severian e7c9adbcc5 Keep multi-turn conversations linked to their card
The first pass read the name from the last user turn and the token from
the last assistant turn. Both are wrong once a conversation continues.

Only sucker's *first* reply carries a conversation token — later ones say
"Character updated" or "unchanged" with no token in them — so reading the
last assistant turn lost the link three turns in and would have created a
duplicate card on every message. tokenCandidates() now searches the whole
conversation, still preferring whatever the user just typed, since pasting
a token is deliberate.

The name is the first user turn after the greeting, not the last one. That
is what the old code read at messages[3], and unlike the last turn it stays
put as messages are appended.

Tests cover a three-turn conversation for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 04:18:26 +08:00
Severian 11c244fdd2 Read the request by role, not by position
Some users' JanitorAI sends a "." user turn between the card and the
greeting; others' does not. Sucker read fixed indices, so on the shorter
shape every slot was off by one: first_mes was set to the name line, and
the name slot did not exist at all. That TypeError was caught by the
handler and reported as the "you dingus, read the directions" message,
which blamed the user for a shape they never chose.

src/lib/messages.ts now resolves the slots by role instead — the card is
the system turn, the greeting is the first assistant turn, the user's
latest input is the last user turn, and sucker's own previous reply is the
last assistant turn. All four hold in both shapes and in a continued
conversation, so the conversation token is found in either as well.

Extraction now throws a description of what is missing rather than a
TypeError, and tests/messages.test.ts pins both shapes. The tests need no
framework: `npm test` runs node's built-in runner.

Existing cards are unaffected: the update path already discards the
extracted name and keeps the original.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
2026-09-11 04:14:04 +08:00
Eslezer e7da1aab4d Merge pull request #10 from severian-dev/feat/css-editor-banner
profile/css editor banner
2026-09-11 00:17:49 +10:00
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
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
Severian d35d704ce1 chore: 2.2 2026-01-30 18:22:16 +08:00
Severian 549d94fe85 Merge pull request #7 from chill-protocol/webp-link
Option for webp file name or character image URL inputs
2025-12-21 12:41:20 +08:00
tui bdde78475e Added the option to directly input webp file name or character image link when creating PNG for character cards 2025-12-21 09:44:53 +13:00
Ema 06d8b2e36c Merge pull request #6 from severian-dev/docker-next-standalone
Docker next standalone
2025-12-10 22:37:39 -05:00
Ema 59acc534fa Cleaning readme. 2025-12-10 22:34:29 -05:00
Ema fdd13085c3 Removing runtime env 2025-12-10 22:31:54 -05:00
Severian 8923bf3f63 chore: prod env, no sourcemaps 2025-12-11 08:42:20 +08:00
Ema P. a02087915b Standalone Readme Editing 2025-12-10 12:38:10 -05:00
Ema P. e6e230ab84 Building image from .next standalone server. 2025-12-10 10:55:35 -05:00
Ema P. b3aece1e41 Adding next.config.js for standalone. 2025-12-10 10:52:36 -05:00
Ema 24441720d6 Merge pull request #5 from leri-a/master
Updating base image
2025-12-10 09:42:29 -05:00
Ema P. 2fc4e419b2 Updating base image 2025-12-10 09:39:17 -05:00
Severian 95f5a3e725 chore: 2.1 2025-12-10 08:27:22 +08:00
Severian f99985ad6c chore: deps 2025-12-09 10:40:18 +08:00
28 changed files with 3192 additions and 1190 deletions
+5
View File
@@ -0,0 +1,5 @@
# Environment Variables for Sucker
# Disable Discord community banner (optional)
# Uncomment the line below to hide the Discord banner
# NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true
+47
View File
@@ -0,0 +1,47 @@
# Copilot Instructions for sucker.severian.dev
## Project Overview
- This is a Next.js project with a custom proxy API and UI components, using Tailwind CSS and PostCSS for styling.
- The main app logic is in `src/app/`, with global styles in `globals.css` and layout in `layout.tsx`.
- API routes are under `src/app/api/proxy/`, including image proxying (`image/route.ts`).
- UI components are in `src/components/ui/` and utility functions in `src/components/lib/`.
## Architecture & Data Flow
- The app uses Next.js routing and API routes for backend logic. The proxy API handles requests to external services, including image fetching and transformation.
- UI components follow a modular pattern, with reusable elements (e.g., `button.tsx`, `card.tsx`).
- Data flows from API routes to UI via React props and hooks. No global state management library is present.
## Developer Workflows
- **Build & Dev:** Use `npm run dev` to start the development server. Check `package.json` for other scripts.
- **Styling:** Tailwind CSS is configured via `tailwind.config.js` and PostCSS via `postcss.config.js`.
- **API:** Custom logic for proxying and image handling is in `src/app/api/proxy/`. Review these files for request/response patterns.
- **No test suite detected.** If adding tests, follow Next.js and React conventions.
## Project-Specific Conventions
- API routes use Next.js `route.ts` files, with custom logic for proxying and image manipulation.
- UI components are colocated in `src/components/ui/` and use Tailwind utility classes.
- Utility functions (e.g., PNG handling) are in `src/components/lib/`.
- Minimal documentation; refer to code for implementation details.
- Changelog is maintained in `README.md`.
## Integration Points & External Dependencies
- Relies on Next.js, React, Tailwind CSS, and PostCSS.
- External requests are proxied via custom API routes.
- Docker support via `docker-compose.yml` and `dockerfile` for containerization.
## Examples
- To add a new API route: create a `route.ts` under `src/app/api/yourroute/`.
- To add a new UI component: place a `.tsx` file in `src/components/ui/` and use Tailwind for styling.
- For image processing, review `src/app/api/proxy/image/route.ts` and `src/components/lib/png.ts`.
## Key Files & Directories
- `src/app/` — Main app logic and API routes
- `src/components/ui/` — UI components
- `src/components/lib/` — Utility functions
- `tailwind.config.js`, `postcss.config.js` — Styling configuration
- `docker-compose.yml`, `dockerfile` — Containerization
- `README.md` — Changelog and minimal project notes
---
If any section is unclear or missing important project-specific details, please provide feedback to improve these instructions.
+2
View File
@@ -1,2 +1,4 @@
node_modules node_modules
.next .next
.env.local
.env*.local
+19 -1
View File
@@ -1,9 +1,27 @@
# Sucker # Sucker
Check package.json for commands, I can't be bothered. ### Environment Variables
Add these to your `.env.local` file (optional):
```bash
# Disable Discord community banner (defaults to showing banner)
NEXT_PUBLIC_DISABLE_DISCORD_BANNER=true
```
### Usage
Pull this repostory and build with `npm run build`. You can start the server with `node ./.next/standalone/server.js`
You can also build and run Sucker as a Docker container with `docker compose build` and `docker compose up`.
### 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.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.
- also 2.0: V2 charcard format and alternate greetings. - also 2.0: V2 charcard format and alternate greetings.
- 1.9: Not again. They changed stuff again. What is this? - 1.9: Not again. They changed stuff again. What is this?
+1 -2
View File
@@ -2,7 +2,6 @@ services:
web: web:
build: . build: .
image: sucker image: sucker
restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
environment:
NODE_ENV: production
+10 -13
View File
@@ -1,7 +1,5 @@
FROM node:18-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
FROM base AS deps FROM base AS deps
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./ COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN \ RUN \
@@ -12,23 +10,22 @@ RUN \
fi fi
FROM base AS builder FROM base AS builder
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM base AS runner FROM node:22-alpine AS runner
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
WORKDIR /app WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "start"] CMD ["node", "server.js"]
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
productionBrowserSourceMaps: false,
};
module.exports = nextConfig;
+1438 -865
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -6,16 +6,18 @@
"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",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@types/react": "^18.2.39", "@types/react": "^19.2.7",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^19.2.3",
"axios": "^1.6.2", "axios": "^1.6.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -23,9 +25,9 @@
"crc-32": "^1.2.2", "crc-32": "^1.2.2",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"lucide-react": "^0.471.0", "lucide-react": "^0.471.0",
"next": "^14.0.3", "next": "^16.0.7",
"react": "^18.2.0", "react": "^19.2.1",
"react-dom": "^18.2.0", "react-dom": "^19.2.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.3.2" "typescript": "^5.3.2"
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+64 -27
View File
@@ -1,5 +1,17 @@
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;
@@ -64,10 +76,6 @@ 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;
@@ -93,6 +101,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);
@@ -109,9 +126,18 @@ function removePersonaTags(content: string): string {
} }
function extractCardData(messages: Message[]): ExtractedCard { function extractCardData(messages: Message[]): ExtractedCard {
const first_mes = messages[2].content.replace(/{user}/g, "{{user}}"); const { system, greeting, nameSlot } = selectMessageSlots(messages);
const nameContent = messages[3].content; if (!system) {
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(": ");
@@ -121,7 +147,7 @@ function extractCardData(messages: Message[]): ExtractedCard {
? nameContent.substring(lastColonIndex + 2).trim() ? nameContent.substring(lastColonIndex + 2).trim()
: ""; : "";
let content = messages[0].content.replace(/{user}/g, "{{user}}"); let content = system.replace(/{user}/g, "{{user}}");
const inferredName = extractPersonaName(content); const inferredName = extractPersonaName(content);
content = removePersonaTags(content); content = removePersonaTags(content);
@@ -139,11 +165,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}"`
); );
if ( // The two markers are the two REQUIRED setup steps; the diagnosis names
!content.includes("<.>") || // the one that is wrong, or says the card was cut short.
!content.includes("<UserPersona>.</UserPersona>") const setupProblem = diagnoseSetup(content);
) { if (setupProblem) {
throw new Error("Required substrings not found"); throw setupProblem;
} }
content = content.replace("<.>", ""); content = content.replace("<.>", "");
@@ -310,18 +336,19 @@ 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();
const isStreamingRequest = body.stream === true; isStreamingRequest = body.stream === true;
if (!body.messages || body.messages.length < 2) { if (!body.messages || body.messages.length < 2) {
if (isStreamingRequest) { if (isStreamingRequest) {
return createSSEErrorResponse( return createSSEErrorResponse(userFacingMessage(TOO_FEW_MESSAGES));
"Missing messages or insufficient message count"
);
} }
return NextResponse.json( return NextResponse.json(
{ error: "Missing messages or insufficient message count" }, { error: userFacingMessage(TOO_FEW_MESSAGES) },
{ {
status: 400, status: 400,
headers: { headers: {
@@ -331,12 +358,14 @@ export async function POST(request: NextRequest) {
); );
} }
// Parse potential token from messages[3] (user) or messages[4] (assistant prior reply) // The token may be in what the user just typed, or in sucker's own first
const tokenCandidateUser: string | undefined = body.messages?.[3]?.content; // reply from earlier in the conversation. Search every message rather than
const tokenCandidateAssistant: string | undefined = body.messages?.[4]?.content; // a fixed pair of slots: only the first reply carries a token, so a long
// chat would otherwise lose the link and duplicate the card.
const providedConversationId = const providedConversationId =
parseConversationToken(tokenCandidateUser || undefined) || tokenCandidates(body.messages)
parseConversationToken(tokenCandidateAssistant || undefined); .map((c) => parseConversationToken(c))
.find((id): id is string => Boolean(id)) ?? null;
let existingCard: StoredCard | null = null; let existingCard: StoredCard | null = null;
let linkedByToken = false; let linkedByToken = false;
@@ -487,6 +516,13 @@ 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
@@ -527,14 +563,15 @@ export async function POST(request: NextRequest) {
} catch (error) { } catch (error) {
console.error("Error processing request:", error); console.error("Error processing request:", error);
const errorMessage = const errorMessage = userFacingMessage(error);
"You dingus, read the directions on sucker before trying again.";
// Check if this was a streaming request // The body already said whether the client wanted a stream; the Accept
// 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 isStreamingRequest = acceptHeader?.includes("text/event-stream"); const streaming =
isStreamingRequest || (acceptHeader?.includes("text/event-stream") ?? false);
if (isStreamingRequest) { if (streaming) {
return createSSEErrorResponse(errorMessage); return createSSEErrorResponse(errorMessage);
} }
+534 -120
View File
@@ -20,17 +20,20 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Png } from "@/lib/png"; import { Png } from "@/lib/png";
import { import {
ChevronUp,
ChevronDown,
Copy, Copy,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Menu,
} from "lucide-react"; } from "lucide-react";
import { import {
CollapsibleContent, DropdownMenu,
Collapsible, DropdownMenuTrigger,
CollapsibleTrigger, DropdownMenuContent,
} from "@/components/ui/collapsible"; DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { DiscordBannerPermanent } from "@/components/ui/discord-banner-permanent";
import { CssEditorBanner } from "@/components/ui/css-editor-banner";
import { BannerCarousel } from "@/components/ui/banner-carousel";
import Script from "next/script"; import Script from "next/script";
interface CardDataV2 { interface CardDataV2 {
@@ -64,10 +67,36 @@ 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 [isInstructionsOpen, setIsInstructionsOpen] = useState(false);
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
); );
@@ -81,8 +110,13 @@ export default function Home() {
const [altGreetingIndexById, setAltGreetingIndexById] = useState< const [altGreetingIndexById, setAltGreetingIndexById] = useState<
Record<string, number> Record<string, number>
>({}); >({});
const [mirrorsDialogOpen, setMirrorsDialogOpen] = useState(false);
const [changelogDialogOpen, setChangelogDialogOpen] = useState(false);
const [howToUseDialogOpen, setHowToUseDialogOpen] = useState(false);
const [proxyUrl, setProxyUrl] = useState("https://sucker.severian.dev/api/proxy"); const [proxyUrl, setProxyUrl] = useState(
"https://sucker.severian.dev/api/proxy"
);
const fetchCards = async () => { const fetchCards = async () => {
try { try {
@@ -255,8 +289,7 @@ export default function Home() {
const cardData = JSON.stringify(pngData); const cardData = JSON.stringify(pngData);
const newImageData = Png.Generate(arrayBuffer, cardData); const newImageData = Png.Generate(arrayBuffer, cardData);
const newFileName = `${ const newFileName = `${(card.initialVersion?.name || card.data.name).replace(
(card.initialVersion?.name || card.data.name).replace(
/[^a-zA-Z0-9\-_]/g, /[^a-zA-Z0-9\-_]/g,
"_" "_"
) || "character" ) || "character"
@@ -287,6 +320,11 @@ export default function Home() {
}; };
const handleOpenMetadata = () => { const handleOpenMetadata = () => {
// Check if the input is a character metadata URL (janitorai.com/characters/...)
const isCharacterUrl = /janitorai\.com\/characters\//.test(characterUrl);
if (isCharacterUrl) {
// Extract character ID and open metadata page, then show second input
const match = characterUrl.match(/characters\/([\w-]+)/); const match = characterUrl.match(/characters\/([\w-]+)/);
if (match && match[1]) { if (match && match[1]) {
const characterId = match[1].split("_")[0]; const characterId = match[1].split("_")[0];
@@ -296,6 +334,31 @@ export default function Home() {
); );
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;
}
}; };
const handleFetchAvatar = async () => { const handleFetchAvatar = async () => {
@@ -321,7 +384,10 @@ export default function Home() {
return ( return (
<main className="min-h-screen bg-background text-foreground"> <main className="min-h-screen bg-background text-foreground">
<Script src="https://www.googletagmanager.com/gtag/js?id=G-YVD6QFSR71" strategy="afterInteractive" /> <Script
src="https://www.googletagmanager.com/gtag/js?id=G-YVD6QFSR71"
strategy="afterInteractive"
/>
<Script id="gtag-init" strategy="afterInteractive"> <Script id="gtag-init" strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || []; {`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);} function gtag(){dataLayer.push(arguments);}
@@ -329,13 +395,89 @@ export default function Home() {
gtag('config', 'G-YVD6QFSR71');`} gtag('config', 'G-YVD6QFSR71');`}
</Script> </Script>
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-4"> {/* Mobile Layout */}
<div className="sm:hidden mb-4">
{/* Row 1: Title on left, Hamburger on right */}
<div className="flex justify-between items-start mb-3">
<div> <div>
<h1 className="text-3xl font-bold">Sucker v2.0</h1> <h1 className="text-2xl font-bold">Sucker v{APP_VERSION}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-xs text-muted-foreground">
A couple of updates, see below. New: a live CSS editor for your profile.
</p> </p>
</div> </div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="relative">
<Menu className="h-4 w-4" />
{!changelogSeen && (
<span
aria-label="New changelog entry"
className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-sky-500 ring-2 ring-background"
/>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setHowToUseDialogOpen(true)}>
How to Use
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMirrorsDialogOpen(true)}>
Mirrors
</DropdownMenuItem>
<DropdownMenuItem onClick={openChangelog}>
Changelog
{!changelogSeen && (
<span className="ml-2 h-2 w-2 rounded-full bg-sky-500" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Row 2: Full-width Refresh button */}
<Button
onClick={fetchCards}
variant="outline"
disabled={isRefreshing}
className="w-full"
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
</div>
{/* Desktop Layout */}
<div className="hidden sm:flex justify-between items-center mb-4">
<div>
<h1 className="text-3xl font-bold">Sucker v{APP_VERSION}</h1>
<p className="text-sm text-muted-foreground">
New: a live CSS editor for your profile.
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => setHowToUseDialogOpen(true)}
variant="outline"
>
How to Use
</Button>
<Button
onClick={() => setMirrorsDialogOpen(true)}
variant="outline"
>
Mirrors
</Button>
<Button
onClick={openChangelog}
variant="outline"
className="relative"
>
Changelog
{!changelogSeen && (
<span
aria-label="New changelog entry"
className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-sky-500 ring-2 ring-background"
/>
)}
</Button>
<Button <Button
onClick={fetchCards} onClick={fetchCards}
variant="outline" variant="outline"
@@ -344,107 +486,42 @@ export default function Home() {
{isRefreshing ? "Refreshing..." : "Refresh"} {isRefreshing ? "Refreshing..." : "Refresh"}
</Button> </Button>
</div> </div>
</div>
<Separator className="my-4" /> <Separator className="my-4" />
<div className="mb-8">
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4"> {/* Discord Banner - Disabled if NEXT_PUBLIC_DISABLE_DISCORD_BANNER is set */}
<div className="flex flex-col justify-between"> {process.env.NEXT_PUBLIC_DISABLE_DISCORD_BANNER !== "true" && (
<span className="text-lg font-semibold text-blue-800 dark:text-blue-200"> <>
V2 charcard format, multi-turn support for scripts/lorebooks, {/* Desktop: banners side by side */}
alternate greetings. <div className="hidden md:flex gap-4 mb-4 items-stretch">
</span> <DiscordBannerPermanent
<p className="text-sm text-muted-foreground"> inviteLink="https://discord.gg/5jQKkCfHP3"
Sucker now tracks changes to character descriptions and serverName="Avalon"
scenarios across multiple messages. Cards with multiple versions 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:"
show a version badge and offer a "Download Changes" button to ctaText="Join Server"
get the change history. className="flex-1 min-w-0"
<br /> />
Alternate greetings are also supported. Sucker will provide you <CssEditorBanner className="flex-shrink-0 aspect-[16/10] w-[300px] lg:w-[360px] xl:w-[400px]" />
with a conversation ID that you can use to start off a new chat
when capturing alternate greetings, send it as first message
instead of the character name.
<br />
Directions are updated below. Make sure you read 'em.
<br />
If you're interested in hosting your own sucker instance, lmk
via Discord: @lyseverian, I've made the GH repo private for now.
</p>
</div>
</div>
</div> </div>
<Collapsible {/* Mobile: carousel */}
open={isInstructionsOpen} <BannerCarousel
onOpenChange={setIsInstructionsOpen} className="md:hidden mb-4"
className="w-full mb-8" slides={[
> <DiscordBannerPermanent
<div className="flex items-center justify-between"> key="avalon"
<h2 className="text-2xl font-semibold">How to Use</h2> inviteLink="https://discord.gg/5jQKkCfHP3"
<CollapsibleTrigger asChild> serverName="Avalon"
<Button variant="ghost" size="sm" className="w-9 p-0"> 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:"
{isInstructionsOpen ? ( ctaText="Join Server"
<ChevronUp className="h-4 w-4" /> className="h-full"
) : ( />,
<ChevronDown className="h-4 w-4" /> <CssEditorBanner key="css" className="h-full" fit="contain" />,
]}
/>
</>
)} )}
<span className="sr-only">Toggle instructions</span>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="mt-4">
<div className="prose dark:prose-invert max-w-none">
<p className="mb-2">
Follow every instruction here to the letter because it's all you
need to know and I have no intent of helping you further.
</p>
<ol className="list-decimal list-inside">
<li className="mb-2">
Put <code style={{ color: "#fff0b9" }}>{proxyUrl}</code> in your
API settings, any value for model and key.
</li>
<li className="mb-2">
REQUIRED: Set your custom prompt to <code style={{ color: "#fff0b9" }}>&lt;.&gt;</code>
</li>
<li className="mb-2">
REQUIRED: Set your persona (or create a new one) with the name{" "}
<code style={{ color: "#fff0b9" }}>&#123;user&#125;</code> and the description should only
have <code style={{ color: "#fff0b9" }}>.</code> in it.
</li>
<li className="mb-2">
Save settings and refresh the page. Not this page. <i>That</i>{" "}
page.
</li>
<li className="mb-2">Start a new chat with a character.</li>
<li className="mb-2">
Char name inference is implemented: if you send just a dot: <code style={{ color: "#fff0b9" }}>.</code>, sucker will use the inferred name from the persona tag, or you can send the character name yourself.
</li>
<li className="mb-2">
Hit the Refresh button here, and the cards should appear here.
</li>
<li className="mb-2">
If you're interested in capturing alternate greetings, start a
new chat and send the conversation ID as first message instead
of the character name. The format is{" "}
<code style={{ color: "#fff0b9" }}>[sucker:conv=conversationId]</code> which you'll be
given when creating a new card.
</li>
<li className="mb-2">
You can also send more messages with possible keywords to trigger scripts/lorebooks. Sucker will track changes to the description and scenario fields. Cards with multiple versions will show a version badge and offer a "Download Changes" button to get a detailed change
history with timestamps. Unfortunately, lorebook creation is out of scope at the moment, but you can use the changes detected to modify the character card yourself post-export.
</li>
<li className="mb-2">
Download the JSON files or go through a little more effort to
get PNGs instead.
</li>
</ol>
<p className="mb-2">
Extractions will only last for 10 minutes, after which they're
discarded. Reloading the page will remove any attached avatars.
I'm not storing shit.
</p>
</div>
</CollapsibleContent>
</Collapsible>
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-6">
{cards.length === 0 ? ( {cards.length === 0 ? (
@@ -544,8 +621,7 @@ export default function Home() {
card.alternate_greetings.length > 0 && ( card.alternate_greetings.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{`Alternate Greetings (${ <h4 className="font-medium">{`Alternate Greetings (${card.alternate_greetings?.length || 0
card.alternate_greetings?.length || 0
})`}</h4> })`}</h4>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@@ -753,16 +829,355 @@ export default function Home() {
</div> </div>
</div> </div>
{/* How to Use Dialog */}
<Dialog open={howToUseDialogOpen} onOpenChange={setHowToUseDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>How to Use</DialogTitle>
<DialogDescription>
Follow every instruction here to the letter because it&apos;s all you
need to know and I have no intent of helping you further.
</DialogDescription>
</DialogHeader>
<div className="prose dark:prose-invert max-w-none">
<ol className="list-decimal list-inside space-y-3 text-sm">
<li>
Put <code className="bg-muted px-1 py-0.5 rounded text-yellow-600">{proxyUrl}</code> in
your API settings, any value for model and key.
</li>
<li>
REQUIRED: Set your custom prompt to{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">&lt;.&gt;</code>
</li>
<li>
REQUIRED: Set your persona (or create a new one) with the name{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">&#123;user&#125;</code> and
the description should only have{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">.</code> in it.
</li>
<li>
Save settings and refresh the page. Not this page. <i>That</i>{" "}
page.
</li>
<li>Start a new chat with a character.</li>
<li>
Char name inference is implemented: if you send just a dot:{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">.</code>, sucker will use
the inferred name from the persona tag, or you can send the
character name yourself.
</li>
<li>
Hit the Refresh button here, and the cards should appear here.
</li>
<li>
If you&apos;re interested in capturing alternate greetings, start a
new chat and send the conversation ID as first message instead
of the character name. The format is{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">
[sucker:conv=conversationId]
</code>{" "}
which you&apos;ll be given when creating a new card.
</li>
<li>
You can also send more messages with possible keywords to
trigger scripts/lorebooks. Sucker will track changes to the
description and scenario fields. Cards with multiple versions
will show a version badge and offer a &quot;Download Changes&quot;
button to get a detailed change history with timestamps.
Unfortunately, lorebook creation is out of scope at the
moment, but you can use the changes detected to modify the
character card yourself post-export.
</li>
<li>
Some advanced scripts and lorebooks only add their entries
when the <i>character</i> says the keyword, not you. Put{" "}
<code className="bg-muted px-1 py-0.5 rounded text-yellow-600">ECHO:</code>{" "}
in a message and everything after it gets repeated at the end
of the reply — instant character-side keywords, and sucker
tracks whatever gets added.
</li>
<li>
Download the JSON files or go through a little more effort to
get PNGs instead.
</li>
</ol>
<p className="mt-4 text-sm text-muted-foreground">
Extractions will only last for 10 minutes, after which they&apos;re
discarded. Reloading the page will remove any attached avatars.
I&apos;m not storing shit.
</p>
</div>
</DialogContent>
</Dialog>
{/* Mirrors Dialog */}
<Dialog open={mirrorsDialogOpen} onOpenChange={setMirrorsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sucker Mirrors</DialogTitle>
<DialogDescription>
Sucker goes down sometimes on severian.dev because I use the
server for other stuff. Here&apos;s a full list of existing sucker
instances (thanks to those who signed up for it!):
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<ul className="text-sm flex flex-col gap-2">
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.severian.dev"
target="_blank"
rel="noopener noreferrer"
>
severian.dev
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.trashpanda.land"
target="_blank"
rel="noopener noreferrer"
>
trashpanda.land
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.hitani.me"
target="_blank"
rel="noopener noreferrer"
>
hitani.me
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://succ.portalnexus.link"
target="_blank"
rel="noopener noreferrer"
>
portalnexus.link
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://sucker.lemuria.dev"
target="_blank"
rel="noopener noreferrer"
>
lemuria.dev
</a>
</li>
<li>
<a
className="text-yellow-600 hover:underline"
href="https://milker.ladyofpa.in/"
target="_blank"
rel="noopener noreferrer"
>
ladyofpa.in
</a>
</li>
</ul>
<p className="text-sm text-muted-foreground pt-4">
If you&apos;re interested in hosting your own sucker instance, lmk
via Discord: @lyseverian, I&apos;ve made the GH repo private for
now. Or send me a message if there&apos;s anything you think that
could be added here, open to suggestions.
</p>
</div>
</DialogContent>
</Dialog>
{/* Changelog Dialog */}
<Dialog open={changelogDialogOpen} onOpenChange={setChangelogDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Changelog</DialogTitle>
<DialogDescription>
Recent updates and changes to Sucker
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div>
<h3 className="font-semibold text-lg mb-2">Sep 2026 - Fixed the dingus error some of you always got</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
If sucker told you to read the directions no matter what you
did, it wasn&apos;t you. Janitor doesn&apos;t send the same
request to everyone: some accounts include an extra blank turn
before the bot&apos;s first message and some don&apos;t. Sucker
was counting positions in that request instead of reading what
each part was, so for anyone on the shorter shape it read the
wrong pieces and fell over.
</p>
<p>
It now finds each part by what it is. Both shapes work, and
longer conversations stay linked to the right card. If sucker
never worked for you, give it another go.
</p>
<p>
The Changelog button also carries a dot now when there is
something here you haven&apos;t read.
</p>
<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>
<h3 className="font-semibold text-lg mb-2">Jan 2026 - Direct image input</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
You can now paste webp filenames (like id.webp) or full image URLs
directly into the avatar field without having to open the metadata tab first.
</p>
<p>
Makes grabbing avatars way faster when you already know the image path.
</p>
<p>
You should also consider joining our new thing, a Discord community server for botmakers:{" "}
<a
href="https://discord.gg/5jQKkCfHP3"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
discord.gg/5jQKkCfHP3
</a>
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">Dec 2025 - A note about fetching avatars</h3>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
The platform you suck from has implemented limited visibility
of metadata for certain content with a particular &apos;obscenity
rating&apos;. This means that in some cases, the Fetch Avatar flow
here will show a 404 - character not found error at the end.
</p>
<p>
Sometimes (but not always), the avatar URL can still be
fetched after a day or two since the bot was published.
</p>
<p>
As of this moment, can&apos;t really find a fix for it, so you&apos;ll
have to download the image yourself and just add the image to
the card someplace else.
</p>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold text-lg mb-2">
Oct 2025 - V2 charcard format, multi-turn support
</h3>
<div className="text-sm text-muted-foreground space-y-2">
<p>
Sucker now tracks changes to character descriptions and
scenarios across multiple messages. Cards with multiple versions
show a version badge and offer a &quot;Download Changes&quot; button to
get the change history.
</p>
<p>
Alternate greetings are also supported. Sucker will provide you
with a conversation ID that you can use to start off a new chat
when capturing alternate greetings, send it as first message
instead of the character name.
</p>
<p>
Directions are updated below. Make sure you read &apos;em.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isMetadataOpen ? "Enter Avatar Path" : "Enter Character URL"} {isMetadataOpen ? "Enter Avatar Path" : "Fetch Avatar"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{isMetadataOpen {isMetadataOpen
? "Look for the avatar field in the opened tab and paste the value here." ? "Look for the avatar field in the opened tab and paste the value here."
: "Enter the Janitor character URL (https://janitorai.com/characters/...)."} : "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>
@@ -782,19 +1197,18 @@ export default function Home() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<Input <Input
placeholder="https://janitorai.com/characters/..." placeholder="URL or id.webp"
value={characterUrl} value={characterUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setCharacterUrl(e.target.value) setCharacterUrl(e.target.value)
} }
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Upon clicking this button, a new tab will open with the For character URLs, a new tab will open with metadata. For image
character's metadata. Look for the avatar field and copy the filenames or full image URLs, the avatar will be set directly.
value before returning to this page.
</p> </p>
<Button onClick={handleOpenMetadata} className="w-full"> <Button onClick={handleOpenMetadata} className="w-full">
Open Metadata Fetch Avatar
</Button> </Button>
</div> </div>
)} )}
+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>
);
}
@@ -0,0 +1,112 @@
"use client";
import Image from "next/image";
interface DiscordBannerPermanentProps {
inviteLink: string;
serverName?: string;
description?: string;
ctaText?: string;
className?: string;
}
export function DiscordBannerPermanent({
inviteLink,
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 = "mb-4",
}: DiscordBannerPermanentProps) {
return (
<div className={`relative rounded-lg overflow-hidden shadow-lg bg-[#36393f] ${className}`}>
{/* Background Image */}
<div className="absolute inset-0">
<Image
src="/avalon/bg.webp"
alt="Background"
fill
className="object-cover opacity-20"
/>
</div>
{/* Content */}
<div className="relative p-3 sm:p-4">
{/* Chat Message Style */}
<div className="flex gap-3 mb-2">
{/* Morgana Avatar */}
<div className="flex-shrink-0 pt-0.5">
<Image
src="/avalon/morgana-thumb.webp"
alt="Morgana"
width={40}
height={40}
className="rounded-full w-10 h-10"
/>
</div>
{/* Message Content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-0.5">
<span className="text-white font-medium text-[15px]">Morgana</span>
<span className="text-[#a3a6aa] text-[11px] font-medium">Today at {new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}</span>
</div>
<div className="text-[#dcddde] text-[15px] leading-[1.375rem] mb-2">
{description}
</div>
{/* Server Invite Embed */}
<div className="bg-[#2f3136] rounded border-l-4 border-[#5865F2] overflow-hidden max-w-[432px]">
<div className="p-4 pb-3">
<div className="text-[#00b0f4] text-xs font-semibold uppercase mb-2">
You&apos;ve been invited to join a server
</div>
<div className="flex items-center gap-4">
<Image
src="/avalon/avalon-pfp.webp"
alt="Avalon"
width={50}
height={50}
className="rounded-2xl w-[50px] h-[50px] flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<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="font-medium">885 members</div>
<a
href="https://from-avalon.com"
target="_blank"
rel="noopener noreferrer"
className="text-[#00aff4] hover:underline text-xs inline-block"
>
from-avalon.com
</a>
</div>
</div>
<a
href={inviteLink}
target="_blank"
rel="noopener noreferrer"
className="bg-[#248046] hover:bg-[#1a6334] text-white px-4 py-2 text-sm rounded font-medium transition-colors flex-shrink-0"
>
{ctaText}
</a>
</div>
</div>
</div>
</div>
</div>
{/* Trash Panda Icon - Bottom Right */}
<div className="absolute bottom-1.5 right-1.5">
<Image
src="/avalon/trashpanda.webp"
alt=""
width={20}
height={20}
className="rounded-full w-5 h-5 ring-1 ring-black/20 opacity-60 hover:opacity-100 transition-opacity"
/>
</div>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className = "", sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={`z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ${className}`}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className = "", inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={`relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${
inset ? "pl-8" : ""
} ${className}`}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem };
+161
View File
@@ -0,0 +1,161 @@
/**
* 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
@@ -0,0 +1,117 @@
/**
* 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
@@ -0,0 +1,119 @@
/**
* 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
@@ -0,0 +1,149 @@
/**
* 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), []);
});
+22 -6
View File
@@ -2,8 +2,14 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"target": "es5", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"types": ["node"], "dom",
"dom.iterable",
"esnext"
],
"types": [
"node"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -13,7 +19,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -21,9 +27,19 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }