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:
Severian
2026-09-11 04:18:26 +08:00
parent 11c244fdd2
commit e7c9adbcc5
3 changed files with 86 additions and 37 deletions
+14 -12
View File
@@ -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;