This commit is contained in:
Severian
2025-10-04 04:28:02 +08:00
parent 624f9f264b
commit d720ddcea5
2 changed files with 398 additions and 154 deletions

View File

@@ -20,13 +20,34 @@ interface CardVersion {
};
}
interface StoredCard extends CardData {
interface CardDataV2 {
name: string;
first_mes: string;
alternate_greetings: string[];
description: string;
personality: string;
mes_example: string;
scenario: string;
creator: string;
creator_notes: string;
system_prompt: string;
post_history_instructions: string;
tags: string[];
character_version: string;
extensions: Record<string, unknown>;
}
interface StoredCard {
timestamp: number;
id: string;
versions: CardVersion[];
currentVersion: number;
messageCount: number;
conversationId: string;
trackingName: string;
data: CardDataV2;
spec: "chara_card_v2";
spec_version: "2.0";
}
let extractedCards: StoredCard[] = [];
@@ -47,14 +68,10 @@ interface Message {
content: string;
}
interface CardData {
name: string;
// Extracted shape used during POST handling
interface ExtractedCard {
trackingName: string;
first_mes: string;
description: string;
personality: string;
mes_example: string;
scenario: string;
data: CardDataV2;
}
function extractPersonaName(content: string): string | null {
@@ -65,6 +82,16 @@ function extractPersonaName(content: string): string | null {
return null;
}
function parseConversationToken(content: string | undefined | null): string | null {
if (!content) return null;
const trimmed = content.trim();
const match = trimmed.match(/^\[sucker:conv=([a-z0-9]+)\]$/i);
if (match) {
return match[1];
}
return null;
}
function removePersonaTags(content: string): string {
let result = content;
const openingMatch = result.match(/<[^<>]+?\s*'s\s+Persona>/i);
@@ -80,15 +107,18 @@ function removePersonaTags(content: string): string {
return result;
}
function extractCardData(messages: Message[]): CardData {
function extractCardData(messages: Message[]): ExtractedCard {
const first_mes = messages[2].content.replace(/{user}/g, "{{user}}");
const nameContent = messages[3].content;
// If the name slot is actually a token, ignore it for naming purposes
const tokenInNameSlot = parseConversationToken(nameContent);
const lastColonIndex = nameContent.lastIndexOf(": ");
const nameFromUser =
lastColonIndex !== -1
? nameContent.substring(lastColonIndex + 2).trim()
: "";
const nameFromUser = tokenInNameSlot
? ""
: lastColonIndex !== -1
? nameContent.substring(lastColonIndex + 2).trim()
: "";
let content = messages[0].content.replace(/{user}/g, "{{user}}");
const inferredName = extractPersonaName(content);
@@ -143,36 +173,33 @@ function extractCardData(messages: Message[]): CardData {
const description = content.trim();
return {
const data: CardDataV2 = {
name: displayName,
trackingName: cleanTrackingName,
first_mes,
alternate_greetings: [],
description,
personality: "",
mes_example,
scenario,
creator: "",
creator_notes: "",
system_prompt: "",
post_history_instructions: "",
tags: [],
character_version: "1",
extensions: {},
};
return {
trackingName: cleanTrackingName,
data,
};
}
function generateConversationId(messages: Message[]): string {
// Create a simple hash from the character name in the persona tag to identify conversations
const content = messages[0]?.content || "";
const personaMatch = content.match(/<([^<>]+?)\s*'s\s+Persona>/i);
if (personaMatch) {
return personaMatch[1]
.trim()
.toLowerCase()
.replace(/[^a-zA-Z0-9]/g, "");
}
// Fallback to content-based ID
return content
.substring(0, 50)
.replace(/[^a-zA-Z0-9]/g, "")
.toLowerCase();
}
// conversationId is now an opaque random ID generated via generateId() on creation
function detectChanges(
newCard: CardData,
newData: CardDataV2,
existingCard: StoredCard
): {
description?: { old: string; new: string };
@@ -184,16 +211,16 @@ function detectChanges(
} = {};
let hasChanges = false;
if (newCard.description.trim() !== existingCard.description.trim()) {
if (newData.description.trim() !== existingCard.data.description.trim()) {
changes.description = {
old: existingCard.description,
new: newCard.description,
old: existingCard.data.description,
new: newData.description,
};
hasChanges = true;
}
if (newCard.scenario.trim() !== existingCard.scenario.trim()) {
changes.scenario = { old: existingCard.scenario, new: newCard.scenario };
if (newData.scenario.trim() !== existingCard.data.scenario.trim()) {
changes.scenario = { old: existingCard.data.scenario, new: newData.scenario };
hasChanges = true;
}
@@ -207,9 +234,13 @@ function findExistingCard(trackingName: string): StoredCard | null {
);
}
function findExistingCardByConversationId(conversationId: string): StoredCard | null {
return extractedCards.find((card) => card.conversationId === conversationId) || null;
}
function updateCardWithVersion(
existingCard: StoredCard,
newCard: CardData,
newData: CardDataV2,
changes: {
description?: { old: string; new: string };
scenario?: { old: string; new: string };
@@ -259,10 +290,10 @@ function updateCardWithVersion(
// Update the main card data
if (changes.description) {
existingCard.description = changes.description.new;
existingCard.data.description = changes.description.new;
}
if (changes.scenario) {
existingCard.scenario = changes.scenario.new;
existingCard.data.scenario = changes.scenario.new;
}
}
@@ -303,9 +334,62 @@ export async function POST(request: NextRequest) {
);
}
const cardData = extractCardData(body.messages);
const conversationId = generateConversationId(body.messages);
const existingCard = findExistingCard(cardData.trackingName);
// Parse potential token from messages[3]
const tokenCandidate: string | undefined = body.messages?.[3]?.content;
const providedConversationId = parseConversationToken(tokenCandidate || undefined);
let existingCard: StoredCard | null = null;
let linkedByToken = false;
if (providedConversationId) {
const byToken = findExistingCardByConversationId(providedConversationId);
if (!byToken) {
const notFoundMessage = `Conversation ID not found. Please provide a valid token or the character name to create a new one: [sucker:conv=<conversationId>]`;
if (isStreamingRequest) {
return createSSEErrorResponse(notFoundMessage);
}
return NextResponse.json(
{
id: `chatcmpl-${generateId()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "sucker-v2",
choices: [
{
index: 0,
message: {
role: "assistant",
content: notFoundMessage,
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 0,
completion_tokens: notFoundMessage.split(" ").length,
total_tokens: notFoundMessage.split(" ").length,
},
},
{
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
}
);
}
existingCard = byToken;
linkedByToken = true;
}
const extracted = extractCardData(body.messages);
if (!existingCard) {
existingCard = findExistingCard(extracted.trackingName);
}
// Prefer existing card's conversationId; otherwise generate a new random one for creation
const conversationId = existingCard
? existingCard.conversationId
: providedConversationId || generateId();
console.log(`Conversation ID: ${conversationId}`);
@@ -313,13 +397,13 @@ export async function POST(request: NextRequest) {
let changesSummary = "";
console.log(
`Processing card: "${cardData.name}" (tracking: "${cardData.trackingName}"), ConversationID: ${conversationId}`
`Processing card: "${extracted.data.name}" (tracking: "${extracted.trackingName}"), ConversationID: ${conversationId}`
);
console.log(
`Existing cards: ${extractedCards
.map(
(c) =>
`"${c.name}" (tracking: "${c.trackingName}", v${c.currentVersion})`
`"${c.data.name}" (tracking: "${c.trackingName}", v${c.currentVersion})`
)
.join(", ")}`
);
@@ -330,7 +414,21 @@ export async function POST(request: NextRequest) {
);
if (existingCard) {
const changes = detectChanges(cardData, existingCard);
let alternateGreetingRecorded = false;
// Capture alternate greeting if applicable (no version bump for greetings-only)
const normalizedGreeting = extracted.data.first_mes.trim();
if (
normalizedGreeting &&
normalizedGreeting !== existingCard.data.first_mes &&
!(existingCard.data.alternate_greetings || []).includes(normalizedGreeting)
) {
if (!existingCard.data.alternate_greetings) existingCard.data.alternate_greetings = [];
existingCard.data.alternate_greetings.push(normalizedGreeting);
existingCard.timestamp = Date.now();
alternateGreetingRecorded = true;
}
const changes = detectChanges(extracted.data, existingCard);
console.log(`Changes detected:`, changes ? "YES" : "NO");
if (changes) {
console.log(
@@ -338,10 +436,10 @@ export async function POST(request: NextRequest) {
existingCard.currentVersion + 1
}`
);
updateCardWithVersion(existingCard, cardData, changes);
updateCardWithVersion(existingCard, extracted.data, changes);
// Keep the original display name (don't update it)
// existingCard.name stays the same
// existingCard.data.name stays the same
// Create a summary of changes for the response
const changeTypes = [];
@@ -353,13 +451,19 @@ export async function POST(request: NextRequest) {
} else {
existingCard.messageCount += 1;
// Keep the original display name (don't update it)
// existingCard.name stays the same
// existingCard.data.name stays the same
responseMessage = `Character data unchanged (v${existingCard.currentVersion}, message ${existingCard.messageCount}).`;
}
if (alternateGreetingRecorded) {
responseMessage += ` Alternate greeting recorded.`;
}
if (linkedByToken) {
responseMessage += ` Conversation linked via provided ID.`;
}
} else {
// Create new card with initial version
const newCard: StoredCard = {
...cardData,
data: extracted.data,
timestamp: Date.now(),
id: generateId(),
conversationId,
@@ -369,17 +473,21 @@ export async function POST(request: NextRequest) {
version: 1,
timestamp: Date.now(),
changes: {
description: { old: "", new: cardData.description },
scenario: { old: "", new: cardData.scenario },
description: { old: "", new: extracted.data.description },
scenario: { old: "", new: extracted.data.scenario },
},
changeType: "initial",
messageCount: 1,
},
],
currentVersion: 1,
trackingName: extracted.trackingName,
spec: "chara_card_v2",
spec_version: "2.0",
};
extractedCards.push(newCard);
responseMessage = `New character "${cardData.trackingName}" created (v1).`;
const tokenNote = ` This is the conversation ID you can use to start off a new chat when capturing alternate greetings, use it instead of the character name: [sucker:conv=${conversationId}]`;
responseMessage = `New character "${extracted.trackingName}" created (v1).${tokenNote}`;
}
cleanupExpiredCards();
@@ -466,7 +574,7 @@ export async function POST(request: NextRequest) {
}
}
function getInitialCardVersion(card: StoredCard): CardData {
function getInitialCardVersion(card: StoredCard): CardDataV2 {
// Get the initial version (v1) of the card
const initialVersion = card.versions.find((v) => v.version === 1);
if (
@@ -475,24 +583,38 @@ function getInitialCardVersion(card: StoredCard): CardData {
initialVersion.changes.scenario
) {
return {
name: card.name,
trackingName: card.trackingName,
first_mes: card.first_mes,
name: card.data.name,
first_mes: card.data.first_mes,
alternate_greetings: card.data.alternate_greetings || [],
description: initialVersion.changes.description.new,
personality: card.personality,
mes_example: card.mes_example,
personality: card.data.personality,
mes_example: card.data.mes_example,
scenario: initialVersion.changes.scenario.new,
creator: card.data.creator,
creator_notes: card.data.creator_notes,
system_prompt: card.data.system_prompt,
post_history_instructions: card.data.post_history_instructions,
tags: card.data.tags,
character_version: card.data.character_version,
extensions: card.data.extensions,
};
}
// Fallback to current version if initial not found
return {
name: card.name,
trackingName: card.trackingName,
first_mes: card.first_mes,
description: card.description,
personality: card.personality,
mes_example: card.mes_example,
scenario: card.scenario,
name: card.data.name,
first_mes: card.data.first_mes,
alternate_greetings: card.data.alternate_greetings || [],
description: card.data.description,
personality: card.data.personality,
mes_example: card.data.mes_example,
scenario: card.data.scenario,
creator: card.data.creator,
creator_notes: card.data.creator_notes,
system_prompt: card.data.system_prompt,
post_history_instructions: card.data.post_history_instructions,
tags: card.data.tags,
character_version: card.data.character_version,
extensions: card.data.extensions,
};
}
@@ -519,7 +641,7 @@ export async function GET(request: NextRequest) {
}
const changesReport = {
cardName: card.name,
cardName: card.data.name,
cardId: card.id,
totalVersions: card.versions.length,
currentVersion: card.currentVersion,
@@ -545,7 +667,7 @@ export async function GET(request: NextRequest) {
};
// Sanitize filename for download
const sanitizedName = card.name.replace(/[^a-zA-Z0-9\-_]/g, "_");
const sanitizedName = card.data.name.replace(/[^a-zA-Z0-9\-_]/g, "_");
return NextResponse.json(changesReport, {
headers: {
@@ -559,10 +681,12 @@ export async function GET(request: NextRequest) {
{
status: "online",
cards: extractedCards.map((card) => {
const { timestamp, versions, ...cardData } = card;
const { timestamp, versions, ...rest } = card;
const initialVersion = getInitialCardVersion(card);
return {
...cardData,
...rest,
data: card.data,
alternate_greetings: card.data.alternate_greetings || [],
hasVersions: versions && versions.length > 1,
versionCount: versions ? versions.length : 0,
messageCount: card.messageCount || 1,