├── .gitignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── package.json ├── src ├── index.ts └── types.d.ts ├── tsconfig.json └── tsup.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .turbo -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | module.exports = { 5 | arrowParens: 'always', 6 | singleQuote: true, 7 | tabWidth: 2, 8 | trailingComma: 'none' 9 | }; 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 OpenRouter LLC and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Character 2 | 3 | A library for working with character cards. 4 | 5 | ## Acknowledgements 6 | 7 | - https://github.com/SillyTavern/SillyTavern 8 | - https://github.com/ZoltanAI/character-editor 9 | - https://github.com/AVAKSon/character-editor 10 | 11 | ## License 12 | 13 | MIT 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openrouter/character", 3 | "version": "0.0.2", 4 | "description": "A character utility library", 5 | "type": "module", 6 | "module": "./src/index.ts", 7 | "types": "./src/index.ts", 8 | "publishConfig": { 9 | "module": "./dist/index.js", 10 | "types": "./dist/index.d.ts" 11 | }, 12 | "exports": { 13 | ".": { 14 | "import": "./dist/index.js", 15 | "require": "./dist/index.cjs", 16 | "types": "./dist/index.d.ts" 17 | } 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "dev": "tsup --watch", 24 | "build": "tsup", 25 | "prepublishOnly": "pnpm build" 26 | }, 27 | "author": "L ❤️ ☮ ✋", 28 | "contributors": [ 29 | "@louisgv" 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/OpenRouterTeam/character.git" 34 | }, 35 | "license": "MIT", 36 | "keywords": [ 37 | "character", 38 | "AI", 39 | "LLM" 40 | ], 41 | "devDependencies": { 42 | "png-chunks-extract": "1.0.0", 43 | "png-chunk-text": "1.0.0", 44 | "png-chunks-encode": "1.0.0", 45 | "exifreader": "4.13.0", 46 | "json5": "2.2.3", 47 | "tsup": "7.1.0", 48 | "typescript": "5.1.6", 49 | "prettier": "3.0.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'exifreader'; 2 | import json5 from 'json5'; 3 | import { decode } from 'png-chunk-text'; 4 | import pngEncode from 'png-chunks-encode'; 5 | import pngExtract from 'png-chunks-extract'; 6 | 7 | function assertUnreachable(_: never): never { 8 | throw new Error('Statement should be unreachable'); 9 | } 10 | 11 | export type CharacterMetadata = { 12 | alternate_greetings: any[]; 13 | avatar: string; 14 | character_book: null | string; 15 | character_version: string; 16 | chat: string; 17 | create_date: string; 18 | creator: string; 19 | creator_notes: string; 20 | description: string; 21 | extensions: { 22 | chub: { 23 | expressions: null | string; 24 | full_path: string; 25 | id: number; 26 | related_lorebooks: any[]; 27 | }; 28 | fav: boolean; 29 | talkativeness: string; 30 | }; 31 | first_mes: string; 32 | mes_example: string; 33 | name: string; 34 | personality: string; 35 | post_history_instructions: string; 36 | scenario: string; 37 | system_prompt: string; 38 | tags: string[]; 39 | char_greeting: string; 40 | example_dialogue: string; 41 | world_scenario: string; 42 | char_persona: string; 43 | char_name: string; 44 | }; 45 | 46 | const VALID_FILE_TYPES = [ 47 | 'application/json', 48 | 'image/png', 49 | 'image/webp' 50 | ] as const; 51 | 52 | type ValidCharacterFileType = (typeof VALID_FILE_TYPES)[number]; 53 | 54 | const VALID_FILE_EXTENSIONS = VALID_FILE_TYPES.map( 55 | (type) => `.${type.split('/')[1]}` 56 | ); 57 | 58 | const INPUT_ACCEPT = VALID_FILE_EXTENSIONS.join(', '); 59 | 60 | export class Character { 61 | static INPUT_ACCEPT = INPUT_ACCEPT; 62 | 63 | constructor( 64 | public metadata: CharacterMetadata, 65 | private fallbackAvatar = '' 66 | ) {} 67 | 68 | get avatar() { 69 | if (!!this.metadata.avatar && this.metadata.avatar !== 'none') { 70 | return this.metadata.avatar; 71 | } else { 72 | return this.fallbackAvatar; 73 | } 74 | } 75 | 76 | get description() { 77 | return this.metadata.system_prompt || this.metadata.description || ''; 78 | } 79 | 80 | get name() { 81 | return this.metadata.name || this.metadata.char_name || ''; 82 | } 83 | 84 | // Adapted from: https://github.com/SillyTavern/SillyTavern/blob/2befcd87124f30e09496a02e7ce203c3d9ba15fd/src/character-card-parser.js 85 | static async fromFile(file: File): Promise { 86 | const fileType = file.type as ValidCharacterFileType; 87 | 88 | switch (fileType) { 89 | case 'application/json': { 90 | const rawText = await file.text(); 91 | return new Character(JSON.parse(rawText)); 92 | } 93 | case 'image/png': { 94 | // work with v1/v2? 95 | const rawBuffer = await file.arrayBuffer(); 96 | 97 | const chunks = pngExtract(new Uint8Array(rawBuffer)); 98 | 99 | const extChunk = chunks 100 | .filter((chunk) => chunk.name === 'tEXt') 101 | .map((d) => decode(d.data)) 102 | .find((d) => d.keyword === 'chara'); 103 | 104 | if (!extChunk) { 105 | throw new Error('No character data found!'); 106 | } 107 | 108 | const card = JSON.parse( 109 | Buffer.from(extChunk.text, 'base64').toString('utf8') 110 | ); 111 | 112 | const pngChunks = chunks.filter((chunk) => chunk.name !== 'tEXt'); 113 | const base64Avatar = `data:image/png;base64,${Buffer.from( 114 | pngEncode(pngChunks) 115 | ).toString('base64')}`; 116 | 117 | if (card.spec_version === '2.0') { 118 | return new Character(card.data, base64Avatar); 119 | } 120 | 121 | return new Character(card, base64Avatar); 122 | } 123 | case 'image/webp': { 124 | const rawBuffer = await file.arrayBuffer(); 125 | 126 | const exifData = load(rawBuffer); 127 | 128 | const base64Avatar = `data:image/webp;base64,${Buffer.from( 129 | rawBuffer 130 | ).toString('base64')}`; 131 | 132 | if (exifData['UserComment']?.['description']) { 133 | const description = exifData['UserComment']['description']; 134 | 135 | if (description !== 'Undefined') { 136 | return new Character(json5.parse(description), base64Avatar); 137 | } 138 | 139 | if ( 140 | exifData['UserComment'].value && 141 | exifData['UserComment'].value.length === 1 142 | ) { 143 | // silly's way to store json data in webp exif 144 | const _temp = exifData['UserComment'].value as unknown as string[]; 145 | const data = _temp[0]; 146 | 147 | if (!!data) { 148 | const utf8Decoder = new TextDecoder('utf-8', { ignoreBOM: true }); 149 | try { 150 | const card = json5.parse(data); 151 | return new Character(card, base64Avatar); 152 | } catch { 153 | const byteArr = data.split(',').map(Number); 154 | const uint8Array = new Uint8Array(byteArr); 155 | const utf8Data = utf8Decoder.decode(uint8Array); 156 | const card = json5.parse(utf8Data); 157 | return new Character(card, base64Avatar); 158 | } 159 | } 160 | } 161 | } 162 | 163 | throw new Error('No character data found!'); 164 | } 165 | default: { 166 | assertUnreachable(fileType); 167 | } 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | type PngChunk = { 2 | name: 'IHDR' | 'IDAT' | 'IEND' | 'tEXt'; 3 | data: Uint8Array; 4 | }; 5 | 6 | declare module 'png-chunks-extract' { 7 | export default function pngExtract(data: Uint8Array): Array; 8 | } 9 | 10 | declare module 'png-chunks-encode' { 11 | export default function pngEncode(chunks: Array): Uint8Array; 12 | } 13 | 14 | declare module 'png-chunk-text' { 15 | export function encode(key: string, value: string): PngChunk; 16 | export function decode(data: Uint8Array): { 17 | keyword: string; 18 | text: string; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "inlineSources": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "preserveWatchOutput": true, 12 | "skipLibCheck": true, 13 | "strict": false, 14 | "allowJs": true, 15 | "incremental": true, 16 | "resolveJsonModule": true, 17 | "verbatimModuleSyntax": true, 18 | "tsBuildInfoFile": ".tsbuildinfo", 19 | "lib": ["dom", "dom.iterable", "esnext"], 20 | "allowImportingTsExtensions": true, 21 | "moduleResolution": "bundler", 22 | "module": "ESNext", 23 | "target": "ESNext", 24 | "composite": false 25 | }, 26 | "exclude": ["node_modules"], 27 | "include": ["src/**/*.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup" 2 | 3 | export default defineConfig((opt) => { 4 | const isProd = !opt.watch 5 | return { 6 | entry: ["src/index.ts"], 7 | 8 | format: ["esm", "cjs"], 9 | 10 | target: "esnext", 11 | platform: "node", 12 | splitting: false, 13 | bundle: true, 14 | dts: true, 15 | 16 | watch: opt.watch, 17 | sourcemap: !isProd, 18 | minify: isProd, 19 | clean: isProd 20 | } 21 | }) 22 | --------------------------------------------------------------------------------