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")); +});