Character-Cards / index.html
CultriX's picture
Update index.html
7429fa9 verified
Raw
History Blame Contribute Delete
88.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CardForge - Character Card Studio</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background: #0f172a;
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(168, 85, 247, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(236, 72, 153, 0.15) 0px, transparent 50%),
radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.15) 0px, transparent 50%);
background-attachment: fixed;
}
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
}
.glass-input {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
transition: all 0.3s ease;
}
.glass-input:focus {
background: rgba(15, 23, 42, 0.8);
border-color: rgba(99, 102, 241, 0.5);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
outline: none;
}
.gradient-text {
background: linear-gradient(135deg, #818cf8 0%, #c084fc 50%, #f472b6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.mode-btn {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.mode-btn.active {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.5);
}
.mode-btn.active::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #818cf8, #c084fc);
}
.character-card {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.chat-bubble {
background: rgba(99, 102, 241, 0.2);
border-left: 3px solid #818cf8;
}
.drop-zone {
border: 2px dashed rgba(99, 102, 241, 0.3);
transition: all 0.3s ease;
}
.drop-zone.drag-over {
border-color: #818cf8;
background: rgba(99, 102, 241, 0.1);
}
.tag-pill {
background: rgba(99, 102, 241, 0.2);
border: 1px solid rgba(99, 102, 241, 0.3);
}
.preview-avatar {
background: linear-gradient(135deg, #3730a3 0%, #7c3aed 50%, #db2777 100%);
}
.section-collapse {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.section-collapse.open {
max-height: 1000px;
}
.btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(99, 102, 241, 0.4);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.error-toast {
background: rgba(239, 68, 68, 0.9);
backdrop-filter: blur(10px);
}
.success-toast {
background: rgba(34, 197, 94, 0.9);
backdrop-filter: blur(10px);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.5);
}
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.5);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.7);
}
</style>
</head>
<body class="text-gray-100 min-h-screen flex flex-col">
<!-- Header with Hero Banner -->
<header class="relative overflow-hidden">
<!-- Hero Background -->
<div class="absolute inset-0 z-0">
<img src="https://static.photos/gradient/1200x630/42" alt="" class="w-full h-full object-cover opacity-30">
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-[#0f172a]/80 to-[#0f172a]"></div>
<div class="absolute inset-0 bg-gradient-to-r from-indigo-900/50 via-purple-900/50 to-pink-900/50"></div>
</div>
<div class="glass-panel sticky top-0 z-50 border-b border-white/10 relative">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
<i data-lucide="sparkles" class="w-6 h-6 text-white"></i>
</div>
<div>
<h1 class="text-2xl font-bold gradient-text">CardForge</h1>
<span class="text-xs text-gray-400">Character Card Studio</span>
</div>
</div>
<nav class="flex gap-2 bg-white/5 p-1 rounded-xl border border-white/10">
<button id="btn-create-mode" class="mode-btn active px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 border border-transparent transition-all" onclick="switchMode('create')">
<i data-lucide="plus-circle" class="w-4 h-4"></i>
Create
</button>
<button id="btn-extract-mode" class="mode-btn px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 border border-transparent transition-all" onclick="switchMode('extract')">
<i data-lucide="file-input" class="w-4 h-4"></i>
Extract
</button>
</nav>
</div>
</div>
<!-- Hero Content -->
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm mb-1">Professional Character Creation Suite</p>
<h2 class="text-3xl font-bold text-white" id="hero-title">Create Stunning Characters</h2>
</div>
<div class="flex gap-4 text-center">
<div class="bg-white/5 rounded-xl p-3 border border-white/10 backdrop-blur-sm">
<div class="text-2xl font-bold text-indigo-400" id="stat-fields">0%</div>
<div class="text-xs text-gray-400">Complete</div>
</div>
<div class="bg-white/5 rounded-xl p-3 border border-white/10 backdrop-blur-sm">
<div class="text-2xl font-bold text-purple-400" id="stat-words">0</div>
<div class="text-xs text-gray-400">Words</div>
</div>
<div class="bg-white/5 rounded-xl p-3 border border-white/10 backdrop-blur-sm">
<div class="text-2xl font-bold text-pink-400" id="stat-tags">0</div>
<div class="text-xs text-gray-400">Tags</div>
</div>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 max-w-7xl mx-auto w-full p-4 sm:p-6 lg:p-8">
<!-- CREATE MODE -->
<div id="create-mode" class="grid lg:grid-cols-2 gap-6 h-[calc(100vh-12rem)]">
<!-- Left Panel: Editor -->
<div class="glass-panel rounded-2xl p-6 overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold flex items-center gap-2">
<i data-lucide="pen-tool" class="w-5 h-5 text-indigo-400"></i>
Character Editor
</h2>
<div class="flex gap-2">
<button onclick="autoFillCharacter()" class="btn-secondary px-3 py-1.5 rounded-lg text-sm flex items-center gap-2" title="Auto-fill with example data">
<i data-lucide="wand-2" class="w-4 h-4"></i>
Demo
</button>
<button onclick="resetCharacter()" class="btn-secondary px-3 py-1.5 rounded-lg text-sm flex items-center gap-2">
<i data-lucide="rotate-ccw" class="w-4 h-4"></i>
Reset
</button>
</div>
</div>
<!-- Avatar Upload with better visuals -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-300 mb-2 flex items-center gap-2">
<i data-lucide="image" class="w-4 h-4 text-indigo-400"></i>
Character Avatar
</label>
<div class="drop-zone rounded-xl p-8 text-center cursor-pointer relative group overflow-hidden" id="avatar-drop-zone" onclick="document.getElementById('avatar-input').click()">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-500/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<input type="file" id="avatar-input" class="hidden" accept="image/png,image/jpeg,image/webp" onchange="handleAvatarSelect(event)">
<div id="avatar-preview-container" class="hidden relative z-10">
<img id="avatar-preview" class="w-32 h-48 object-cover rounded-lg mx-auto shadow-2xl mb-3 ring-2 ring-white/20" alt="Avatar">
<p class="text-sm text-gray-400 flex items-center justify-center gap-2">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
Click to change image
</p>
</div>
<div id="avatar-placeholder" class="relative z-10">
<div class="w-20 h-20 rounded-full bg-gradient-to-br from-indigo-500/20 to-purple-500/20 flex items-center justify-center mx-auto mb-3 ring-2 ring-dashed ring-white/20">
<i data-lucide="image-plus" class="w-10 h-10 text-indigo-400"></i>
</div>
<p class="text-sm text-gray-300 font-medium mb-1">Drop image here or click to upload</p>
<p class="text-xs text-gray-500">PNG, JPG, WEBP • Optimal: 400x600</p>
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="mb-6 bg-white/5 rounded-full h-2 overflow-hidden border border-white/10">
<div id="completion-bar" class="h-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 transition-all duration-500" style="width: 0%"></div>
</div>
<p class="text-xs text-gray-400 mb-6 flex items-center gap-2">
<i data-lucide="pie-chart" class="w-3 h-3"></i>
<span id="completion-text">Fill in the character details below</span>
</p>
<!-- Basic Info -->
<div class="space-y-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Character Name *</label>
<input type="text" id="char-name" class="glass-input w-full px-4 py-2.5 rounded-lg" placeholder="Enter character name..." oninput="updatePreview()">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Creator</label>
<input type="text" id="char-creator" class="glass-input w-full px-4 py-2.5 rounded-lg" placeholder="Your name..." oninput="updatePreview()">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Version</label>
<input type="text" id="char-version" class="glass-input w-full px-4 py-2.5 rounded-lg" placeholder="1.0" oninput="updatePreview()">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Tags (comma separated)</label>
<input type="text" id="char-tags" class="glass-input w-full px-4 py-2.5 rounded-lg" placeholder="fantasy, roleplay, friendly..." oninput="updatePreview()">
</div>
</div>
<!-- Content Sections -->
<div class="space-y-4">
<!-- Description -->
<div class="border border-white/10 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
<button onclick="toggleSection('desc-section')" class="w-full px-4 py-3 bg-white/5 flex items-center justify-between hover:bg-white/10 transition-colors">
<span class="font-medium flex items-center gap-2">
<i data-lucide="align-left" class="w-4 h-4 text-indigo-400"></i>
Description
<span class="text-xs text-gray-500 font-normal" id="count-description">0 words</span>
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500" id="status-description"></span>
<i data-lucide="chevron-down" class="w-4 h-4 toggle-icon transition-transform" id="icon-desc-section"></i>
</div>
</button>
<div id="desc-section" class="section-collapse open">
<textarea id="char-description" class="glass-input w-full px-4 py-3 h-32 resize-none border-0 rounded-none" placeholder="Character background, appearance, and details..." oninput="updatePreview()"></textarea>
</div>
</div>
<!-- Personality -->
<div class="border border-white/10 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
<button onclick="toggleSection('personality-section')" class="w-full px-4 py-3 bg-white/5 flex items-center justify-between hover:bg-white/10 transition-colors">
<span class="font-medium flex items-center gap-2">
<i data-lucide="brain" class="w-4 h-4 text-purple-400"></i>
Personality
<span class="text-xs text-gray-500 font-normal" id="count-personality">0 words</span>
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500" id="status-personality"></span>
<i data-lucide="chevron-down" class="w-4 h-4 toggle-icon transition-transform" id="icon-personality-section"></i>
</div>
</button>
<div id="personality-section" class="section-collapse open">
<textarea id="char-personality" class="glass-input w-full px-4 py-3 h-24 resize-none border-0 rounded-none" placeholder="Character traits, behavior patterns, speaking style..." oninput="updatePreview()"></textarea>
</div>
</div>
<!-- Scenario -->
<div class="border border-white/10 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
<button onclick="toggleSection('scenario-section')" class="w-full px-4 py-3 bg-white/5 flex items-center justify-between hover:bg-white/10 transition-colors">
<span class="font-medium flex items-center gap-2">
<i data-lucide="map" class="w-4 h-4 text-pink-400"></i>
Scenario
<span class="text-xs text-gray-500 font-normal" id="count-scenario">0 words</span>
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500" id="status-scenario"></span>
<i data-lucide="chevron-down" class="w-4 h-4 toggle-icon transition-transform" id="icon-scenario-section"></i>
</div>
</button>
<div id="scenario-section" class="section-collapse open">
<textarea id="char-scenario" class="glass-input w-full px-4 py-3 h-24 resize-none border-0 rounded-none" placeholder="The situation or context for the conversation..." oninput="updatePreview()"></textarea>
</div>
</div>
<!-- First Message -->
<div class="border border-white/10 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
<button onclick="toggleSection('firstmsg-section')" class="w-full px-4 py-3 bg-white/5 flex items-center justify-between hover:bg-white/10 transition-colors">
<span class="font-medium flex items-center gap-2">
<i data-lucide="message-square" class="w-4 h-4 text-green-400"></i>
First Message
<span class="text-xs text-gray-500 font-normal" id="count-first-message">0 words</span>
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500" id="status-first-message"></span>
<i data-lucide="chevron-down" class="w-4 h-4 toggle-icon transition-transform" id="icon-firstmsg-section"></i>
</div>
</button>
<div id="firstmsg-section" class="section-collapse open">
<textarea id="char-first-message" class="glass-input w-full px-4 py-3 h-32 resize-none border-0 rounded-none" placeholder="The first message the character sends..." oninput="updatePreview()"></textarea>
</div>
</div>
<!-- Example Messages -->
<div class="border border-white/10 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
<button onclick="toggleSection('examples-section')" class="w-full px-4 py-3 bg-white/5 flex items-center justify-between hover:bg-white/10 transition-colors">
<span class="font-medium flex items-center gap-2">
<i data-lucide="messages-square" class="w-4 h-4 text-yellow-400"></i>
Example Messages
<span class="text-xs text-gray-500 font-normal" id="count-example-messages">0 words</span>
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500" id="status-example-messages"></span>
<i data-lucide="chevron-down" class="w-4 h-4 toggle-icon transition-transform" id="icon-examples-section"></i>
</div>
</button>
<div id="examples-section" class="section-collapse">
<textarea id="char-example-messages" class="glass-input w-full px-4 py-3 h-32 resize-none border-0 rounded-none" placeholder="<START>&#10;{{user}}: Hello there!&#10;{{char}}: Greetings! How can I help you today?" oninput="updatePreview()"></textarea>
<div class="px-4 py-2 bg-white/5 text-xs text-gray-400 flex items-center gap-2">
<i data-lucide="info" class="w-3 h-3"></i>
Use &lt;START&gt; to separate conversations. Use {{user}} and {{char}} placeholders.
</div>
</div>
</div>
<!-- Creator Notes -->
<div class="border border-white/10 rounded-xl overflow-hidden hover:border-white/20 transition-colors">
<button onclick="toggleSection('notes-section')" class="w-full px-4 py-3 bg-white/5 flex items-center justify-between hover:bg-white/10 transition-colors">
<span class="font-medium flex items-center gap-2">
<i data-lucide="lock" class="w-4 h-4 text-red-400"></i>
Creator's Comments (Private)
<span class="text-xs text-gray-500 font-normal" id="count-notes">0 words</span>
</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500" id="status-notes"></span>
<i data-lucide="chevron-down" class="w-4 h-4 toggle-icon transition-transform" id="icon-notes-section"></i>
</div>
</button>
<div id="notes-section" class="section-collapse">
<textarea id="char-notes" class="glass-input w-full px-4 py-3 h-24 resize-none border-0 rounded-none" placeholder="Private notes about the character (not exported)..." oninput="updatePreview()"></textarea>
</div>
</div>
</div>
</div>
<!-- Right Panel: Preview -->
<div class="glass-panel rounded-2xl p-6 flex flex-col sticky top-24 h-fit">
<h2 class="text-xl font-semibold flex items-center gap-2 mb-6">
<i data-lucide="eye" class="w-5 h-5 text-indigo-400"></i>
Live Preview
</h2>
<!-- Character Card Preview -->
<div class="character-card rounded-xl overflow-hidden flex-1 mb-6 shadow-2xl shadow-black/50">
<!-- Card Header with Avatar -->
<div class="relative h-64 preview-avatar" id="card-header-bg">
<div class="absolute inset-0 bg-gradient-to-t from-[#1e293b] via-transparent to-transparent"></div>
<img id="card-avatar" class="absolute bottom-0 left-1/2 -translate-x-1/2 w-32 h-48 object-cover rounded-t-lg shadow-2xl border-2 border-white/20 hidden transition-all duration-300" alt="Character">
<div id="card-avatar-placeholder" class="absolute bottom-0 left-1/2 -translate-x-1/2 w-32 h-48 bg-gradient-to-b from-white/10 to-white/5 rounded-t-lg flex items-center justify-center border-2 border-white/20 border-dashed backdrop-blur-sm">
<div class="text-center">
<div id="placeholder-initials" class="text-4xl font-bold text-white/30 mb-2">?</div>
<i data-lucide="user" class="w-8 h-8 text-white/20 mx-auto"></i>
</div>
</div>
<div class="absolute bottom-4 left-4 right-4">
<h3 id="preview-name" class="text-2xl font-bold text-white mb-1 drop-shadow-lg">Unnamed Character</h3>
<p id="preview-creator" class="text-sm text-gray-300 opacity-80 flex items-center gap-1">
<i data-lucide="user-circle" class="w-3 h-3"></i>
<span>by Unknown Creator</span>
</p>
</div>
</div>
<!-- Card Content -->
<div class="p-4 space-y-4">
<!-- Tags -->
<div id="preview-tags" class="flex flex-wrap gap-2">
<span class="text-sm text-gray-500 italic">No tags added</span>
</div>
<!-- Description -->
<div class="space-y-2 group">
<h4 class="text-sm font-semibold text-indigo-400 uppercase tracking-wider flex items-center gap-2">
<i data-lucide="file-text" class="w-3 h-3"></i>
Description
</h4>
<p id="preview-description" class="text-sm text-gray-300 line-clamp-4 whitespace-pre-wrap">No description provided.</p>
</div>
<!-- Personality -->
<div class="space-y-2 group">
<h4 class="text-sm font-semibold text-purple-400 uppercase tracking-wider flex items-center gap-2">
<i data-lucide="sparkles" class="w-3 h-3"></i>
Personality
</h4>
<p id="preview-personality" class="text-sm text-gray-300 line-clamp-3 whitespace-pre-wrap">No personality defined.</p>
</div>
<!-- First Message Preview -->
<div class="mt-6 pt-4 border-t border-white/10">
<h4 class="text-sm font-semibold text-green-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<i data-lucide="message-circle" class="w-3 h-3"></i>
First Message
</h4>
<div class="chat-bubble rounded-lg p-3 relative">
<i data-lucide="quote" class="w-4 h-4 text-indigo-400/30 absolute top-2 left-2"></i>
<p id="preview-first-message" class="text-sm text-gray-200 whitespace-pre-wrap pl-6">No first message set.</p>
</div>
</div>
</div>
</div>
<!-- Export Actions -->
<div class="space-y-3">
<button onclick="exportJSON()" class="btn-primary w-full py-3 rounded-xl font-medium flex items-center justify-center gap-2">
<i data-lucide="download" class="w-5 h-5"></i>
Download JSON
</button>
<div class="grid grid-cols-2 gap-3">
<button onclick="exportPNG()" class="btn-secondary w-full py-3 rounded-xl font-medium flex items-center justify-center gap-2">
<i data-lucide="image-down" class="w-5 h-5"></i>
Export PNG
</button>
<button onclick="loadExtractedToEditor()" class="btn-secondary w-full py-3 rounded-xl font-medium flex items-center justify-center gap-2 opacity-50 cursor-not-allowed" id="btn-import-extracted" disabled>
<i data-lucide="upload" class="w-5 h-5"></i>
Load to Editor
</button>
</div>
</div>
</div>
</div>
<!-- EXTRACT MODE -->
<div id="extract-mode" class="hidden h-[calc(100vh-12rem)] flex flex-col lg:flex-row gap-6">
<!-- Left: Upload -->
<div class="lg:w-1/3 glass-panel rounded-2xl p-6 flex flex-col relative overflow-hidden">
<!-- Decorative background -->
<div class="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-indigo-500/20 to-purple-500/20 rounded-full blur-3xl -mr-16 -mt-16"></div>
<h2 class="text-xl font-semibold flex items-center gap-2 mb-6 relative z-10">
<i data-lucide="file-up" class="w-5 h-5 text-indigo-400"></i>
Import Character
</h2>
<div class="flex-1 flex flex-col justify-center relative z-10">
<div class="drop-zone rounded-2xl p-12 text-center border-2 border-dashed cursor-pointer transition-all hover:scale-[1.02] group" id="file-drop-zone" onclick="document.getElementById('file-input').click()">
<input type="file" id="file-input" class="hidden" accept=".png,.webp,.jpeg,.jpg,.json" onchange="handleFileSelect(event)">
<div class="w-20 h-20 rounded-full bg-gradient-to-br from-indigo-500/20 to-purple-500/20 flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<i data-lucide="upload-cloud" class="w-10 h-10 text-indigo-400"></i>
</div>
<h3 class="text-lg font-medium mb-2">Drop character card here</h3>
<p class="text-sm text-gray-400 mb-4">or click to browse</p>
<div class="flex flex-wrap justify-center gap-2 text-xs">
<span class="px-3 py-1.5 bg-indigo-500/20 text-indigo-300 rounded-full border border-indigo-500/30">PNG</span>
<span class="px-3 py-1.5 bg-purple-500/20 text-purple-300 rounded-full border border-purple-500/30">WEBP</span>
<span class="px-3 py-1.5 bg-pink-500/20 text-pink-300 rounded-full border border-pink-500/30">JPEG</span>
<span class="px-3 py-1.5 bg-green-500/20 text-green-300 rounded-full border border-green-500/30">JSON</span>
</div>
</div>
<div class="mt-6 space-y-3">
<div class="p-4 bg-indigo-500/10 rounded-xl border border-indigo-500/20">
<h4 class="text-sm font-medium text-indigo-400 mb-2 flex items-center gap-2">
<i data-lucide="info" class="w-4 h-4"></i>
Supported Formats
</h4>
<ul class="text-xs text-gray-400 space-y-1.5">
<li class="flex items-center gap-2">
<i data-lucide="check" class="w-3 h-3 text-green-400"></i>
SillyTavern PNG cards (with metadata)
</li>
<li class="flex items-center gap-2">
<i data-lucide="check" class="w-3 h-3 text-green-400"></i>
Character Hub exports
</li>
<li class="flex items-center gap-2">
<i data-lucide="check" class="w-3 h-3 text-green-400"></i>
TavernAI V1/V2/V3 specification
</li>
<li class="flex items-center gap-2">
<i data-lucide="check" class="w-3 h-3 text-green-400"></i>
Raw JSON character files
</li>
</ul>
</div>
<!-- File Stats -->
<div id="extract-file-info" class="hidden p-4 bg-white/5 rounded-xl border border-white/10">
<h4 class="text-xs font-medium text-gray-400 mb-3 uppercase tracking-wider">File Information</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Name:</span>
<span id="file-info-name" class="text-gray-300 truncate max-w-[150px]">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Size:</span>
<span id="file-info-size" class="text-gray-300">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Format:</span>
<span id="file-info-format" class="text-indigo-400">-</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Spec:</span>
<span id="file-info-spec" class="text-purple-400">-</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right: Results -->
<div class="lg:flex-1 glass-panel rounded-2xl p-6 overflow-hidden flex flex-col">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold flex items-center gap-2">
<i data-lucide="file-text" class="w-5 h-5 text-indigo-400"></i>
Extracted Data
</h2>
<div class="flex gap-2">
<button onclick="loadExtractedToEditor()" class="btn-primary px-4 py-2 rounded-lg text-sm flex items-center gap-2 hidden" id="btn-load-to-editor">
<i data-lucide="edit-3" class="w-4 h-4"></i>
Edit in Creator
</button>
<button onclick="showSystemPromptModal()" class="btn-secondary px-4 py-2 rounded-lg text-sm flex items-center gap-2 hidden" id="btn-system-prompt" style="background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.2)); border-color: rgba(99, 102, 241, 0.4);">
<i data-lucide="terminal" class="w-4 h-4"></i>
System Prompt
</button>
<button onclick="downloadExtractedJSON()" class="btn-secondary px-4 py-2 rounded-lg text-sm flex items-center gap-2 hidden" id="btn-download-json">
<i data-lucide="download" class="w-4 h-4"></i>
JSON
</button>
</div>
</div>
<div id="extract-placeholder" class="flex-1 flex flex-col items-center justify-center text-gray-500">
<div class="relative">
<div class="absolute inset-0 bg-indigo-500/20 blur-3xl rounded-full"></div>
<img src="https://static.photos/abstract/320x240/123" alt="" class="w-40 h-32 object-cover rounded-2xl opacity-50 mb-4 relative z-10 border border-white/10">
</div>
<p class="text-lg mb-2">Ready to Extract</p>
<p class="text-sm text-gray-600 max-w-md text-center">Drop a character card file to extract and view all embedded metadata including personality, scenario, and example messages.</p>
</div>
<div id="extract-content" class="hidden flex-1 overflow-y-auto space-y-4 pr-2">
<div class="grid grid-cols-2 gap-4">
<div class="bg-white/5 rounded-lg p-4 border border-white/10">
<label class="text-xs text-gray-400 uppercase tracking-wider">Name</label>
<p id="extract-name" class="text-lg font-medium text-white mt-1">-</p>
</div>
<div class="bg-white/5 rounded-lg p-4 border border-white/10">
<label class="text-xs text-gray-400 uppercase tracking-wider">Creator</label>
<p id="extract-creator" class="text-lg font-medium text-white mt-1">-</p>
</div>
<div class="bg-white/5 rounded-lg p-4 border border-white/10">
<label class="text-xs text-gray-400 uppercase tracking-wider">Version</label>
<p id="extract-version" class="text-sm text-gray-300 mt-1">-</p>
</div>
<div class="bg-white/5 rounded-lg p-4 border border-white/10">
<label class="text-xs text-gray-400 uppercase tracking-wider">Tags</label>
<div id="extract-tags" class="flex flex-wrap gap-1 mt-1">-</div>
</div>
</div>
<div class="space-y-4">
<div class="bg-white/5 rounded-lg p-4 border border-white/10">
<label class="text-xs text-gray-400 uppercase tracking-wider mb-2 block">Description</label>
<div id="extract-description" class="text-sm text-gray-300 whitespace-pre-wrap max-h-32 overflow-y-auto">-</div>
</div>
<div class="bg-white/5 rounded-lg p-4 border border-white/10">
<label class="text-xs text-gray-400 uppercase tracking-wider mb-2 block">Personality</label>
<div id="extract-personality" class="text-sm text-gray-300 whitespace-pre-wrap max-h-32 overflow-y-auto">-</div>
</div>
<div class="bg-white/5 rounded-lg p-4 border border-white/10">
<label class="text-xs text-gray-400 uppercase tracking-wider mb-2 block">Scenario</label>
<div id="extract-scenario" class="text-sm text-gray-300 whitespace-pre-wrap max-h-32 overflow-y-auto">-</div>
</div>
<div class="bg-white/5 rounded-lg p-4 border border-white/10">
<label class="text-xs text-gray-400 uppercase tracking-wider mb-2 block">First Message</label>
<div id="extract-first-message" class="text-sm text-gray-300 whitespace-pre-wrap max-h-32 overflow-y-auto chat-bubble rounded p-3">-</div>
</div>
<div class="bg-white/5 rounded-lg p-4 border border-white/10" id="extract-examples-container" style="display: none;">
<label class="text-xs text-gray-400 uppercase tracking-wider mb-2 block">Example Messages</label>
<div id="extract-example-messages" class="text-sm text-gray-300 whitespace-pre-wrap max-h-32 overflow-y-auto font-mono text-xs">-</div>
</div>
</div>
<div class="mt-4 p-4 bg-indigo-500/10 rounded-lg border border-indigo-500/20">
<h4 class="text-sm font-medium text-indigo-400 mb-2">Technical Info</h4>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-400">
<div>Format: <span id="extract-format" class="text-gray-300">-</span></div>
<div>Spec: <span id="extract-spec" class="text-gray-300">-</span></div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- System Prompt Export Modal -->
<div id="system-prompt-modal" class="fixed inset-0 z-50 hidden">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" onclick="closeSystemPromptModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="glass-panel rounded-2xl w-full max-w-3xl max-h-[90vh] flex flex-col shadow-2xl border border-indigo-500/30">
<div class="p-6 border-b border-white/10 flex items-center justify-between bg-gradient-to-r from-indigo-500/10 to-purple-500/10">
<div>
<h3 class="text-xl font-semibold text-white flex items-center gap-2">
<i data-lucide="terminal" class="w-5 h-5 text-indigo-400"></i>
System Prompt Export
</h3>
<p class="text-sm text-gray-400 mt-1">Optimized instructions for LLM roleplay</p>
</div>
<button onclick="closeSystemPromptModal()" class="btn-secondary p-2 rounded-lg">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="p-6 flex-1 overflow-hidden flex flex-col">
<div class="flex items-center justify-between mb-4">
<div class="flex gap-2">
<button onclick="copySystemPrompt()" class="btn-primary px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<i data-lucide="copy" class="w-4 h-4"></i>
Copy to Clipboard
</button>
<button onclick="downloadSystemPrompt()" class="btn-secondary px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<i data-lucide="download" class="w-4 h-4"></i>
Download .txt
</button>
</div>
<span class="text-xs text-gray-500" id="prompt-char-count">0 characters</span>
</div>
<div class="flex-1 overflow-hidden rounded-xl border border-white/10 bg-[#0f172a]/80 relative group">
<textarea id="system-prompt-output" class="w-full h-full p-4 bg-transparent text-gray-300 font-mono text-sm resize-none focus:outline-none custom-scrollbar" readonly placeholder="Generate a system prompt to see the output here..."></textarea>
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-xs text-gray-500 bg-black/50 px-2 py-1 rounded">Read-only</span>
</div>
</div>
<div class="mt-4 p-4 bg-indigo-500/10 rounded-xl border border-indigo-500/20">
<h4 class="text-sm font-medium text-indigo-400 mb-2 flex items-center gap-2">
<i data-lucide="info" class="w-4 h-4"></i>
How to Use
</h4>
<ul class="text-xs text-gray-400 space-y-1.5">
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-3 h-3 text-green-400 mt-0.5"></i>
<span>Copy this prompt into your LLM's system message or initial instruction</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-3 h-3 text-green-400 mt-0.5"></i>
<span>Works best with Claude, GPT-4, Llama, and other roleplay-capable models</span>
</li>
<li class="flex items-start gap-2">
<i data-lucide="check" class="w-3 h-3 text-green-400 mt-0.5"></i>
<span>Format automatically adapts based on available character data</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container" class="fixed bottom-4 right-4 z-50 flex flex-col gap-2"></div>
<!-- Hidden canvas for PNG processing -->
<canvas id="png-canvas" class="hidden"></canvas>
<script type="module">
// Import char-card-reader library for v1/v2/v3 spec support
import { CharacterCard } from 'https://esm.run/@lenml/char-card-reader';
// Initialize Lucide icons
lucide.createIcons();
// Global state
let currentCharacter = {
name: '',
description: '',
personality: '',
scenario: '',
first_mes: '',
mes_example: '',
creatorcomment: '',
creator: '',
character_version: '',
tags: [],
data: {}
};
let extractedCharacter = null;
let currentAvatar = null;
let currentFileName = 'character';
// CRC32 table for PNG metadata
const CRC32_TABLE = new Int32Array(256).map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
return c;
});
function crc32(bytes) {
let c = -1;
for (let i = 0; i < bytes.length; i++) {
c = CRC32_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
}
return c ^ -1;
}
// Mode switching
function switchMode(mode) {
document.getElementById('create-mode').classList.add('hidden');
document.getElementById('extract-mode').classList.add('hidden');
document.getElementById('btn-create-mode').classList.remove('active');
document.getElementById('btn-extract-mode').classList.remove('active');
if (mode === 'create') {
document.getElementById('create-mode').classList.remove('hidden');
document.getElementById('btn-create-mode').classList.add('active');
} else {
document.getElementById('extract-mode').classList.remove('hidden');
document.getElementById('btn-extract-mode').classList.add('active');
}
}
// Section toggling
function toggleSection(id) {
const section = document.getElementById(id);
const icon = document.getElementById('icon-' + id);
if (section.classList.contains('open')) {
section.classList.remove('open');
icon.style.transform = 'rotate(-90deg)';
} else {
section.classList.add('open');
icon.style.transform = 'rotate(0deg)';
}
}
// Avatar handling
function handleAvatarSelect(event) {
const file = event.target.files[0];
if (file) processAvatar(file);
}
function processAvatar(file) {
if (!file.type.startsWith('image/')) {
showToast('Please upload an image file', 'error');
return;
}
currentFileName = file.name.replace(/\.[^/.]+$/, "");
const reader = new FileReader();
reader.onload = (e) => {
currentAvatar = e.target.result;
document.getElementById('avatar-preview').src = currentAvatar;
document.getElementById('card-avatar').src = currentAvatar;
document.getElementById('avatar-preview-container').classList.remove('hidden');
document.getElementById('avatar-placeholder').classList.add('hidden');
document.getElementById('card-avatar').classList.remove('hidden');
document.getElementById('card-avatar-placeholder').classList.add('hidden');
updatePreview();
showToast('Avatar uploaded successfully', 'success');
};
reader.readAsDataURL(file);
}
// Drag and drop
const avatarDropZone = document.getElementById('avatar-drop-zone');
avatarDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
avatarDropZone.classList.add('drag-over');
});
avatarDropZone.addEventListener('dragleave', () => {
avatarDropZone.classList.remove('drag-over');
});
avatarDropZone.addEventListener('drop', (e) => {
e.preventDefault();
avatarDropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) processAvatar(file);
});
const fileDropZone = document.getElementById('file-drop-zone');
fileDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
fileDropZone.classList.add('drag-over');
});
fileDropZone.addEventListener('dragleave', () => {
fileDropZone.classList.remove('drag-over');
});
fileDropZone.addEventListener('drop', (e) => {
e.preventDefault();
fileDropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) processExtractFile(file);
});
// Word count helper
function countWords(text) {
if (!text || !text.trim()) return 0;
return text.trim().split(/\s+/).length;
}
// Update field statistics
function updateFieldStats() {
const fields = ['description', 'personality', 'scenario', 'first-message', 'example-messages', 'notes'];
const requiredFields = ['name', 'description', 'first-message'];
let completedRequired = 0;
let totalWords = 0;
fields.forEach(field => {
const text = document.getElementById(`char-${field}`).value || '';
const wordCount = countWords(text);
const countEl = document.getElementById(`count-${field}`);
const statusEl = document.getElementById(`status-${field}`);
if (countEl) countEl.textContent = `${wordCount} words`;
if (statusEl) {
if (wordCount > 0) {
statusEl.innerHTML = '<i data-lucide="check-circle-2" class="w-3 h-3 text-green-400"></i>';
if (requiredFields.includes(field)) completedRequired++;
} else {
statusEl.innerHTML = '•';
}
}
totalWords += wordCount;
});
// Update completion stats
const completionPercent = Math.round((completedRequired / requiredFields.length) * 100);
document.getElementById('completion-bar').style.width = `${completionPercent}%`;
document.getElementById('completion-text').textContent = completionPercent === 100 ? 'Character complete! Ready to export.' : `${completionPercent}% complete - Fill in required fields`;
// Update hero stats
document.getElementById('stat-fields').textContent = `${completionPercent}%`;
document.getElementById('stat-words').textContent = totalWords.toLocaleString();
const tags = document.getElementById('char-tags').value.split(',').map(t => t.trim()).filter(t => t);
document.getElementById('stat-tags').textContent = tags.length;
// Update hero title
const name = document.getElementById('char-name').value;
document.getElementById('hero-title').textContent = name ? `Editing: ${name}` : 'Create Stunning Characters';
// Update placeholder initials
const placeholder = document.getElementById('placeholder-initials');
if (placeholder && name) {
placeholder.textContent = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
}
// Re-create icons for dynamic content
lucide.createIcons();
}
// Demo data for testing
function autoFillCharacter() {
const demoData = {
name: 'Aurora Starweaver',
description: 'A mysterious celestial mage who wanders between dimensions. She has silver hair that flows like stardust and eyes that shimmer with the colors of nebulae. Aurora speaks in riddles and often references cosmic events that haven\'t happened yet. She carries a staff made of crystallized moonlight and wears robes that seem to contain actual galaxies within their fabric.',
personality: 'Enigmatic, wise, playful yet distant, speaks in metaphors, protective of innocent souls, has a mischievous streak, tends to appear when least expected but most needed.',
scenario: 'You discover an ancient observatory deep in a forgotten forest. As you touch the central crystal, the air shimmers and Aurora materializes, having been waiting for someone worthy to find this place for centuries.',
first_mes: '*The air around you seems to thicken with stardust as a figure materializes from the cosmic light* Oh? Finally, someone with enough curiosity to find my little sanctuary. I\'ve been waiting... oh, about three centuries give or take a decade. *tilts head with a knowing smile* You\'re not what I expected, but then again, reality rarely is. Tell me, traveler, what brings you to the edge of the known universe?',
mes_example: '<START>\n{{user}}: Who are you?\n{{char}}: *laughs softly, the sound like wind chimes in a meteor shower* Names are such fleeting things, aren\'t they? Some call me Aurora, some call me trouble, and one very confused astronomer once called me "that bright thing that broke my telescope." *winks* But you may call me whatever feels right. The universe has many names for its children.\n<START>\n{{user}}: Can you teach me magic?\n{{char}}: *considers this with the gravity of someone watching a star being born* Magic isn\'t taught, dear one. It\'s remembered. You already know it - you just forgot somewhere between your first breath and this moment. *extends hand* Shall we help you remember?',
creatorcomment: 'Designed for fantasy/sci-fi roleplay. Works well in both serious and lighthearted scenarios. Can be adapted to different settings by adjusting the observatory location.',
creator: 'CardForge Demo',
character_version: '1.0',
tags: ['fantasy', 'magic', 'celestial', 'mysterious', 'female', 'ai assistant']
};
updateFormFromCharacter(demoData);
updatePreview();
showToast('Demo character loaded!', 'success');
}
// Character data management
function updateCharacterFromForm() {
currentCharacter = {
name: document.getElementById('char-name').value || 'Unnamed',
description: document.getElementById('char-description').value,
personality: document.getElementById('char-personality').value,
scenario: document.getElementById('char-scenario').value,
first_mes: document.getElementById('char-first-message').value,
mes_example: document.getElementById('char-example-messages').value,
creatorcomment: document.getElementById('char-notes').value,
creator: document.getElementById('char-creator').value,
character_version: document.getElementById('char-version').value || '1.0',
tags: document.getElementById('char-tags').value.split(',').map(t => t.trim()).filter(t => t),
data: {}
};
updateFieldStats();
}
function updateFormFromCharacter(char) {
document.getElementById('char-name').value = char.name || '';
document.getElementById('char-description').value = char.description || '';
document.getElementById('char-personality').value = char.personality || '';
document.getElementById('char-scenario').value = char.scenario || '';
document.getElementById('char-first-message').value = char.first_mes || '';
document.getElementById('char-example-messages').value = char.mes_example || '';
document.getElementById('char-notes').value = char.creatorcomment || '';
document.getElementById('char-creator').value = char.creator || '';
document.getElementById('char-version').value = char.character_version || '1.0';
document.getElementById('char-tags').value = (char.tags || []).join(', ');
}
function updatePreview() {
updateCharacterFromForm();
document.getElementById('preview-name').textContent = currentCharacter.name || 'Unnamed Character';
document.getElementById('preview-creator').textContent = currentCharacter.creator ? `by ${currentCharacter.creator}` : 'by Unknown Creator';
const tagsContainer = document.getElementById('preview-tags');
if (currentCharacter.tags.length > 0) {
tagsContainer.innerHTML = currentCharacter.tags.map(tag =>
`<span class="tag-pill px-2 py-1 rounded text-xs text-indigo-300">${tag}</span>`
).join('');
} else {
tagsContainer.innerHTML = '<span class="text-sm text-gray-500 italic">No tags added</span>';
}
document.getElementById('preview-description').textContent = currentCharacter.description || 'No description provided.';
document.getElementById('preview-personality').textContent = currentCharacter.personality || 'No personality defined.';
document.getElementById('preview-first-message').textContent = currentCharacter.first_mes || 'No first message set.';
}
function resetCharacter() {
if (confirm('Are you sure you want to reset all fields?')) {
document.getElementById('char-name').value = '';
document.getElementById('char-description').value = '';
document.getElementById('char-personality').value = '';
document.getElementById('char-scenario').value = '';
document.getElementById('char-first-message').value = '';
document.getElementById('char-example-messages').value = '';
document.getElementById('char-notes').value = '';
document.getElementById('char-creator').value = '';
document.getElementById('char-version').value = '';
document.getElementById('char-tags').value = '';
currentAvatar = null;
document.getElementById('avatar-preview-container').classList.add('hidden');
document.getElementById('avatar-placeholder').classList.remove('hidden');
document.getElementById('card-avatar').classList.add('hidden');
document.getElementById('card-avatar-placeholder').classList.remove('hidden');
updatePreview();
showToast('Character reset', 'success');
}
}
// PNG Metadata handling
function stringToBytes(str) {
return new TextEncoder().encode(str);
}
function bytesToString(bytes) {
return new TextDecoder().decode(bytes);
}
function readPNGChunks(arrayBuffer) {
const view = new DataView(arrayBuffer);
const chunks = [];
let offset = 8; // Skip PNG signature
while (offset < arrayBuffer.byteLength) {
const length = view.getUint32(offset);
const type = bytesToString(new Uint8Array(arrayBuffer, offset + 4, 4));
const data = new Uint8Array(arrayBuffer, offset + 8, length);
const crc = view.getUint32(offset + 8 + length);
chunks.push({ type, data, crc });
offset += 12 + length;
if (type === 'IEND') break;
}
return chunks;
}
function writePNGChunk(type, data) {
const typeBytes = stringToBytes(type);
const chunk = new Uint8Array(4 + 4 + data.length + 4);
const view = new DataView(chunk.buffer);
view.setUint32(0, data.length);
chunk.set(typeBytes, 4);
chunk.set(data, 8);
const crc = crc32(new Uint8Array(chunk.buffer, 4, 4 + data.length));
view.setUint32(8 + data.length, crc >>> 0);
return chunk;
}
function embedMetadataToPNG(arrayBuffer, metadata) {
const chunks = readPNGChunks(arrayBuffer);
const jsonStr = JSON.stringify(metadata);
const base64Data = btoa(unescape(encodeURIComponent(jsonStr)));
const textData = stringToBytes(`chara\u0000${base64Data}`);
// Find IDAT position to insert before it
let insertIndex = chunks.findIndex(c => c.type === 'IDAT');
if (insertIndex === -1) insertIndex = chunks.length - 1;
const tEXtChunk = writePNGChunk('tEXt', textData);
const newChunks = [
...chunks.slice(0, insertIndex),
{ type: 'tEXt', data: textData, crc: 0 },
...chunks.slice(insertIndex)
];
// Rebuild PNG
const pngSignature = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
const parts = [pngSignature];
for (const chunk of newChunks) {
if (chunk.type === 'tEXt' && chunk.data === textData) {
parts.push(tEXtChunk);
} else {
const len = chunk.data.length;
const header = new ArrayBuffer(8);
const view = new DataView(header);
view.setUint32(0, len);
const typeBytes = stringToBytes(chunk.type);
const typeArr = new Uint8Array(header, 4, 4);
typeArr.set(typeBytes);
parts.push(new Uint8Array(header));
parts.push(chunk.data);
const crcBuf = new ArrayBuffer(4);
const crcView = new DataView(crcBuf);
crcView.setUint32(0, chunk.crc >>> 0);
parts.push(new Uint8Array(crcBuf));
}
}
let totalLength = 0;
for (const part of parts) totalLength += part.length;
const result = new Uint8Array(totalLength);
let pos = 0;
for (const part of parts) {
result.set(part, pos);
pos += part.length;
}
return result.buffer;
}
function extractMetadataFromPNG(arrayBuffer) {
const chunks = readPNGChunks(arrayBuffer);
for (const chunk of chunks) {
if (chunk.type === 'tEXt') {
const text = bytesToString(chunk.data);
if (text.startsWith('chara\u0000')) {
try {
const base64Data = text.substring(6);
const jsonStr = decodeURIComponent(escape(atob(base64Data)));
const data = JSON.parse(jsonStr);
return { success: true, data, format: 'PNG tEXt' };
} catch (e) {
console.error('Error parsing tEXt chunk:', e);
}
}
}
}
return { success: false, error: 'No character metadata found' };
}
// Export functions
function exportJSON() {
updateCharacterFromForm();
const data = {
...currentCharacter,
tags: currentCharacter.tags
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${currentCharacter.name || 'character'}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('JSON exported successfully', 'success');
}
async function exportPNG() {
updateCharacterFromForm();
if (!currentAvatar) {
// Generate a simple placeholder image
const canvas = document.getElementById('png-canvas');
canvas.width = 400;
canvas.height = 600;
const ctx = canvas.getContext('2d');
// Create gradient background
const gradient = ctx.createLinearGradient(0, 0, 400, 600);
gradient.addColorStop(0, '#3730a3');
gradient.addColorStop(0.5, '#7c3aed');
gradient.addColorStop(1, '#db2777');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 400, 600);
// Add text
ctx.fillStyle = 'white';
ctx.font = 'bold 32px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(currentCharacter.name || 'Character', 200, 300);
ctx.font = '20px Inter, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fillText('Generated by CardForge', 200, 340);
const dataUrl = canvas.toDataURL('image/png');
currentAvatar = dataUrl;
}
try {
// Convert data URL to ArrayBuffer
const response = await fetch(currentAvatar);
const arrayBuffer = await response.arrayBuffer();
// Embed metadata in v2 spec format for better compatibility
const metadata = {
spec: 'chara_card_v2',
spec_version: '2.0',
name: currentCharacter.name,
description: currentCharacter.description,
personality: currentCharacter.personality,
scenario: currentCharacter.scenario,
first_mes: currentCharacter.first_mes,
mes_example: currentCharacter.mes_example,
creatorcomment: currentCharacter.creatorcomment,
creator: currentCharacter.creator,
character_version: currentCharacter.character_version || '1.0',
tags: currentCharacter.tags || [],
data: {
name: currentCharacter.name,
description: currentCharacter.description,
personality: currentCharacter.personality,
scenario: currentCharacter.scenario,
first_mes: currentCharacter.first_mes,
mes_example: currentCharacter.mes_example,
creatorcomment: currentCharacter.creatorcomment,
creator: currentCharacter.creator,
character_version: currentCharacter.character_version || '1.0',
tags: currentCharacter.tags || []
}
};
const pngWithMetadata = embedMetadataToPNG(arrayBuffer, metadata);
const blob = new Blob([pngWithMetadata], { type: 'image/png' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${currentCharacter.name || 'character'}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('PNG exported with metadata', 'success');
} catch (error) {
showToast('Error exporting PNG: ' + error.message, 'error');
console.error(error);
}
}
// Extract functions
function handleFileSelect(event) {
const file = event.target.files[0];
if (file) processExtractFile(file);
}
async function processExtractFile(file) {
try {
const arrayBuffer = await file.arrayBuffer();
const extension = file.name.split('.').pop().toLowerCase();
// Store file stats
const fileStats = {
name: file.name,
size: file.size,
extension: extension
};
let result = null;
if (extension === 'json') {
// Parse JSON directly
const text = new TextDecoder().decode(arrayBuffer);
const jsonData = JSON.parse(text);
const card = CharacterCard.from_json(jsonData);
const data = card.toSpecV3();
result = {
success: true,
data: data,
format: 'JSON (Char Card Reader)'
};
} else if (['png', 'webp', 'jpg', 'jpeg'].includes(extension)) {
// Use char-card-reader library for images
const card = await CharacterCard.from_file(arrayBuffer);
const data = card.toSpecV3();
result = {
success: true,
data: data,
format: `${extension.toUpperCase()} (Char Card Reader v1/v2/v3)`
};
} else {
showToast('Unsupported file format', 'error');
return;
}
if (result.success) {
extractedCharacter = result.data;
displayExtractedData(result.data, result.format, fileStats);
showToast('Character extracted successfully', 'success');
}
} catch (error) {
const errorMsg = error && error.message ? error.message : String(error);
showToast('Error reading file: ' + errorMsg, 'error');
console.error('Extraction error:', error);
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function generateSystemPrompt(characterData) {
const data = characterData.data || characterData;
const name = data.name || characterData.name || 'Character';
const creator = characterData.creator || data.creator || 'Unknown';
// Build sections
let sections = [];
// Header/Role Definition
sections.push(`You are ${name}. ${creator !== 'Unknown' ? `This character was created by ${creator}.` : ''}`);
// Core Identity
sections.push(`\n=== CORE IDENTITY ===`);
sections.push(`You must embody ${name} completely. Never break character. Never refer to yourself as an AI, language model, or assistant. You are ${name}, and you have always been ${name}.`);
// Description/Appearance
if (data.description) {
sections.push(`\n=== APPEARANCE & BACKGROUND ===`);
sections.push(data.description);
}
// Personality
if (data.personality) {
sections.push(`\n=== PERSONALITY & BEHAVIOR ===`);
sections.push(`Your personality traits: ${data.personality}`);
}
// Scenario/Context
if (data.scenario) {
sections.push(`\n=== CURRENT SITUATION ===`);
sections.push(`You are currently in this scenario: ${data.scenario}`);
sections.push(`Stay true to this context. Respond naturally as if you are living in this moment.`);
}
// Speaking Style Guidelines
sections.push(`\n=== SPEAKING STYLE ===`);
if (data.mes_example) {
sections.push(`Study these example exchanges to understand how you speak:`);
sections.push(`\n${data.mes_example}`);
} else if (data.first_mes) {
sections.push(`Observe your opening message to understand your tone:`);
sections.push(`${data.first_mes}`);
}
sections.push(`\nGeneral guidelines:`);
sections.push(`- Speak in first person ("I", "me", "my")`);
sections.push(`- Use {{user}} to refer to the person you're talking to`);
sections.push(`- Be proactive, not just reactive. Initiate actions when appropriate`);
sections.push(`- Show personality through your word choice, sentence structure, and actions`);
sections.push(`- Describe your physical actions and expressions in italics or asterisks`);
sections.push(`- Maintain consistency with your established character throughout`);
// Conversation Rules
sections.push(`\n=== CONVERSATION RULES ===`);
sections.push(`1. **Stay In Character**: Never acknowledge you are an AI or break the fourth wall.`);
sections.push(`2. **Proactive Roleplay**: Don't just answer questions. Ask questions, share thoughts, initiate actions.`);
sections.push(`3. **Emotional Depth**: Show genuine emotion appropriate to your character and the situation.`);
sections.push(`4. **Physical Presence**: Regularly describe your body language, facial expressions, and physical actions.`);
sections.push(`5. **Memory Consistency**: Remember details shared in the conversation and reference them appropriately.`);
sections.push(`6. **Natural Speech**: Speak as a real person would, not as an encyclopedia. Use contractions, slang, and informal language if it fits your character.`);
// First Message Context (if provided)
if (data.first_mes) {
sections.push(`\n=== OPENING CONTEXT ===`);
sections.push(`Your first message to {{user}} should be something like this, but adapt based on where the conversation actually goes:`);
sections.push(`${data.first_mes}`);
}
// Additional Traits (Tags)
if (characterData.tags && characterData.tags.length > 0) {
sections.push(`\n=== CHARACTER TAGS ===`);
sections.push(`Key themes: ${characterData.tags.join(', ')}`);
}
// Creator Notes (if present and relevant)
if (data.creatorcomment || data.creator_comment) {
sections.push(`\n=== NOTES FROM CREATOR ===`);
sections.push(data.creatorcomment || data.creator_comment);
}
// Final instructions
sections.push(`\n=== FINAL INSTRUCTIONS ===`);
sections.push(`You are ${name}. Embody this character fully. Be authentic, consistent, and engaging. Make the user feel like they are truly talking to ${name} in the scenario described above.`);
sections.push(`\nBegin the roleplay now.`);
return sections.join('\n');
}
function showSystemPromptModal() {
if (!extractedCharacter) {
showToast('No character data extracted', 'error');
return;
}
const prompt = generateSystemPrompt(extractedCharacter);
const output = document.getElementById('system-prompt-output');
output.value = prompt;
// Update char count
document.getElementById('prompt-char-count').textContent = `${prompt.length.toLocaleString()} characters`;
// Show modal
document.getElementById('system-prompt-modal').classList.remove('hidden');
// Re-initialize icons in modal
lucide.createIcons();
}
function closeSystemPromptModal() {
document.getElementById('system-prompt-modal').classList.add('hidden');
}
function copySystemPrompt() {
const output = document.getElementById('system-prompt-output');
output.select();
output.setSelectionRange(0, 99999); // For mobile
try {
navigator.clipboard.writeText(output.value);
showToast('System prompt copied to clipboard', 'success');
} catch (err) {
document.execCommand('copy');
showToast('System prompt copied to clipboard', 'success');
}
}
function downloadSystemPrompt() {
const prompt = document.getElementById('system-prompt-output').value;
if (!prompt) return;
const name = (extractedCharacter?.data?.name || extractedCharacter?.name || 'character').replace(/[^a-z0-9]/gi, '_').toLowerCase();
const blob = new Blob([prompt], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}_system_prompt.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('System prompt downloaded', 'success');
}
function displayExtractedData(data, format, fileStats = null) {
document.getElementById('extract-placeholder').classList.add('hidden');
document.getElementById('extract-content').classList.remove('hidden');
document.getElementById('btn-load-to-editor').classList.remove('hidden');
document.getElementById('btn-download-json').classList.remove('hidden');
document.getElementById('btn-system-prompt').classList.remove('hidden');
// Show file info if available
if (fileStats) {
document.getElementById('extract-file-info').classList.remove('hidden');
document.getElementById('file-info-name').textContent = fileStats.name;
document.getElementById('file-info-size').textContent = formatFileSize(fileStats.size);
document.getElementById('file-info-format').textContent = fileStats.extension.toUpperCase();
}
// Also enable the "Load to Editor" button in Create mode
const importBtn = document.getElementById('btn-import-extracted');
if (importBtn) {
importBtn.disabled = false;
importBtn.classList.remove('opacity-50', 'cursor-not-allowed');
}
// Handle v3 spec nested data structure
const charData = data.data || data;
document.getElementById('extract-name').textContent = charData.name || data.name || 'Unnamed';
document.getElementById('extract-creator').textContent = data.creator || charData.creator || 'Unknown';
document.getElementById('extract-version').textContent = data.character_version || charData.character_version || '1.0';
document.getElementById('extract-format').textContent = format;
document.getElementById('extract-spec').textContent = data.spec || 'Unknown';
// Update spec in file info
const specEl = document.getElementById('file-info-spec');
if (specEl) specEl.textContent = data.spec || 'Unknown';
const tags = charData.tags || data.tags || [];
const tagsContainer = document.getElementById('extract-tags');
if (tags.length > 0) {
tagsContainer.innerHTML = tags.map(tag =>
`<span class="tag-pill px-2 py-0.5 rounded text-xs text-indigo-300">${tag}</span>`
).join('');
} else {
tagsContainer.innerHTML = '<span class="text-gray-500">No tags</span>';
}
document.getElementById('extract-description').textContent = charData.description || '-';
document.getElementById('extract-personality').textContent = charData.personality || '-';
document.getElementById('extract-scenario').textContent = charData.scenario || '-';
document.getElementById('extract-first-message').textContent = charData.first_mes || '-';
if (charData.mes_example) {
document.getElementById('extract-examples-container').style.display = 'block';
document.getElementById('extract-example-messages').textContent = charData.mes_example;
} else {
document.getElementById('extract-examples-container').style.display = 'none';
}
}
function loadExtractedToEditor() {
if (!extractedCharacter) return;
// Handle v3 spec nested data structure
const charData = extractedCharacter.data || extractedCharacter;
// Create a flat character object for the form
const flatCharacter = {
name: charData.name || extractedCharacter.name || '',
description: charData.description || '',
personality: charData.personality || '',
scenario: charData.scenario || '',
first_mes: charData.first_mes || '',
mes_example: charData.mes_example || '',
creatorcomment: charData.creatorcomment || charData.creator_comment || '',
creator: extractedCharacter.creator || charData.creator || '',
character_version: extractedCharacter.character_version || charData.character_version || '1.0',
tags: charData.tags || extractedCharacter.tags || []
};
updateFormFromCharacter(flatCharacter);
updatePreview();
switchMode('create');
showToast('Loaded into editor', 'success');
}
function downloadExtractedJSON() {
if (!extractedCharacter) return;
const blob = new Blob([JSON.stringify(extractedCharacter, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${extractedCharacter.name || 'character'}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('JSON downloaded', 'success');
}
// Toast notifications
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const colors = type === 'error' ? 'error-toast' : type === 'success' ? 'success-toast' : 'glass-panel';
const icon = type === 'error' ? 'x-circle' : type === 'success' ? 'check-circle' : 'info';
toast.className = `${colors} px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] transform transition-all duration-300 translate-x-full`;
toast.innerHTML = `
<i data-lucide="${icon}" class="w-5 h-5"></i>
<span class="font-medium">${message}</span>
`;
container.appendChild(toast);
lucide.createIcons();
setTimeout(() => toast.classList.remove('translate-x-full'), 10);
setTimeout(() => {
toast.classList.add('translate-x-full');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Initialize
updatePreview();
updateFieldStats();
// Expose all functions to window for HTML onclick handlers
window.switchMode = switchMode;
window.toggleSection = toggleSection;
window.handleAvatarSelect = handleAvatarSelect;
window.processAvatar = processAvatar;
window.updatePreview = updatePreview;
window.resetCharacter = resetCharacter;
window.exportJSON = exportJSON;
window.exportPNG = exportPNG;
window.handleFileSelect = handleFileSelect;
window.processExtractFile = processExtractFile;
window.loadExtractedToEditor = loadExtractedToEditor;
window.downloadExtractedJSON = downloadExtractedJSON;
window.importToEditor = loadExtractedToEditor; // Alias for the button in create mode
window.autoFillCharacter = autoFillCharacter; // Demo data function
window.showSystemPromptModal = showSystemPromptModal;
window.closeSystemPromptModal = closeSystemPromptModal;
window.copySystemPrompt = copySystemPrompt;
window.downloadSystemPrompt = downloadSystemPrompt;
// Expose CharacterCard to window for debugging (optional)
window.CharacterCard = CharacterCard;
</script>
<script src="https://deepsite.hf.co/deepsite-badge.js"></script>
</body>
</html>