Code Reader
首页
帮助
设计文档
首页
帮助
设计文档
  • SillyTavern 角色卡系统技术分析

SillyTavern 角色卡系统技术分析

概述

SillyTavern 的角色卡系统是一个多格式、多层次的复杂架构,支持多种角色卡规范和导入/导出格式。本文档详细分析该系统的实现原理和关键技术点。


1. 角色卡格式体系

1.1 格式对比概览

特性V1 格式V2 格式V3 格式CharX 格式
存储方式PNG tEXt chunkPNG tEXt chunkPNG tEXt chunkZIP 压缩包
Chunk 名称characharaccv3card.json
编码方式Base64Base64Base64UTF-8 JSON
数据结构扁平化嵌套 data 对象嵌套 data 对象嵌套 data 对象 + 资源文件
支持资源仅头像仅头像仅头像多文件资源
向后兼容-读取 V1读取 V2/V1转换为内部格式

1.2 V2 格式详解

V2 格式使用 chara PNG chunk 存储 Base64 编码的 JSON 数据。

数据结构示例:

{
  "spec": "chara_card_v2",
  "spec_version": "2.0",
  "name": "Character Name",
  "description": "Character description...",
  "personality": "Personality traits...",
  "scenario": "Scenario context...",
  "first_mes": "First message...",
  "mes_example": "Example messages...",
  "creatorcomment": "Creator notes (legacy)",
  "talkativeness": 0.5,
  "fav": false,
  "tags": ["tag1", "tag2"],
  "data": {
    "name": "Character Name",
    "description": "...",
    "personality": "...",
    "scenario": "...",
    "first_mes": "...",
    "mes_example": "...",
    "creator_notes": "...",
    "system_prompt": "...",
    "post_history_instructions": "...",
    "alternate_greetings": [],
    "tags": [],
    "creator": "Creator Name",
    "character_version": "1.0",
    "extensions": {
      "talkativeness": 0.5,
      "fav": false,
      "world": "",
      "depth_prompt": {
        "prompt": "",
        "depth": 4,
        "role": "system"
      }
    }
  }
}

1.3 V3 格式详解

V3 格式使用 ccv3 PNG chunk,与 V2 的主要区别在于:

  1. Chunk 标识不同:使用 ccv3 而非 chara
  2. Spec 声明更新:spec: "chara_card_v3", spec_version: "3.0"
  3. 读取优先级:ccv3 优先于 chara chunk

V2 vs V3 关键区别:

方面V2V3
Chunk 关键字characcv3
Spec 值chara_card_v2chara_card_v3
版本号2.03.0
优先级低高(优先读取)
创建方式原生支持从 V2 数据自动转换

写入逻辑(character-card-parser.js):

// 写入时同时创建 V2 和 V3 chunks
export const write = (image, data) => {
    // 1. 清除现有 chara/ccv3 chunks
    // 2. 添加新的 V2 chunk
    chunks.splice(-1, 0, PNGtext.encode('chara', base64EncodedData));
    // 3. 尝试添加 V3 chunk(从 V2 数据转换)
    const v3Data = JSON.parse(data);
    v3Data.spec = 'chara_card_v3';
    v3Data.spec_version = '3.0';
    chunks.splice(-1, 0, PNGtext.encode('ccv3', base64EncodedV3Data));
};

读取逻辑:

export const read = (image) => {
    // V3 (ccv3) 优先
    const ccv3Index = textChunks.findIndex(chunk => chunk.keyword.toLowerCase() === 'ccv3');
    if (ccv3Index > -1) {
        return Buffer.from(textChunks[ccv3Index].text, 'base64').toString('utf8');
    }
    // 降级到 V2 (chara)
    const charaIndex = textChunks.findIndex(chunk => chunk.keyword.toLowerCase() === 'chara');
    if (charaIndex > -1) {
        return Buffer.from(textChunks[charaIndex].text, 'base64').toString('utf8');
    }
};

1.4 CharX 格式详解

CharX 是一种基于 ZIP 压缩包的多文件角色卡格式,支持嵌入多个资源文件。

文件结构:

