From aad0d06798eb2a294b3240424c430c0c13e6b8f3 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Tue, 7 Apr 2026 09:52:12 +0000 Subject: [PATCH] Import character card --- src/games/storywriter/components/editor.tsx | 6 +- src/games/storywriter/components/menu.tsx | 38 ++-- src/games/storywriter/contexts/state.tsx | 16 ++ src/games/storywriter/utils/character-card.ts | 199 ++++++++++++++++++ 4 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 src/games/storywriter/utils/character-card.ts diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx index 0af3e99..ecfbe31 100644 --- a/src/games/storywriter/components/editor.tsx +++ b/src/games/storywriter/components/editor.tsx @@ -98,6 +98,10 @@ export const Editor = () => { return highlight(text, false); }, [currentTab, appState]); + const overrideValue = useMemo(() => { + return highlight(currentWorld?.systemInstructionOverride || ''); + }, [currentWorld?.systemInstructionOverride]); + useEffect(() => { if (currentStory?.lastEditedText) { const raf = requestAnimationFrame(() => { @@ -183,7 +187,7 @@ export const Editor = () => { {currentTab === "system" && currentWorld && ( diff --git a/src/games/storywriter/components/menu.tsx b/src/games/storywriter/components/menu.tsx index fab7de4..f37db53 100644 --- a/src/games/storywriter/components/menu.tsx +++ b/src/games/storywriter/components/menu.tsx @@ -5,6 +5,8 @@ import { useAppState } from "../contexts/state"; import { useBool } from "@common/hooks/useBool"; import { useInputState } from "@common/hooks/useInputState"; import type { World, Story } from "../contexts/state"; +import { isWorld } from "../contexts/state"; +import CharacterCard from "../utils/character-card"; import styles from '../assets/menu.module.css'; import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe, Download, Upload, MessagesSquare, MessageSquarePlus } from "lucide-preact"; @@ -257,20 +259,32 @@ export const Menu = () => { const handleImportWorld = () => { const input = document.createElement('input'); input.type = 'file'; - input.accept = '.json,application/json'; - input.onchange = () => { + input.accept = '.json,.png,application/json,image/png'; + input.onchange = async () => { const file = input.files?.[0]; if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - try { - const world = JSON.parse(reader.result as string); - dispatch({ type: 'IMPORT_WORLD', world }); - } catch { - alert('Invalid world file.'); - } - }; - reader.readAsText(file); + if (file.name.endsWith('.png')) { + const card = await CharacterCard.extractFromPng(file); + if (!card) { alert('Invalid character card PNG.'); return; } + dispatch({ type: 'IMPORT_WORLD', world: CharacterCard.toWorld(card) }); + } else { + const reader = new FileReader(); + reader.onload = () => { + try { + const parsed = JSON.parse(reader.result as string); + if (CharacterCard.isV2(parsed)) { + dispatch({ type: 'IMPORT_WORLD', world: CharacterCard.toWorld(parsed) }); + } else if (isWorld(parsed)) { + dispatch({ type: 'IMPORT_WORLD', world: parsed }); + } else { + alert('Invalid file.'); + } + } catch { + alert('Invalid file.'); + } + }; + reader.readAsText(file); + } }; input.click(); }; diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index 7201218..dca4582 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -88,6 +88,22 @@ export interface World { systemInstructionOverride?: string; } +// ─── Type Guards ────────────────────────────────────────────────────────────── + +export function isWorld(obj: unknown): obj is World { + if (typeof obj !== 'object' || obj === null) return false; + const w = obj as Record; + return ( + typeof w.id === 'string' && + typeof w.title === 'string' && + typeof w.chatOnly === 'boolean' && + Array.isArray(w.lore) && + Array.isArray(w.characters) && + Array.isArray(w.locations) && + Array.isArray(w.stories) + ); +} + // ─── State ─────────────────────────────────────────────────────────────────── interface IState { diff --git a/src/games/storywriter/utils/character-card.ts b/src/games/storywriter/utils/character-card.ts new file mode 100644 index 0000000..bd4d8f5 --- /dev/null +++ b/src/games/storywriter/utils/character-card.ts @@ -0,0 +1,199 @@ +import type { World, Story } from "../contexts/state"; + +// ─── Spec Types ─────────────────────────────────────────────────────────────── + +namespace CharacterCard { + export interface V2Data { + name: string; + description: string; + personality: string; + scenario: string; + first_mes: string; + mes_example: string; + system_prompt?: string; + alternate_greetings?: string[]; + post_history_instructions?: string; + creator_notes?: string; + tags?: string[]; + creator?: string; + character_version?: string; + } + + export interface V2 { + spec: "chara_card_v2"; + spec_version: string; + data: V2Data; + } + + // ─── Type Guard ─────────────────────────────────────────────────────────── + + export function isV2(obj: unknown): obj is V2 { + if (typeof obj !== 'object' || obj === null) return false; + const c = obj as Record; + return ( + c.spec === 'chara_card_v2' && + typeof c.spec_version === 'string' && + typeof c.data === 'object' && c.data !== null && + typeof (c.data as Record).name === 'string' + ); + } + + // ─── PNG Parsing ────────────────────────────────────────────────────────── + + /** + * Extracts and parses the chara_card_v2 JSON embedded in a PNG tEXt chunk. + * Returns null if the file is not a valid V2 character card. + */ + export async function extractFromPng(file: File): Promise { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + + // Validate PNG magic bytes + const PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10]; + for (let i = 0; i < PNG_MAGIC.length; i++) { + if (bytes[i] !== PNG_MAGIC[i]) return null; + } + + const view = new DataView(buffer); + let offset = 8; // skip magic + + while (offset + 12 <= bytes.length) { + const length = view.getUint32(offset); + const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]); + + if (type === 'tEXt') { + const dataStart = offset + 8; + const dataEnd = dataStart + length; + const chunkData = bytes.slice(dataStart, dataEnd); + + // Find null separator between keyword and text + const nullIdx = chunkData.indexOf(0); + if (nullIdx === -1) { offset += 12 + length; continue; } + + const keyword = new TextDecoder().decode(chunkData.slice(0, nullIdx)); + if (keyword !== 'chara') { offset += 12 + length; continue; } + + const encoded = new TextDecoder('latin1').decode(chunkData.slice(nullIdx + 1)); + const jsonBytes = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)); + const json = new TextDecoder('utf-8').decode(jsonBytes); + + try { + const card = JSON.parse(json); + if (card?.spec === 'chara_card_v2' && card?.data) { + return card as V2; + } + } catch { + return null; + } + } + + if (type === 'IEND') break; + offset += 12 + length; + } + + return null; + } + + // ─── Variable Substitution ──────────────────────────────────────────────── + + function substituteVars(text: string, charName: string): string { + return text + .replaceAll('{{char}}', charName) + .replaceAll('{{Char}}', charName) + .replaceAll('{{user}}', 'User') + .replaceAll('{{User}}', 'User'); + } + + // ─── Formatting ─────────────────────────────────────────────────────────── + + export const DEFAULT_SYSTEM_INSTRUCTION = + `You are roleplaying as the character described below. Stay in character at all times. Write in first person from the character's perspective. Keep responses engaging and true to the character's personality and background.`; + + /** + * Builds the systemInstructionOverride from a V2 card's data fields. + * Mirrors the formatting style used in prompt.ts. + */ + export function formatSystemPrompt(data: V2Data): string { + const sub = (text: string) => substituteVars(text, data.name); + const parts: string[] = []; + + parts.push(data.system_prompt ? sub(data.system_prompt.trim()) : DEFAULT_SYSTEM_INSTRUCTION); + + if (data.description?.trim()) { + parts.push(`## Description\n${sub(data.description.trim())}`); + } + + if (data.personality?.trim()) { + parts.push(`## Personality\n${sub(data.personality.trim())}`); + } + + if (data.scenario?.trim()) { + parts.push(`## Scenario\n${sub(data.scenario.trim())}`); + } + + if (data.mes_example?.trim()) { + parts.push(`## Example Messages\n${sub(data.mes_example.trim())}`); + } + + return parts.join('\n\n'); + } + + // ─── World Builder ──────────────────────────────────────────────────────── + + function greetingTitle(mes: string): string { + const snippet = mes.trim().slice(0, 40); + return snippet.length < mes.trim().length ? `${snippet}…` : snippet; + } + + /** + * Converts a V2 character card into a chat-only World. + * Creates one story per greeting (first_mes + alternate_greetings). + */ + export function toWorld(card: V2): World { + const { data } = card; + + const greetings: string[] = [ + ...(data.first_mes?.trim() ? [data.first_mes.trim()] : []), + ...(data.alternate_greetings ?? []).filter(g => g?.trim()).map(g => g.trim()), + ]; + + const stories: Story[] = greetings.map(mes => ({ + id: crypto.randomUUID(), + title: greetingTitle(mes), + text: '', + scratchpad: '', + lore: [], + characters: [], + locations: [], + chatMessages: [{ id: crypto.randomUUID(), role: 'assistant', content: substituteVars(mes, data.name) }], + chapters: [], + })); + + if (stories.length === 0) { + stories.push({ + id: crypto.randomUUID(), + title: 'Chat', + text: '', + scratchpad: '', + lore: [], + characters: [], + locations: [], + chatMessages: [], + chapters: [], + }); + } + + return { + id: crypto.randomUUID(), + title: data.name || 'Imported Character', + chatOnly: true, + lore: [], + characters: [], + locations: [], + stories, + systemInstructionOverride: formatSystemPrompt(data), + }; + } +} + +export default CharacterCard;