diff --git a/package.json b/package.json index 470e35d..13d6fe6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "node --experimental-strip-types --test \"tests/**/*.test.ts\"" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.2", diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index e3b1999..8fec27f 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { selectMessageSlots, type Message } from "@/lib/messages"; interface CardVersion { version: number; @@ -64,10 +65,6 @@ function cleanupExpiredCards() { ); } -interface Message { - content: string; -} - // Extracted shape used during POST handling interface ExtractedCard { trackingName: string; @@ -118,9 +115,18 @@ function removePersonaTags(content: string): string { } function extractCardData(messages: Message[]): ExtractedCard { - const first_mes = messages[2].content.replace(/{user}/g, "{{user}}"); + const { system, greeting, lastUser } = selectMessageSlots(messages); - const nameContent = messages[3].content; + if (!system?.content) { + throw new Error("No system message: this request carries no character card."); + } + if (!greeting?.content) { + throw new Error("No assistant message: this chat has no greeting to read."); + } + + const first_mes = greeting.content.replace(/{user}/g, "{{user}}"); + + const nameContent = lastUser?.content ?? ""; // If the name slot is actually a token, ignore it for naming purposes const tokenInNameSlot = parseConversationToken(nameContent); const lastColonIndex = nameContent.lastIndexOf(": "); @@ -130,7 +136,7 @@ function extractCardData(messages: Message[]): ExtractedCard { ? nameContent.substring(lastColonIndex + 2).trim() : ""; - let content = messages[0].content.replace(/{user}/g, "{{user}}"); + let content = system.content.replace(/{user}/g, "{{user}}"); const inferredName = extractPersonaName(content); content = removePersonaTags(content); @@ -340,9 +346,13 @@ export async function POST(request: NextRequest) { ); } - // Parse potential token from messages[3] (user) or messages[4] (assistant prior reply) - const tokenCandidateUser: string | undefined = body.messages?.[3]?.content; - const tokenCandidateAssistant: string | undefined = body.messages?.[4]?.content; + // The token is either in what the user just typed, or in sucker's own + // previous reply. Both are found by role, so a missing "." turn or a + // longer conversation cannot shift them out from under us. + const tokenSlots = selectMessageSlots(body.messages); + const tokenCandidateUser: string | undefined = tokenSlots.lastUser?.content; + const tokenCandidateAssistant: string | undefined = + tokenSlots.lastAssistant?.content; const providedConversationId = parseConversationToken(tokenCandidateUser || undefined) || parseConversationToken(tokenCandidateAssistant || undefined); diff --git a/src/lib/messages.ts b/src/lib/messages.ts new file mode 100644 index 0000000..1d812ce --- /dev/null +++ b/src/lib/messages.ts @@ -0,0 +1,52 @@ +/** + * 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: + * + * - the card is the system turn; + * - the greeting is the FIRST assistant turn; + * - whatever the user just typed is the LAST user turn; + * - sucker's own previous reply, which carries the conversation token, is + * the LAST assistant turn. + * + * If JanitorAI changes the shape again, this is the only file that should + * need to know. + */ + +export interface Message { + role?: string; + content?: string; +} + +export interface MessageSlots { + /** The character card. */ + system: Message | undefined; + /** The character's opening message. */ + greeting: Message | undefined; + /** What the user typed most recently — the name, or a conversation token. */ + lastUser: Message | undefined; + /** Sucker's own most recent reply, when there is one. */ + lastAssistant: Message | undefined; +} + +export function selectMessageSlots(messages: Message[] | undefined): MessageSlots { + const list = Array.isArray(messages) ? messages : []; + const assistants = list.filter((m) => m?.role === "assistant"); + const users = list.filter((m) => m?.role === "user"); + + return { + // Falling back to the first message keeps working if a client ever omits + // the role on the card, which is where it has always been. + system: list.find((m) => m?.role === "system") ?? list[0], + greeting: assistants[0], + lastUser: users[users.length - 1], + lastAssistant: assistants[assistants.length - 1], + }; +} diff --git a/tests/messages.test.ts b/tests/messages.test.ts new file mode 100644 index 0000000..721a804 --- /dev/null +++ b/tests/messages.test.ts @@ -0,0 +1,68 @@ +/** + * Both request shapes JanitorAI sends, and a continued conversation. + * Run with `npm test` — no test framework, just node's built-in runner. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { selectMessageSlots } from "../src/lib/messages.ts"; + +const CARD = + "<.>\na tired innkeeper."; +const GREETING = "The clink of bottles is the only greeting you get."; +const NAME = "{{user}}: Gaius"; + +/** 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 }, +]; + +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?.content, CARD, `${label}: card`); + assert.equal(slots.greeting?.content, GREETING, `${label}: greeting`); + assert.equal(slots.lastUser?.content, NAME, `${label}: name slot`); + } +}); + +test("the greeting stays the first assistant turn as a chat continues", () => { + const continued = [ + ...withProbe, + { role: "assistant", content: "Character updated (v2)." }, + { role: "user", content: "{{user}}: [sucker:conv=abc123]" }, + ]; + const slots = selectMessageSlots(continued); + assert.equal(slots.greeting?.content, GREETING); + // Sucker's own reply, where a conversation token would be found. + assert.equal(slots.lastAssistant?.content, "Character updated (v2)."); + assert.equal(slots.lastUser?.content, "{{user}}: [sucker:conv=abc123]"); +}); + +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?.content, CARD); +}); + +test("nothing throws on empty or malformed input", () => { + for (const input of [[], undefined, [{}, { role: "user" }]]) { + assert.doesNotThrow(() => selectMessageSlots(input as never)); + } + assert.equal(selectMessageSlots([]).greeting, undefined); +});