character.charx (ZIP)
├── card.json          # 角色卡元数据(CCv2 或 CCv3 格式)
├── assets/            # 资源文件目录
│   ├── icon_main.png  # 主头像
│   ├── emotion_happy.png
│   ├── emotion_sad.png
│   ├── expression_angry.png
│   ├── background_room.png
│   └── ...
└── ...

URI 引用格式:

  • embedded://assets/icon_main.png
  • embeded://assets/emotion_happy.png(兼容 RisuAI 的拼写错误)
  • __asset:assets/background_room.png

CharX 资产类型映射(charx.js):

CharX 类型ST 存储类别存储路径
icon, user_icon头像角色 PNG 文件本身
emotion, expressionspritecharacters/{charName}/
backgroundbackgroundcharacters/{charName}/backgrounds/
其他miscuser/images/{charName}/

CharX 导入流程:

CharX ZIP 文件
    ↓
CharXParser.parse()
    ├─→ 提取 card.json
    ├─→ 解析 data.assets 数组
    ├─→ 收集所有嵌入式资源 URI
    ├─→ 提取头像(type=icon)
    └─→ 映射辅助资源(emotion/expression/background)
    ↓
persistCharXAssets()
    ├─→ Sprites → characters/{name}/
    ├─→ Backgrounds → characters/{name}/backgrounds/
    └─→ Misc → user/images/{name}/
    ↓
writeCharacterData() → PNG 文件

CharX 优势:

  1. 多资源支持:可包含表情、背景、语音等多媒体资源
  2. 标准化结构:清晰的资产分类和引用方式
  3. 跨平台兼容:ZIP 格式广泛支持
  4. 渐进加载:资源按需提取,不一次性加载全部

2. PNG 元数据系统

2.1 PNG tEXt Chunk 结构

PNG 文件使用 tEXt chunk 存储文本元数据,结构如下:

tEXt Chunk Structure:
+------------------+------------------+------------------+
| Keyword (n bytes)| Null separator   | Text (m bytes)   |
| (Latin-1 编码)    | (1 byte: 0x00)   | (Latin-1 编码)    |
+------------------+------------------+------------------+

关键技术细节:

  • 使用 png-chunks-extract 库提取 chunks
  • 使用 png-chunk-text 库编码/解码 tEXt chunks
  • 数据使用 Base64 编码存储
  • 新 chunk 插入在 IEND chunk 之前

2.2 PNG 编码实现(png/encode.js)

// PNG 文件签名
const PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];

// Chunk 结构:Length (4 bytes) | Type (4 bytes) | Data (n bytes) | CRC (4 bytes)
// 每个 chunk 的 CRC32 校验涵盖 Type 和 Data 部分

编码流程:

  1. 写入 PNG 文件签名(8 bytes)
  2. 遍历所有 chunks:
    • 写入数据长度(4 bytes, big-endian)
    • 写入 chunk 类型(4 bytes ASCII)
    • 写入数据内容
    • 写入 CRC32 校验值(4 bytes)
  3. 返回完整 PNG 数据

2.3 角色卡数据写入流程

输入:图像缓冲区 + JSON 数据
    ↓
write(image, data)
    ├─→ extract(Uint8Array) → PNG chunks[]
    ├─→ 过滤现有 chara/ccv3 tEXt chunks
    ├─→ 删除旧的元数据 chunks
    ├─→ Base64 编码数据
    ├─→ 插入新 chara chunk(在 IEND 之前)
    ├─→ 尝试插入新 ccv3 chunk
    ↓
encode(chunks) → Uint8Array
    ↓
输出:带元数据的新 PNG 缓冲区

3. 角色管理功能

3.1 数据流 Mermaid 流程图

3.2 详细数据流程

角色列表获取流程

POST /api/characters/all
    ↓
readdir(charactersDir) → .png 文件列表
    ↓
Promise.all(pngFiles.map(file => processCharacter(file, {shallow})))
    ↓
processCharacter:
    ├─→ readCharacterData(imgFile) [带缓存]
    ├─→ getCharaCardV2(JSON.parse(data))
    ├─→ calculateChatSize(chatsDir)
    ├─→ toShallow(character) [如果 shallow=true]
    ↓
返回角色列表

角色创建/编辑流程

POST /api/characters/create 或 /edit
    ↓
charaFormatData(request.body) → V2 格式对象
    ↓
