From d720ddcea530214256581e4701c8f81ca2fa2686 Mon Sep 17 00:00:00 2001 From: Severian Date: Sat, 4 Oct 2025 04:28:02 +0800 Subject: [PATCH] 2.0 --- src/app/api/proxy/route.ts | 264 +++++++++++++++++++++++++--------- src/app/page.tsx | 288 ++++++++++++++++++++++++++----------- 2 files changed, 398 insertions(+), 154 deletions(-) diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index 7b76e02..de5c248 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -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; +} + +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=]`; + 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, diff --git a/src/app/page.tsx b/src/app/page.tsx index bc1773d..434b4ce 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -19,33 +19,48 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Png } from "@/lib/png"; -import { ChevronUp, ChevronDown, Copy } from "lucide-react"; +import { + ChevronUp, + ChevronDown, + Copy, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { CollapsibleContent, Collapsible, CollapsibleTrigger, } from "@/components/ui/collapsible"; -interface Card { - id: string; +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; +} + +interface Card { + id: string; + data: CardDataV2; + trackingName?: string; + spec?: string; + spec_version?: string; avatarUrl?: string; hasVersions?: boolean; versionCount?: number; messageCount?: number; - initialVersion?: { - name: string; - first_mes: string; - description: string; - personality: string; - mes_example: string; - scenario: string; - }; + alternate_greetings?: string[]; + initialVersion?: CardDataV2; } export default function Home() { @@ -62,6 +77,9 @@ export default function Home() { const [changesDialogOpen, setChangesDialogOpen] = useState(false); const [selectedChanges, setSelectedChanges] = useState(null); const [showFullText, setShowFullText] = useState(false); + const [altGreetingIndexById, setAltGreetingIndexById] = useState< + Record + >({}); const fetchCards = async () => { try { @@ -91,30 +109,38 @@ export default function Home() { const downloadJson = (card: Card) => { // Use initial version for download, or current version if no initial version available - const downloadData = card.initialVersion - ? { - name: card.initialVersion.name, - first_mes: card.initialVersion.first_mes, - description: card.initialVersion.description, - personality: card.initialVersion.personality, - mes_example: card.initialVersion.mes_example, - scenario: card.initialVersion.scenario, - } - : { - name: card.name, - first_mes: card.first_mes, - description: card.description, - personality: card.personality, - mes_example: card.mes_example, - scenario: card.scenario, - }; + const chosen = card.initialVersion || card.data; + const downloadData = { + data: { + name: chosen.name, + first_mes: chosen.first_mes, + alternate_greetings: chosen.alternate_greetings || [], + description: chosen.description, + personality: chosen.personality, + mes_example: chosen.mes_example, + scenario: chosen.scenario, + creator: (chosen as any).creator || "", + creator_notes: (chosen as any).creator_notes || "", + system_prompt: (chosen as any).system_prompt || "", + post_history_instructions: + (chosen as any).post_history_instructions || "", + tags: (chosen as any).tags || [], + character_version: (chosen as any).character_version || "1", + extensions: (chosen as any).extensions || {}, + }, + spec: card.spec || "chara_card_v2", + spec_version: card.spec_version || "2.0", + }; const element = document.createElement("a"); const file = new Blob([JSON.stringify(downloadData, null, 2)], { type: "application/json", }); element.href = URL.createObjectURL(file); - element.download = `${card.name.replace(/[^a-zA-Z0-9\-_]/g, "_")}.json`; + element.download = `${(card.initialVersion?.name || card.data.name).replace( + /[^a-zA-Z0-9\-_]/g, + "_" + )}.json`; document.body.appendChild(element); element.click(); document.body.removeChild(element); @@ -133,10 +159,9 @@ export default function Home() { type: "application/json", }); element.href = URL.createObjectURL(file); - element.download = `${card.name.replace( - /[^a-zA-Z0-9\-_]/g, - "_" - )}_changes.json`; + element.download = `${( + card.initialVersion?.name || card.data.name + ).replace(/[^a-zA-Z0-9\-_]/g, "_")}_changes.json`; document.body.appendChild(element); element.click(); document.body.removeChild(element); @@ -194,31 +219,39 @@ export default function Home() { const arrayBuffer = await pngBlob.arrayBuffer(); // Use initial version for PNG embedding, or current version if no initial version available - const pngData = card.initialVersion - ? { - name: card.initialVersion.name, - first_mes: card.initialVersion.first_mes, - description: card.initialVersion.description, - personality: card.initialVersion.personality, - mes_example: card.initialVersion.mes_example, - scenario: card.initialVersion.scenario, - } - : { - name: card.name, - first_mes: card.first_mes, - description: card.description, - personality: card.personality, - mes_example: card.mes_example, - scenario: card.scenario, - }; + const chosen = card.initialVersion || card.data; + const pngData = { + data: { + name: chosen.name, + first_mes: chosen.first_mes, + alternate_greetings: chosen.alternate_greetings || [], + description: chosen.description, + personality: chosen.personality, + mes_example: chosen.mes_example, + scenario: chosen.scenario, + creator: (chosen as any).creator || "", + creator_notes: (chosen as any).creator_notes || "", + system_prompt: (chosen as any).system_prompt || "", + post_history_instructions: + (chosen as any).post_history_instructions || "", + tags: (chosen as any).tags || [], + character_version: (chosen as any).character_version || "1", + extensions: (chosen as any).extensions || {}, + }, + spec: card.spec || "chara_card_v2", + spec_version: card.spec_version || "2.0", + }; const cardData = JSON.stringify(pngData); const newImageData = Png.Generate(arrayBuffer, cardData); const newFileName = `${ - card.name.replace(/[^a-zA-Z0-9\-_]/g, "_") || "character" + (card.initialVersion?.name || card.data.name).replace( + /[^a-zA-Z0-9\-_]/g, + "_" + ) || "character" }.png`; - const newFile = new File([newImageData], newFileName, { + const newFile = new File([new Uint8Array(newImageData)], newFileName, { type: "image/png", }); @@ -283,8 +316,7 @@ export default function Home() {

Sucker v2.0

- Now with multimessage support! Tracks changes to character - descriptions and scenarios across multiple extractions. + A couple of updates, see below.

+ + + + {(() => { + const greetings = + card.alternate_greetings || []; + const index = + altGreetingIndexById[card.id] ?? 0; + const current = greetings.length + ? greetings[index % greetings.length] + : ""; + return ( +
+
+                                        {current}
+                                      
+ +
+ ); + })()} + + )} + {(card.initialVersion?.scenario || + card.data?.scenario) && ( Scenario @@ -486,7 +606,7 @@ export default function Home() {
                                       {card.initialVersion?.scenario ||
-                                        card.scenario}
+                                        card.data.scenario}