mirror of
https://github.com/severian-dev/sucker.severian.dev.git
synced 2026-10-18 01:20:29 +00:00
Keep multi-turn conversations linked to their card
The first pass read the name from the last user turn and the token from the last assistant turn. Both are wrong once a conversation continues. Only sucker's *first* reply carries a conversation token — later ones say "Character updated" or "unchanged" with no token in them — so reading the last assistant turn lost the link three turns in and would have created a duplicate card on every message. tokenCandidates() now searches the whole conversation, still preferring whatever the user just typed, since pasting a token is deliberate. The name is the first user turn after the greeting, not the last one. That is what the old code read at messages[3], and unlike the last turn it stays put as messages are appended. Tests cover a three-turn conversation for both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NX2SsmUqQftWTZAY4BDuFa
This commit is contained in:
+14
-12
@@ -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;
|
||||
|
||||
+36
-11
@@ -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);
|
||||
}
|
||||
|
||||
+36
-14
@@ -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 =
|
||||
"<.>\n<Gaius's Persona>a tired innkeeper</Gaius's Persona><UserPersona>.</UserPersona>";
|
||||
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), []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user