writeCharacterData(inputFile, charJSON, fileName, request, crop)
    ├─→ 重置内存缓存
    ├─→ 添加到磁盘缓存同步队列
    ├─→ getInputImage() / parseImageBuffer()
    │   └─→ Jimp.fromBuffer() → crop/resize → PNG 缓冲区
    ├─→ write(inputImage, charJSON) → 嵌入 PNG chunk
    └─→ writeFileAtomicSync() → 原子写入
    ↓
返回状态

多格式导入流程

POST /api/characters/import
    ↓
formatImportFunctions[format]
    ├─→ png: importFromPng → readCharacterData → writeCharacterData
    ├─→ json: importFromJson → parse → writeCharacterData
    ├─→ yaml: importFromYaml → yaml.parse → convertToV2 → writeCharacterData
    ├─→ charx: importFromCharX → CharXParser.parse → persistCharXAssets → writeCharacterData
    └─→ byaf: importFromByaf → ByafParser.parse → writeCharacterData
    ↓
返回 file_name

4. 缓存机制设计

4.1 双层缓存架构

┌─────────────────────────────────────────────────────────────┐
│                        读取请求                              │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│  Layer 1: Memory Cache (MemoryLimitedMap)                   │
│  ├─ 容量: 可配置 (默认 100MB)                                │
│  ├─ 键: `${filePath}-${mtimeMs}`                            │
│  ├─ 值: 字符卡 JSON 字符串                                   │
│  ├─ 驱逐策略: LRU (当内存不足时移除最旧条目)                  │
│  └─ 跳过条件: Android 平台                                   │
└─────────────────────────────────────────────────────────────┘
                            │ 未命中
                            ▼
┌─────────────────────────────────────────────────────────────┐
│  Layer 2: Disk Cache (node-persist)                         │
│  ├─ 位置: `DATA_ROOT/_cache/characters/`                    │
│  ├─ 键: 同上                                                │
│  ├─ 值: 同上                                                │
│  ├─ 同步间隔: 5 分钟                                        │
│  └─ 验证: 启动时清理无效条目                                 │
└─────────────────────────────────────────────────────────────┘
                            │ 未命中
                            ▼
                    从 PNG 文件解析

4.2 MemoryLimitedMap 实现细节

核心算法:

export class MemoryLimitedMap {
    constructor(cacheCapacity) {
        this.maxMemory = bytes.parse(cacheCapacity) ?? 0;  // 最大内存限制
        this.currentMemory = 0;                             // 当前内存使用
        this.map = new Map();                               // 键值存储
        this.queue = [];                                    // LRU 队列
    }

    static estimateStringSize(str) {
        return str ? str.length * 2 : 0;  // UTF-16: 2 bytes/char
    }

    set(key, value) {
        const newValueSize = MemoryLimitedMap.estimateStringSize(value);
        
        // 如果单条超过限制,拒绝
        if (newValueSize > this.maxMemory) return;
        
        // 驱逐旧条目直到有足够空间
        while (this.currentMemory + newValueSize > this.maxMemory && this.queue.length > 0) {
            const oldestKey = this.queue.shift();
            const oldestValue = this.map.get(oldestKey);
            this.map.delete(oldestKey);
            this.currentMemory -= MemoryLimitedMap.estimateStringSize(oldestValue);
        }
        
        // 添加新条目
        this.map.set(key, value);
        this.queue.push(key);
        this.currentMemory += newValueSize;
    }
}

内存计算:

  • 假设 UTF-16 编码,每字符 2 字节
  • 典型角色卡大小:5-20KB
  • 100MB 缓存可存储约 5000-20000 个角色卡

4.3 DiskCache 设计

配置参数:

class DiskCache {
    static DIRECTORY = 'characters';
    static SYNC_INTERVAL = 5 * 60 * 1000;  // 5 分钟
    
    // node-persist 配置
    const storage = {
        dir: cachePath,
        ttl: false,                    // 不过期
        forgiveParseErrors: true,      // 宽容解析错误
        expiredInterval: 0,            // 不检查过期
        maxFileDescriptors: 100,       // 最大文件描述符
    };
}

同步机制:

