mirror of
https://github.com/severian-dev/sucker.severian.dev.git
synced 2026-10-18 01:20:29 +00:00
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
This commit is contained in:
@@ -17,7 +17,7 @@ You can also build and run Sucker as a Docker container with `docker compose bui
|
|||||||
|
|
||||||
### Changelog
|
### 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.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.3: ECHO. Put ECHO: anywhere in a message and sucker repeats whatever comes after it in the reply — for triggering char-side script and lorebook keywords.
|
||||||
- 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards
|
- 2.2: Added support for directly inputting webp file names or character image links when creating PNG character cards
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import {
|
|||||||
tokenCandidates,
|
tokenCandidates,
|
||||||
type Message,
|
type Message,
|
||||||
} from "@/lib/messages";
|
} from "@/lib/messages";
|
||||||
|
import {
|
||||||
|
NO_CARD,
|
||||||
|
NO_GREETING,
|
||||||
|
TOO_FEW_MESSAGES,
|
||||||
|
TRUNCATED_CARD,
|
||||||
|
userFacingMessage,
|
||||||
|
} from "@/lib/errors";
|
||||||
|
|
||||||
interface CardVersion {
|
interface CardVersion {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -122,10 +129,10 @@ function extractCardData(messages: Message[]): ExtractedCard {
|
|||||||
const { system, greeting, nameSlot } = selectMessageSlots(messages);
|
const { system, greeting, nameSlot } = selectMessageSlots(messages);
|
||||||
|
|
||||||
if (!system) {
|
if (!system) {
|
||||||
throw new Error("No system message: this request carries no character card.");
|
throw NO_CARD;
|
||||||
}
|
}
|
||||||
if (!greeting) {
|
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}}");
|
const first_mes = greeting.replace(/{user}/g, "{{user}}");
|
||||||
@@ -162,7 +169,7 @@ function extractCardData(messages: Message[]): ExtractedCard {
|
|||||||
!content.includes("<.>") ||
|
!content.includes("<.>") ||
|
||||||
!content.includes("<UserPersona>.</UserPersona>")
|
!content.includes("<UserPersona>.</UserPersona>")
|
||||||
) {
|
) {
|
||||||
throw new Error("Required substrings not found");
|
throw TRUNCATED_CARD;
|
||||||
}
|
}
|
||||||
|
|
||||||
content = content.replace("<.>", "");
|
content = content.replace("<.>", "");
|
||||||
@@ -335,12 +342,10 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (!body.messages || body.messages.length < 2) {
|
if (!body.messages || body.messages.length < 2) {
|
||||||
if (isStreamingRequest) {
|
if (isStreamingRequest) {
|
||||||
return createSSEErrorResponse(
|
return createSSEErrorResponse(TOO_FEW_MESSAGES.message);
|
||||||
"Missing messages or insufficient message count"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Missing messages or insufficient message count" },
|
{ error: TOO_FEW_MESSAGES.message },
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -560,8 +565,7 @@ 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. If you're trying to suck a bot with more tokens than your current context limit, try bumping your Context Size setting up on Generation Settings.";
|
|
||||||
|
|
||||||
// Check if this was a streaming request
|
// Check if this was a streaming request
|
||||||
const acceptHeader = request.headers.get("accept");
|
const acceptHeader = request.headers.get("accept");
|
||||||
|
|||||||
@@ -1025,6 +1025,12 @@ export default function Home() {
|
|||||||
The Changelog button also carries a dot now when there is
|
The Changelog button also carries a dot now when there is
|
||||||
something here you haven't read.
|
something here you haven't read.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -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."
|
||||||
|
);
|
||||||
@@ -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"));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user