diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index 8fec27f..77607ed 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { selectMessageSlots, type Message } from "@/lib/messages"; +import { + selectMessageSlots, + tokenCandidates, + type Message, +} from "@/lib/messages"; interface CardVersion { version: number; @@ -115,7 +119,7 @@ function removePersonaTags(content: string): string { } function extractCardData(messages: Message[]): ExtractedCard { - const { system, greeting, lastUser } = selectMessageSlots(messages); + const { system, greeting, nameSlot } = selectMessageSlots(messages); if (!system?.content) { throw new Error("No system message: this request carries no character card."); @@ -126,7 +130,7 @@ function extractCardData(messages: Message[]): ExtractedCard { const first_mes = greeting.content.replace(/{user}/g, "{{user}}"); - const nameContent = lastUser?.content ?? ""; + const nameContent = nameSlot?.content ?? ""; // If the name slot is actually a token, ignore it for naming purposes const tokenInNameSlot = parseConversationToken(nameContent); const lastColonIndex = nameContent.lastIndexOf(": "); @@ -346,16 +350,14 @@ export async function POST(request: NextRequest) { ); } - // 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; + // The token may be in what the user just typed, or in sucker's own first + // reply from earlier in the conversation. Search every message rather than + // 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 = - parseConversationToken(tokenCandidateUser || undefined) || - parseConversationToken(tokenCandidateAssistant || undefined); + tokenCandidates(body.messages) + .map((c) => parseConversationToken(c)) + .find((id): id is string => Boolean(id)) ?? null; let existingCard: StoredCard | null = null; let linkedByToken = false; diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 1d812ce..720ff5d 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -8,13 +8,14 @@ * 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: + * 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; - * - 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. + * - 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. * * If JanitorAI changes the shape again, this is the only file that should * need to know. @@ -30,23 +31,47 @@ export interface MessageSlots { system: Message | undefined; /** The character's opening message. */ greeting: Message | undefined; - /** What the user typed most recently — the name, or a conversation token. */ + /** The name — or conversation token — the user typed to start the chat. */ + nameSlot: Message | undefined; + /** Whatever the user typed most recently. */ lastUser: Message | undefined; - /** Sucker's own most recent reply, when there is one. */ - lastAssistant: Message | undefined; } -export function selectMessageSlots(messages: Message[] | undefined): MessageSlots { +export function selectMessageSlots( + messages: Message[] | undefined +): MessageSlots { const list = Array.isArray(messages) ? messages : []; - const assistants = list.filter((m) => m?.role === "assistant"); + const greetingIndex = list.findIndex((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], + greeting: greetingIndex === -1 ? undefined : list[greetingIndex], + nameSlot: + greetingIndex === -1 + ? undefined + : list.slice(greetingIndex + 1).find((m) => m?.role === "user"), lastUser: users[users.length - 1], - lastAssistant: assistants[assistants.length - 1], }; } + +/** + * Message contents to search for a conversation token, most promising first. + * + * Only sucker's *first* reply carries a token — every later reply says + * "Character updated" or "unchanged" with no token in it. So a long + * conversation has to be searched all the way back rather than at its tail, + * or the link to the existing card is lost and a duplicate gets created. + * What the user just typed still wins, since pasting a token is deliberate. + */ +export function tokenCandidates(messages: Message[] | undefined): string[] { + const list = Array.isArray(messages) ? messages : []; + const users = list.filter((m) => m?.role === "user"); + const mostRecentUser = users[users.length - 1]; + + return [mostRecentUser, ...list.filter((m) => m !== mostRecentUser)] + .map((m) => m?.content) + .filter((c): c is string => typeof c === "string" && c.length > 0); +} diff --git a/tests/messages.test.ts b/tests/messages.test.ts index 721a804..bb8fba1 100644 --- a/tests/messages.test.ts +++ b/tests/messages.test.ts @@ -1,18 +1,19 @@ /** - * Both request shapes JanitorAI sends, and a continued conversation. + * Both request shapes JanitorAI sends, and conversations that keep going. * 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"; +import { selectMessageSlots, tokenCandidates } 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"; +const TOKEN = "[sucker:conv=abc123]"; /** What most clients send: a throwaway "." user turn before the greeting. */ -const withProbe = [ +const withProbe: { role: string; content: string }[] = [ { role: "system", content: CARD }, { role: "user", content: "." }, { role: "assistant", content: GREETING }, @@ -20,12 +21,21 @@ const withProbe = [ ]; /** What some clients send: no "." turn, so every index shifts by one. */ -const withoutProbe = [ +const withoutProbe: { role: string; content: string }[] = [ { 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], @@ -34,21 +44,31 @@ test("both shapes resolve to the same slots", () => { 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`); + assert.equal(slots.nameSlot?.content, NAME, `${label}: name`); } }); -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]" }, - ]; +test("the greeting and the name stay put as a conversation grows", () => { 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]"); + // Not "keep going" or "and again": the name is what started the chat. + assert.equal(slots.nameSlot?.content, NAME); + assert.equal(slots.lastUser?.content, "{{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("a token the user just pasted outranks an older one", () => { + const candidates = tokenCandidates([ + ...withProbe, + { role: "assistant", content: `created (v1). ${TOKEN}` }, + { role: "user", content: "{{user}}: [sucker:conv=zzz999]" }, + ]); + assert.match(candidates[0], /zzz999/); }); test("a card with no role on the system turn still resolves", () => { @@ -63,6 +83,8 @@ test("a card with no role on the system turn still resolves", () => { test("nothing throws on empty or malformed input", () => { for (const input of [[], undefined, [{}, { role: "user" }]]) { assert.doesNotThrow(() => selectMessageSlots(input as never)); + assert.doesNotThrow(() => tokenCandidates(input as never)); } assert.equal(selectMessageSlots([]).greeting, undefined); + assert.deepEqual(tokenCandidates(undefined), []); });