// 每 5 分钟执行一次
async #syncCacheEntries() {
    const directories = [...this.syncQueue].map(entry => getUserDirectories(entry));
    this.syncQueue.clear();
    await this.verify(directories);  // 清理无效条目
}

// 验证:删除已不存在的角色文件对应的缓存
async verify(directoriesList) {
    for (const dir of directoriesList) {
        const files = fs.readdirSync(dir.characters, { withFileTypes: true });
        // 只保留存在的 PNG 文件对应的缓存键
        const validKeys = new Set(files.filter(f => f.isFile() && path.extname(f.name) === '.png')
            .map(file => path.parse(cache.getDatumPath(getCacheKey(filePath))).base));
    }
}

缓存键生成:

function getCacheKey(inputFile) {
    if (fs.existsSync(inputFile)) {
        const stat = fs.statSync(inputFile);
        return `${inputFile}-${stat.mtimeMs}`;  // 包含修改时间
    }
    return inputFile;
}

5. 角色卡验证系统

5.1 TavernCardValidator 架构

export class TavernCardValidator {
    #lastValidationError = null;

    validate() {
        if (this.validateV1()) return 1;
        if (this.validateV2()) return 2;
        if (this.validateV3()) return 3;
        return false;
    }
}

5.2 各版本验证规则

V1 验证(传统格式):

validateV1() {
    const requiredFields = [
        'name', 'description', 'personality', 
        'scenario', 'first_mes', 'mes_example'
    ];
    return requiredFields.every(field => Object.hasOwn(this.card, field));
}

V2 验证(完整格式):

validateV2() {
    return this.#validateSpecV2()           // spec === 'chara_card_v2'
        && this.#validateSpecVersionV2()    // spec_version === '2.0'
        && this.#validateDataV2()           // data 对象验证
        && this.#validateCharacterBookV2(); // character_book 验证
}

#validateDataV2() {
    const requiredFields = [
        'name', 'description', 'personality', 'scenario',
        'first_mes', 'mes_example', 'creator_notes', 'system_prompt',
        'post_history_instructions', 'alternate_greetings', 'tags',
        'creator', 'character_version', 'extensions'
    ];
    // 检查所有必需字段 + 数组类型验证
}

V3 验证(最新格式):

validateV3() {
    return this.#validateSpecV3()           // spec === 'chara_card_v3'
        && this.#validateSpecVersionV3()    // 3.0 <= version < 4.0
        && this.#validateDataV3();          // data 对象存在
}

5.3 验证失败处理

router.post('/merge-attributes', async (request, response) => {
    const validator = new TavernCardValidator(character);
    
    if (validator.validate()) {
        await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request);
        response.sendStatus(200);
    } else {
        response.status(400).send({
            message: `Validation failed for ${character.name}`,
            error: validator.lastValidationError
        });
    }
});

6. 头像处理系统

6.1 Jimp 图像处理配置

// jimp.js - 自定义 Jimp 实例
const Jimp = createJimp({
    formats: [
        webp, png, jpeg, avif,  // WASM 优化格式
        bmp, msBmp, gif, tiff   // JS 实现格式
    ],
    plugins: [
        blit, circle, color, contain, cover,
        crop, displace, fisheye, flip, mask,
        resize, rotate, threshold, quantize
    ],
});

6.2 头像裁剪与缩放

标准尺寸:

export const AVATAR_WIDTH = 512;
export const AVATAR_HEIGHT = 768;

处理流程:

export async function applyAvatarCropResize(jimp, crop) {
    let finalWidth = image.bitmap.width;
    let finalHeight = image.bitmap.height;

    // 1. 应用裁剪(如果指定)
    if (crop && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) {
        image.crop({ x: crop.x, y: crop.y, w: crop.width, h: crop.height });
        
        // 2. 应用标准缩放(如果请求)
        if (crop.want_resize) {
            finalWidth = AVATAR_WIDTH;
            finalHeight = AVATAR_HEIGHT;
        }
    }

    // 3. 使用 cover 模式调整尺寸(保持比例,裁剪溢出)
    image.cover({ w: finalWidth, h: finalHeight });
    
    return await image.getBuffer(JimpMime.png);
}

7. 数据转换与迁移

7.1 V1 到 V2 转换

