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,

View File

@@ -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<string, unknown>;
}
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<any>(null);
const [showFullText, setShowFullText] = useState(false);
const [altGreetingIndexById, setAltGreetingIndexById] = useState<
Record<string, number>
>({});
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() {
<div>
<h1 className="text-3xl font-bold">Sucker v2.0</h1>
<p className="text-sm text-muted-foreground">
Now with multimessage support! Tracks changes to character
descriptions and scenarios across multiple extractions.
A couple of updates, see below.
</p>
</div>
<Button
@@ -301,13 +333,24 @@ export default function Home() {
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex flex-col justify-between">
<span className="text-lg font-semibold text-blue-800 dark:text-blue-200">
New: Multimessage Support
V2 charcard format, multi-turn support for scripts/lorebooks,
alternate greetings.
</span>
<p className="text-sm text-muted-foreground">
Sucker now tracks changes to character descriptions and
scenarios across multiple messages. Cards with multiple versions
show a version badge and offer a "Download Changes" button to
get the change history.
<br />
Alternate greetings are also supported. Sucker will provide you
with a conversation ID that you can use to start off a new chat
when capturing alternate greetings, send it as first message
instead of the character name.
<br />
Directions are updated below. Make sure you read 'em.
<br />
If you're interested in hosting your own sucker instance, lmk
via Discord: @lyseverian, I've privated the GH repo for now.
</p>
</div>
</div>
@@ -354,17 +397,25 @@ export default function Home() {
Save settings and refresh the page. Not this page. <i>That</i>{" "}
page.
</li>
<li className="mb-2">Start a new chat with a character.</li>
<li className="mb-2">
Start a new chat with a character or multiple.
</li>
<li className="mb-2">
You can either send a dot to let sucker make a best guess
about the char name, or send the char name yourself and it'll
be used instead.
Char name inference is implemented, but the prompt structure
changed. You'll need to send the character name yourself.
</li>
<li className="mb-2">
Hit the Refresh button here, and the cards should appear here.
</li>
<li className="mb-2">
If you're interested in capturing alternate greetings, start a
new chat and send the conversation ID as first message instead
of the character name. The format is{" "}
<code>[sucker:conv=conversationId]</code> which you'll be
given when creating a new card.
</li>
<li className="mb-2">
You can also send more messages with possible keywords to trigger scripts/lorebooks. Sucker will track changes to the description and scenario fields. Cards with multiple versions will show a version badge and offer a "Download Changes" button to get a detailed change
history with timestamps. Unfortunately, lorebook creation is out of scope at the moment, but you can use the changes detected to modify the character card yourself post-export.
</li>
<li className="mb-2">
Download the JSON files or go through a little more effort to
get PNGs instead.
@@ -375,13 +426,6 @@ export default function Home() {
discarded. Reloading the page will remove any attached avatars.
I'm not storing shit.
</p>
<p className="mb-2">
<strong>New:</strong> If you send multiple messages with the
same character name, Sucker will track changes to the
description and scenario fields. Cards with multiple versions
will show a version badge and offer a "Download Changes" button
to get a detailed change history with timestamps.
</p>
</div>
</CollapsibleContent>
</Collapsible>
@@ -401,7 +445,9 @@ export default function Home() {
<AccordionItem value={`card-${index}`}>
<AccordionTrigger className="text-xl font-semibold">
<div className="flex items-center gap-2">
{card.name || "Unnamed Card"}
{card.initialVersion?.name ||
card.data?.name ||
"Unnamed Card"}
<div className="flex gap-1">
{card.hasVersions && (
<span className="text-sm bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded-full">
@@ -419,7 +465,7 @@ export default function Home() {
<AccordionContent>
<div id={`card-${index}`} className="space-y-4 mt-4">
{(card.initialVersion?.description ||
card.description) && (
card.data?.description) && (
<Accordion type="single" collapsible>
<AccordionItem value="description">
<AccordionTrigger>Description</AccordionTrigger>
@@ -427,7 +473,7 @@ export default function Home() {
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.description ||
card.description}
card.data.description}
</pre>
<Button
variant="ghost"
@@ -436,7 +482,7 @@ export default function Home() {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.description ||
card.description
card.data.description
);
}}
>
@@ -448,7 +494,7 @@ export default function Home() {
</Accordion>
)}
{(card.initialVersion?.first_mes ||
card.first_mes) && (
card.data?.first_mes) && (
<Accordion type="single" collapsible>
<AccordionItem value="first-message">
<AccordionTrigger>
@@ -458,7 +504,7 @@ export default function Home() {
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.first_mes ||
card.first_mes}
card.data.first_mes}
</pre>
<Button
variant="ghost"
@@ -467,7 +513,7 @@ export default function Home() {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.first_mes ||
card.first_mes
card.data.first_mes
);
}}
>
@@ -478,7 +524,81 @@ export default function Home() {
</AccordionItem>
</Accordion>
)}
{(card.initialVersion?.scenario || card.scenario) && (
{card.alternate_greetings &&
card.alternate_greetings.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{`Alternate Greetings (${
card.alternate_greetings?.length || 0
})`}</h4>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => {
const greetings =
card.alternate_greetings || [];
if (greetings.length === 0) return;
setAltGreetingIndexById((prev) => {
const current = prev[card.id] ?? 0;
const next =
(current - 1 + greetings.length) %
greetings.length;
return { ...prev, [card.id]: next };
});
}}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
const greetings =
card.alternate_greetings || [];
if (greetings.length === 0) return;
setAltGreetingIndexById((prev) => {
const current = prev[card.id] ?? 0;
const next =
(current + 1) % greetings.length;
return { ...prev, [card.id]: next };
});
}}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{(() => {
const greetings =
card.alternate_greetings || [];
const index =
altGreetingIndexById[card.id] ?? 0;
const current = greetings.length
? greetings[index % greetings.length]
: "";
return (
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{current}
</pre>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (!current) return;
copyToClipboard(current);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
);
})()}
</div>
)}
{(card.initialVersion?.scenario ||
card.data?.scenario) && (
<Accordion type="single" collapsible>
<AccordionItem value="scenario">
<AccordionTrigger>Scenario</AccordionTrigger>
@@ -486,7 +606,7 @@ export default function Home() {
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.scenario ||
card.scenario}
card.data.scenario}
</pre>
<Button
variant="ghost"
@@ -495,7 +615,7 @@ export default function Home() {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.scenario ||
card.scenario
card.data.scenario
);
}}
>
@@ -507,7 +627,7 @@ export default function Home() {
</Accordion>
)}
{(card.initialVersion?.mes_example ||
card.mes_example) && (
card.data?.mes_example) && (
<Accordion type="single" collapsible>
<AccordionItem value="example-messages">
<AccordionTrigger>
@@ -517,7 +637,7 @@ export default function Home() {
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.mes_example ||
card.mes_example}
card.data.mes_example}
</pre>
<Button
variant="ghost"
@@ -526,7 +646,7 @@ export default function Home() {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.mes_example ||
card.mes_example
card.data.mes_example
);
}}
>
@@ -538,7 +658,7 @@ export default function Home() {
</Accordion>
)}
{(card.initialVersion?.personality ||
card.personality) && (
card.data?.personality) && (
<Accordion type="single" collapsible>
<AccordionItem value="personality">
<AccordionTrigger>Personality</AccordionTrigger>
@@ -546,7 +666,7 @@ export default function Home() {
<div className="flex justify-between">
<pre className="whitespace-pre-wrap font-sans text-sm">
{card.initialVersion?.personality ||
card.personality}
card.data.personality}
</pre>
<Button
variant="ghost"
@@ -555,7 +675,7 @@ export default function Home() {
e.stopPropagation();
copyToClipboard(
card.initialVersion?.personality ||
card.personality
card.data.personality
);
}}
>