diff --git a/README.md b/README.md
index 42ddb80..42a2c9a 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ You can also build and run Sucker as a Docker container with `docker compose bui
### Changelog
-- 2.5: 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.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
diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts
index 5f76ed9..1e30742 100644
--- a/src/app/api/proxy/route.ts
+++ b/src/app/api/proxy/route.ts
@@ -5,6 +5,13 @@ import {
tokenCandidates,
type Message,
} from "@/lib/messages";
+import {
+ NO_CARD,
+ NO_GREETING,
+ TOO_FEW_MESSAGES,
+ TRUNCATED_CARD,
+ userFacingMessage,
+} from "@/lib/errors";
interface CardVersion {
version: number;
@@ -122,10 +129,10 @@ function extractCardData(messages: Message[]): ExtractedCard {
const { system, greeting, nameSlot } = selectMessageSlots(messages);
if (!system) {
- throw new Error("No system message: this request carries no character card.");
+ throw NO_CARD;
}
if (!greeting) {
- throw new Error("No assistant message: this chat has no greeting to read.");
+ throw NO_GREETING;
}
const first_mes = greeting.replace(/{user}/g, "{{user}}");
@@ -162,7 +169,7 @@ function extractCardData(messages: Message[]): ExtractedCard {
!content.includes("<.>") ||
!content.includes(".")
) {
- throw new Error("Required substrings not found");
+ throw TRUNCATED_CARD;
}
content = content.replace("<.>", "");
@@ -335,12 +342,10 @@ export async function POST(request: NextRequest) {
if (!body.messages || body.messages.length < 2) {
if (isStreamingRequest) {
- return createSSEErrorResponse(
- "Missing messages or insufficient message count"
- );
+ return createSSEErrorResponse(TOO_FEW_MESSAGES.message);
}
return NextResponse.json(
- { error: "Missing messages or insufficient message count" },
+ { error: TOO_FEW_MESSAGES.message },
{
status: 400,
headers: {
@@ -560,8 +565,7 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error("Error processing request:", error);
- const errorMessage =
- "You dingus, read the directions on sucker before trying again. If you're trying to suck a bot with more tokens than your current context limit, try bumping your Context Size setting up on Generation Settings.";
+ const errorMessage = userFacingMessage(error);
// Check if this was a streaming request
const acceptHeader = request.headers.get("accept");
diff --git a/src/app/page.tsx b/src/app/page.tsx
index eb5882d..813dab1 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1025,6 +1025,12 @@ export default function Home() {
The Changelog button also carries a dot now when there is
something here you haven't read.
+
+ 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.
+
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
new file mode 100644
index 0000000..98ce706
--- /dev/null
+++ b/src/lib/errors.ts
@@ -0,0 +1,71 @@
+/**
+ * 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 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 told them to check directions they had already followed.
+ *
+ * So: anything thrown as a SuckerError is written for the person who hit it
+ * and is shown to them verbatim. Anything else is a bug nobody anticipated,
+ * and the reply says so and asks them to report it, because an unexplained
+ * failure that nobody hears about never gets fixed.
+ */
+
+/** Where to send someone when sucker is at fault. */
+export const CONTACT = "DM @lyseverian on Discord";
+
+export class SuckerError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "SuckerError";
+ }
+}
+
+/**
+ * `instanceof` is checked by name as well: Next can bundle the same module
+ * into more than one chunk, and a class identity that differs between them
+ * would quietly send every explained error down the "unknown bug" path.
+ */
+export function isSuckerError(error: unknown): error is SuckerError {
+ return (
+ error instanceof SuckerError ||
+ (typeof error === "object" &&
+ error !== null &&
+ (error as { name?: unknown }).name === "SuckerError")
+ );
+}
+
+export function userFacingMessage(error: unknown): string {
+ if (isSuckerError(error)) return error.message;
+ return (
+ "Something went wrong that sucker has no explanation for, and nothing was " +
+ `saved. This one is not your fault — please ${CONTACT} and say what you ` +
+ "were pulling, because unreported ones do not get fixed."
+ );
+}
+
+/** The card arrived without the markers sucker needs to read it. */
+export const TRUNCATED_CARD = new SuckerError(
+ "The card came through incomplete — the markers sucker looks for are not in " +
+ "it. That usually means the bot is larger than your context size and " +
+ "Janitor cut it short, so raise Context Size in Generation Settings and " +
+ `try again. If it is already generous, re-read the directions on sucker, and ${CONTACT} if it still will not go.`
+);
+
+export const NO_CARD = new SuckerError(
+ "There is no character card in that request — sucker could not find a " +
+ "system message in it. Point sucker at a chat with a bot in it, not an " +
+ "empty one, and try again."
+);
+
+export const NO_GREETING = new SuckerError(
+ "That chat has no message from the bot yet, so there is nothing to pull. " +
+ "Open the chat until the bot's first message shows, then send again."
+);
+
+export const TOO_FEW_MESSAGES = new SuckerError(
+ "That request barely had anything in it — sucker needs a chat with a bot " +
+ "and at least one message in it. Open the chat properly and try again."
+);
diff --git a/tests/errors.test.ts b/tests/errors.test.ts
new file mode 100644
index 0000000..e596eb1
--- /dev/null
+++ b/tests/errors.test.ts
@@ -0,0 +1,44 @@
+/**
+ * Which failures explain themselves, and which ask to be reported.
+ */
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ CONTACT,
+ NO_CARD,
+ NO_GREETING,
+ SuckerError,
+ TOO_FEW_MESSAGES,
+ TRUNCATED_CARD,
+ isSuckerError,
+ userFacingMessage,
+} from "../src/lib/errors.ts";
+
+test("an explained failure is shown to the user as written", () => {
+ for (const e of [NO_CARD, NO_GREETING, TOO_FEW_MESSAGES, TRUNCATED_CARD]) {
+ assert.equal(userFacingMessage(e), e.message);
+ assert.ok(!/dingus/i.test(e.message), "nobody is called a dingus for these");
+ }
+});
+
+test("an unexplained failure owns it and asks to be reported", () => {
+ const msg = userFacingMessage(new TypeError("Cannot read properties of undefined"));
+ assert.match(msg, /not your fault/i);
+ assert.ok(msg.includes(CONTACT), "must say where to report it");
+ // The internals must not leak into a user-facing reply.
+ assert.ok(!msg.includes("Cannot read properties"));
+});
+
+test("the truncated-card case keeps the context-size advice", () => {
+ assert.match(TRUNCATED_CARD.message, /Context Size/);
+ assert.ok(TRUNCATED_CARD.message.includes(CONTACT));
+});
+
+test("recognised across module boundaries, not just by identity", () => {
+ assert.ok(isSuckerError(new SuckerError("x")));
+ // What a second copy of the class in another bundle would look like.
+ assert.ok(isSuckerError({ name: "SuckerError", message: "x" }));
+ assert.ok(!isSuckerError(new Error("x")));
+ assert.ok(!isSuckerError(null));
+ assert.ok(!isSuckerError("SuckerError"));
+});