function convertToV2(char, directories) {
    return charaFormatData({
        json_data: JSON.stringify(char),
        ch_name: char.name,
        description: char.description,
        personality: char.personality,
        scenario: char.scenario,
        first_mes: char.first_mes,
        mes_example: char.mes_example,
        creator_notes: char.creatorcomment,
        talkativeness: char.talkativeness,
        fav: char.fav,
        creator: char.creator,
        tags: char.tags,
        depth_prompt_prompt: char.depth_prompt_prompt,
        depth_prompt_depth: char.depth_prompt_depth,
        depth_prompt_role: char.depth_prompt_role,
    }, directories);
}

7.2 V2 读取与字段映射

function readFromV2(char) {
    const fieldMappings = {
        name: 'name',
        description: 'description',
        personality: 'personality',
        scenario: 'scenario',
        first_mes: 'first_mes',
        mes_example: 'mes_example',
        talkativeness: 'extensions.talkativeness',
        fav: 'extensions.fav',
        tags: 'tags',
    };

    _.forEach(fieldMappings, (v2Path, charField) => {
        const v2Value = _.get(char.data, v2Path);
        if (_.isUndefined(v2Value)) {
            // 填充默认值
            if (v2Path === 'extensions.talkativeness') defaultValue = 0.5;
            if (v2Path === 'extensions.fav') defaultValue = false;
        }
        char[charField] = v2Value;
    });
}

7.3 隐私字段清理

function unsetPrivateFields(char) {
    _.set(char, 'fav', false);                    // 重置收藏状态
    _.set(char, 'data.extensions.fav', false);
    _.unset(char, 'chat');                        // 移除聊天记录引用
}

8. API Endpoints 清单

Endpoint方法描述关键参数
/api/characters/allPOST获取所有角色列表shallow 控制详细程度
/api/characters/getPOST获取单个角色详情avatar_url
/api/characters/createPOST创建新角色ch_name, file_name, file
/api/characters/editPOST编辑角色avatar_url, ch_name, file
/api/characters/edit-avatarPOST仅编辑头像avatar_url, file
/api/characters/edit-attributePOST编辑单个属性avatar_url, field, value
/api/characters/merge-attributesPOST合并属性更新avatar, 任意属性
/api/characters/renamePOST重命名角色avatar_url, new_name
/api/characters/deletePOST删除角色avatar_url, delete_chats
/api/characters/duplicatePOST复制角色avatar_url
/api/characters/importPOST导入角色file, file_type
/api/characters/exportPOST导出角色avatar_url, format
/api/characters/chatsPOST获取角色聊天记录avatar_url, simple, metadata

9. 性能优化策略

9.1 缓存优化

  • 内存缓存:快速响应,限制内存使用
  • 磁盘缓存:持久化,服务重启后保留
  • 缓存键包含 mtime:文件修改后自动失效
  • 浅层数据模式:列表展示时只加载必要字段

9.2 图像处理优化

  • WASM 加速:WebP/PNG/JPEG/AVIF 使用 WASM 解码
  • 延迟处理:图像处理只在必要时执行
  • 原子写入:使用 write-file-atomic 防止数据损坏

9.3 批量处理

  • Promise.all:并行处理多个角色
  • 异步 I/O:所有文件操作使用异步 API
  • 流式处理:CharX 资源按需提取

10. 安全考虑

10.1 输入验证

  • 文件名清理:使用 sanitize-filename 库
  • 路径遍历防护:验证所有用户输入的路径
  • 格式白名单:只允许特定导入格式

10.2 数据处理

  • 原子写入:防止写入过程中断导致数据损坏
  • 缓存验证:清理已不存在文件的缓存条目
  • 错误隔离:单个角色处理失败不影响整体列表

结论

SillyTavern 的角色卡系统采用了高度模块化和可扩展的设计:

  1. 多格式支持:V1/V2/V3 PNG 格式 + CharX ZIP 格式,向后兼容
  2. 高效缓存:双层缓存架构平衡速度和内存使用
  3. 标准化处理:统一的图像处理、数据验证和转换流程
  4. 资源管理:CharX 支持多媒体资源,自动分类存储
  5. 健壮性:全面的错误处理和验证机制

这套系统为 SillyTavern 提供了灵活、高效、可靠的角色管理能力。