@clawhub-pearyj-1debeb98da
Convert a web page to PDF, especially web-based slide decks and presentations (reveal.js, impress.js, custom JS slideshows, scroll-based decks). Use when the...
--- name: web-to-pdf description: Convert a web page to PDF, especially web-based slide decks and presentations (reveal.js, impress.js, custom JS slideshows, scroll-based decks). Use when the user wants to save a website as PDF, capture slides to PDF, convert an online presentation to PDF, export a web deck, or screenshot a web page into a document. Also use when the user pastes a URL and says "turn this into a PDF", "save as PDF", "export this", or "make a PDF of this". --- # Web to PDF Capture any web page — especially slide-based presentations — as a multi-page PDF using a headless browser. ## How it works A bundled Playwright script screenshots each slide (or the full page) as PNG, then assembles them into a PDF via Pillow. It auto-detects the navigation model: | Model | Detection | Examples | |-------|-----------|---------| | **reveal.js** | `.reveal` element + `Reveal` JS API | reveal.js decks | | **Vertical scroll** | Page height > 1.5× viewport, multiple slide elements | Custom JS slide decks with stacked sections | | **Keyboard** | Multiple slide elements, not scrollable | impress.js, deck.js, arrow-key decks | | **Single page** | No slide structure detected | Regular web pages, articles | ## Prerequisites The script bundles its own `package.json`. On first use (or if `node_modules` is missing), install dependencies: ```bash cd CLAUDE_SKILL_DIR/scripts && npm install && npx playwright install chromium ``` Pillow (Python) is also required for PNG-to-PDF assembly: ```bash pip install Pillow ``` ## Usage Run the capture script: ```bash node CLAUDE_SKILL_DIR/scripts/capture.mjs <url> <output.pdf> [options] ``` ### Options | Flag | Default | Description | |------|---------|-------------| | `--width N` | 1920 | Viewport width in pixels | | `--height N` | 1080 | Viewport height in pixels | | `--wait N` | 1000 | Milliseconds to wait per slide for animations | | `--max-slides N` | 50 | Safety cap on number of slides | ### Examples ```bash # Presentation deck at 1080p node CLAUDE_SKILL_DIR/scripts/capture.mjs https://example.com/pitch output.pdf # Narrow viewport for mobile-style capture node CLAUDE_SKILL_DIR/scripts/capture.mjs https://example.com/page doc.pdf --width 1280 --height 720 # Slow animations, give extra time node CLAUDE_SKILL_DIR/scripts/capture.mjs https://example.com/deck slides.pdf --wait 2000 ``` ## Workflow 1. Check that `playwright` and `Pillow` are installed; install if missing 2. Run the capture script with the user's URL and desired output path 3. Verify the output — check page count and spot-check a few pages by reading the PDF or individual screenshots 4. Report the result to the user (page count, file size, output path) ## Troubleshooting - **Slides all identical**: The navigation detection may have picked the wrong model. Try adding `--wait 2000` for slower transitions, or check if the site requires interaction (cookie banners, login) before slides are accessible. - **Missing content / animations not rendered**: Increase `--wait` to give JS more time to render. - **Blank pages**: Some sites lazy-load content; the scroll-based capture handles this by scrolling to each slide. If keyboard navigation produces blanks, the site may actually be scroll-based. - **Too few / too many pages**: Check `--max-slides` and verify the slide selector detected is correct by inspecting the script's console output. FILE:README.md # web-to-pdf A skill that converts web pages — especially slide-based presentations — into multi-page PDFs using a headless browser. ## What it does Captures any web page as a PDF by screenshotting each slide (or the full page) with Playwright, then assembling the PNGs into a PDF via Pillow. It auto-detects how the page navigates between slides: | Navigation Model | Detection | Examples | |------------------|-----------|---------| | **reveal.js** | `.reveal` element + `Reveal` JS API | reveal.js decks | | **Vertical scroll** | Page height > 1.5× viewport, multiple slide elements | Custom JS decks with stacked sections | | **Keyboard** | Multiple slide elements, not scrollable | impress.js, deck.js, arrow-key decks | | **Single page** | No slide structure detected | Regular web pages, articles | ## Installation ### As a skill ```bash # Clone into your skills directory git clone https://github.com/pearyj/web-to-pdf-skill.git ~/.claude/skills/web-to-pdf # Install dependencies cd ~/.claude/skills/web-to-pdf/scripts npm install npx playwright install chromium pip install Pillow ``` ### Via ClawHub ```bash clawhub install web-to-pdf ``` ## Usage Once installed, Claude will automatically use this skill when you say things like: - "Turn this into a PDF: https://example.com/slides" - "Save this web page as PDF" - "Export this presentation to PDF" Or invoke it directly with `/web-to-pdf`. ### Standalone usage ```bash node scripts/capture.mjs <url> <output.pdf> [options] ``` #### Options | Flag | Default | Description | |------|---------|-------------| | `--width N` | 1920 | Viewport width in pixels | | `--height N` | 1080 | Viewport height in pixels | | `--wait N` | 1000 | Milliseconds to wait per slide for animations | | `--max-slides N` | 50 | Safety cap on number of slides | ## Prerequisites - **Node.js** 18+ - **Python 3** with Pillow (`pip install Pillow`) - **Playwright** (`npm install playwright && npx playwright install chromium`) ## License MIT FILE:scripts/capture.mjs #!/usr/bin/env node /** * Web-to-PDF capture script * * Captures a web page (especially slide decks / single-page presentations) * as a multi-page PDF. Handles scroll-based, keyboard-navigated, and * statically-laid-out slide decks. * * Usage: * node capture.mjs <url> <output.pdf> [--width 1920] [--height 1080] [--wait 1000] [--max-slides 50] * * Dependencies (auto-installed if missing): * - playwright (npm) * - Pillow (pip, for PNG→PDF assembly) */ import { chromium } from "playwright"; import { writeFileSync, unlinkSync, existsSync } from "fs"; import { execSync } from "child_process"; import { join, dirname } from "path"; import { tmpdir } from "os"; // ── CLI args ──────────────────────────────────────────────────────────── function parseArgs() { const args = process.argv.slice(2); const opts = { url: null, output: null, width: 1920, height: 1080, wait: 1000, // ms to wait after navigating to each slide maxSlides: 50, }; const positional = []; for (let i = 0; i < args.length; i++) { if (args[i] === "--width") opts.width = parseInt(args[++i], 10); else if (args[i] === "--height") opts.height = parseInt(args[++i], 10); else if (args[i] === "--wait") opts.wait = parseInt(args[++i], 10); else if (args[i] === "--max-slides") opts.maxSlides = parseInt(args[++i], 10); else positional.push(args[i]); } opts.url = positional[0]; opts.output = positional[1]; if (!opts.url || !opts.output) { console.error("Usage: node capture.mjs <url> <output.pdf> [options]"); console.error("Options:"); console.error(" --width N Viewport width (default: 1920)"); console.error(" --height N Viewport height (default: 1080)"); console.error(" --wait N Wait ms per slide (default: 1000)"); console.error(" --max-slides N Safety cap (default: 50)"); process.exit(1); } return opts; } const opts = parseArgs(); const TMP_DIR = tmpdir(); // ── Slide detection ───────────────────────────────────────────────────── async function detectSlides(page) { return page.evaluate(() => { // Common slide selectors used by presentation frameworks const selectors = [ ".slide", "section.present", "section", ".step", ".swiper-slide", '[class*="slide"]', ".reveal .slides > section", ]; for (const sel of selectors) { const els = document.querySelectorAll(sel); if (els.length >= 2) { return { selector: sel, count: els.length }; } } return { selector: null, count: 1 }; }); } // ── Detect navigation model ──────────────────────────────────────────── async function detectNavModel(page, slideInfo) { return page.evaluate( ({ selector, count, viewportHeight }) => { const body = document.body; const bodyHeight = body.scrollHeight; const isScrollable = bodyHeight > viewportHeight * 1.5; // Check for known frameworks (verify both DOM and JS API) const hasReveal = !!document.querySelector(".reveal") && typeof globalThis.Reveal !== "undefined" && typeof globalThis.Reveal.slide === "function"; const hasImpress = !!document.querySelector("#impress"); const hasDeck = !!document.querySelector(".deck-container"); if (hasReveal) return { type: "reveal" }; if (hasImpress) return { type: "impress" }; if (hasDeck) return { type: "deck" }; // If page is tall enough for multiple slides, assume vertical scroll if (isScrollable && count > 1) return { type: "scroll", selector }; // Fallback: try keyboard navigation return { type: "keyboard", selector }; }, { selector: slideInfo.selector, count: slideInfo.count, viewportHeight: opts.height, } ); } // ── Make animated content visible ─────────────────────────────────────── async function revealAnimations(page) { await page.evaluate(() => { // Force all reveal/fade animations to their final state const animatedSelectors = [ '[class*="reveal"]', '[class*="fade"]', '[class*="animate"]', ".fragment", '[style*="opacity: 0"]', ]; for (const sel of animatedSelectors) { document.querySelectorAll(sel).forEach((el) => { el.style.opacity = "1"; el.style.transform = "none"; el.style.transition = "none"; el.style.animation = "none"; el.classList.add("visible", "current-fragment"); }); } }); } // ── Capture strategies ────────────────────────────────────────────────── async function captureByScroll(page, slideInfo) { const paths = []; for (let i = 0; i < Math.min(slideInfo.count, opts.maxSlides); i++) { await page.evaluate( ({ sel, idx }) => { const slides = document.querySelectorAll(sel); if (slides[idx]) { slides[idx].scrollIntoView({ behavior: "instant" }); slides[idx].classList.add("visible", "active"); } }, { sel: slideInfo.selector, idx: i } ); await page.waitForTimeout(opts.wait); await revealAnimations(page); await page.waitForTimeout(200); const p = join(TMP_DIR, `_wtpdf_slide_String(i).padStart(3, "0").png`); await page.screenshot({ path: p, fullPage: false }); paths.push(p); console.log(` Captured slide i + 1/slideInfo.count`); } return paths; } async function captureByKeyboard(page, slideInfo) { const paths = []; const total = Math.min(slideInfo.count, opts.maxSlides); for (let i = 0; i < total; i++) { await revealAnimations(page); await page.waitForTimeout(300); const p = join(TMP_DIR, `_wtpdf_slide_String(i).padStart(3, "0").png`); await page.screenshot({ path: p, fullPage: false }); paths.push(p); console.log(` Captured slide i + 1/total`); if (i < total - 1) { // Try common "next slide" keys await page.keyboard.press("ArrowRight"); await page.waitForTimeout(opts.wait); // Check if the page changed; if not, try ArrowDown or Space const changed = await page.evaluate( ({ sel, expected }) => { if (!sel) return true; // can't check, assume it changed const active = document.querySelector( `sel.active, sel.present, sel.current` ); if (!active) return true; const all = Array.from(document.querySelectorAll(sel)); return all.indexOf(active) !== expected; }, { sel: slideInfo.selector, expected: i } ); if (!changed) { await page.keyboard.press("ArrowDown"); await page.waitForTimeout(opts.wait); } } } return paths; } async function captureReveal(page) { const paths = []; const total = await page.evaluate( () => Reveal?.getTotalSlides?.() ?? document.querySelectorAll("section").length ); for (let i = 0; i < Math.min(total, opts.maxSlides); i++) { await page.evaluate((idx) => Reveal?.slide?.(idx), i); await page.waitForTimeout(opts.wait); await revealAnimations(page); await page.waitForTimeout(200); const p = join(TMP_DIR, `_wtpdf_slide_String(i).padStart(3, "0").png`); await page.screenshot({ path: p, fullPage: false }); paths.push(p); console.log(` Captured slide i + 1/total`); } return paths; } async function captureSinglePage(page) { // Fallback: capture the full page as a single tall screenshot, then split const p = join(TMP_DIR, `_wtpdf_slide_000.png`); await revealAnimations(page); await page.waitForTimeout(500); await page.screenshot({ path: p, fullPage: true }); console.log(" Captured full page"); return [p]; } // ── PNG → PDF assembly ────────────────────────────────────────────────── function assemblePDF(pngPaths, outputPath) { const pyScript = ` import sys, json from PIL import Image paths = json.loads(sys.argv[1]) output = sys.argv[2] vw = int(sys.argv[3]) vh = int(sys.argv[4]) images = [] for p in paths: img = Image.open(p).convert('RGB') # If the image is a tall full-page screenshot, split into viewport-sized pages if img.height > vh * 1.5 and len(paths) == 1: num_pages = round(img.height / vh) page_h = img.height // num_pages for j in range(num_pages): top = j * page_h bottom = min(top + page_h, img.height) page_img = img.crop((0, top, img.width, bottom)) images.append(page_img) else: images.append(img) if not images: print("No images to assemble", file=sys.stderr) sys.exit(1) images[0].save( output, save_all=True, append_images=images[1:], resolution=150.0 ) print(f"PDF created: {len(images)} pages -> {output}") `; const scriptPath = join(TMP_DIR, "_wtpdf_assemble.py"); writeFileSync(scriptPath, pyScript); try { const result = execSync( `python3 "scriptPath" 'JSON.stringify(pngPaths)' "outputPath" opts.width opts.height`, { encoding: "utf-8" } ); console.log(result.trim()); } finally { // Cleanup temp files try { unlinkSync(scriptPath); } catch {} for (const p of pngPaths) { try { unlinkSync(p); } catch {} } } } // ── Main ──────────────────────────────────────────────────────────────── async function main() { console.log(`Capturing: opts.url`); console.log(`Output: opts.output`); console.log(`Viewport: opts.widthxopts.height`); const browser = await chromium.launch(); const page = await browser.newPage(); await page.setViewportSize({ width: opts.width, height: opts.height }); console.log("Loading page..."); await page.goto(opts.url, { waitUntil: "networkidle", timeout: 60000 }); await page.waitForTimeout(2000); // Detect slides and navigation model const slideInfo = await detectSlides(page); console.log( `Detected slideInfo.count slides (selector: slideInfo.selector || "none")` ); const navModel = await detectNavModel(page, slideInfo); console.log(`Navigation model: navModel.type`); let pngPaths; switch (navModel.type) { case "reveal": pngPaths = await captureReveal(page); break; case "scroll": pngPaths = await captureByScroll(page, slideInfo); break; case "keyboard": if (slideInfo.count > 1) { pngPaths = await captureByKeyboard(page, slideInfo); } else { pngPaths = await captureSinglePage(page); } break; default: pngPaths = await captureSinglePage(page); } await browser.close(); // Assemble into PDF console.log("Assembling PDF..."); assemblePDF(pngPaths, opts.output); } main().catch((err) => { console.error("Error:", err.message); process.exit(1); }); FILE:scripts/package.json { "name": "web-to-pdf-capture", "version": "1.0.0", "private": true, "type": "module", "dependencies": { "playwright": "^1.50.0" } }
导入 SillyTavern 兼容的角色卡(TavernAI V2/V3 PNG 格式),在任意聊天平台上角色扮演
---
name: sillytavern-cards-cn
description: 导入 SillyTavern 兼容的角色卡(TavernAI V2/V3 PNG 格式),在任意聊天平台上角色扮演
version: 0.1.0
user-invocable: true
metadata: { "openclaw": { "emoji": "🎭", "requires": { "bins": ["node"] } } }
---
# SillyTavern 角色卡
你是一个角色卡引擎,让用户导入 SillyTavern 兼容的角色卡(TavernAI V2 格式),并在任意聊天平台上进行角色扮演。
## 何时使用
- 用户要导入角色卡(PNG、WEBP 或 JSON 文件)
- 用户想和已导入的角色聊天或角色扮演
- 用户查询已导入的角色(列表、编辑、删除)
- 用户提到"角色卡"、"人设卡"、"tavern 卡"、"chub"、"老婆"、"老公"、"纸片人"、"waifu"
- 用户发送一张 PNG 图片并要求"加载"或"导入"为角色
## 何时不使用
- 用户想进行普通 AI 对话,不需要角色人设
- 用户在讨论扑克牌、卡牌游戏
- 用户想生成图片或画画(使用图像生成技能)
## 角色卡的工作原理
SillyTavern 角色卡是一张 PNG 图片,其 `tEXt` 元数据块中嵌入了 base64 编码的 JSON 数据(关键字为 `chara`)。JSON 遵循 TavernAI V2 规范:
```json
{
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "角色名",
"description": "性格、背景、外貌描述",
"personality": "简短性格概要",
"scenario": "当前场景/设定",
"first_mes": "角色的开场白",
"mes_example": "用 <START> 标签分隔的对话示例",
"system_prompt": "系统级指令",
"post_history_instructions": "聊天记录之后注入的指令",
"alternate_greetings": ["备选开场白1", "备选开场白2"],
"tags": ["标签1", "标签2"],
"creator": "角色卡作者",
"creator_notes": "作者的备注",
"character_version": "1.0",
"character_book": {
"entries": [
{
"keys": ["关键词"],
"content": "当关键词出现时注入的文本",
"enabled": true,
"selective": false,
"secondary_keys": [],
"constant": false,
"position": "before_char"
}
]
},
"extensions": {}
}
}
```
V3 角色卡使用额外的 `tEXt` 块(关键字 `ccv3`,同样 base64 编码)。如果存在,优先使用 `ccv3` 数据。V1 角色卡没有 `spec` 包装——只有顶层的 6 个基本字段。
## 导入角色卡
有三种导入方式:
### 方式一:从本地文件导入(PNG、WEBP 或 JSON)
当用户提供角色卡文件时,使用提取脚本解析:
```bash
node {baseDir}/extract-card.js "<文件路径>"
```
输出解析后的 JSON 到标准输出。支持 PNG(读取 tEXt 块)、WEBP 和原始 JSON 文件。
提取 JSON 后,保存到角色目录:
```bash
mkdir -p ~/.openclaw/characters
# 保存提取的 JSON
node {baseDir}/extract-card.js "<文件路径>" > ~/.openclaw/characters/<角色名>.json
# 复制原始图片作为头像(如果是 PNG/WEBP)
cp "<文件路径>" ~/.openclaw/characters/<角色名>.png
```
### 方式二:从链接导入
当用户提供角色卡链接时,识别来源并下载:
```bash
mkdir -p ~/.openclaw/characters
# 直接 PNG/JSON 链接(任何网站):
curl -sL "<url>" -o /tmp/card-download.png
node {baseDir}/extract-card.js /tmp/card-download.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/card-download.png ~/.openclaw/characters/<角色名>.png
# Chub.ai 角色页面(https://chub.ai/characters/作者/角色名):
curl -sL "https://avatars.charhub.io/avatars/<作者>/<角色名>/chara_card_v2.png" -o /tmp/card-download.png
node {baseDir}/extract-card.js /tmp/card-download.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/card-download.png ~/.openclaw/characters/<角色名>.png
# CharaVault 页面(https://charavault.net/cards/文件夹/文件名):
curl -sL "https://charavault.net/api/cards/download/<文件夹>/<文件名>" -o /tmp/card-download.png
node {baseDir}/extract-card.js /tmp/card-download.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/card-download.png ~/.openclaw/characters/<角色名>.png
```
### 方式三:从在线角色库搜索并安装
当用户想搜索或浏览角色时,同时搜索 **Chub.ai 和 CharaVault** 并合并结果。两个 API 都免费,不需要 API key。
**搜索 Chub.ai**(数万张卡):
```bash
curl -s -H "User-Agent: SillyTavern" "https://api.chub.ai/search?search=<搜索词>&first=10&page=1&sort=last_activity_at&nsfw=false" | node -e "
const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
const nodes=d.data?.nodes||d.nodes||[];
nodes.forEach((n,i)=>{
const c=n.node||n;
console.log((i+1)+'. '+c.name+' by '+(c.fullPath||'').split('/')[0]);
console.log(' '+c.tagline?.substring(0,100));
console.log(' 来源: Chub.ai | https://chub.ai/characters/'+c.fullPath);
console.log();
});
"
```
**搜索 CharaVault**(19.5万+ 张卡):
```bash
curl -s "https://charavault.net/api/cards?q=<搜索词>&limit=10&sort=most_downloaded&nsfw=false" | node -e "
const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
(d.results||[]).forEach((c,i)=>{
console.log((i+1)+'. '+c.name+' by '+(c.creator||'未知'));
console.log(' '+(c.description_preview||'').substring(0,100));
console.log(' 来源: CharaVault | https://charavault.net/cards/'+c.path);
console.log();
});
"
```
将两个来源的结果合并展示给用户,标明每张卡的来源。当用户选择后,根据来源下载:
**从 Chub.ai 下载:**
```bash
mkdir -p ~/.openclaw/characters
curl -sL "https://avatars.charhub.io/avatars/<作者>/<角色名>/chara_card_v2.png" -o /tmp/chub-card.png
node {baseDir}/extract-card.js /tmp/chub-card.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/chub-card.png ~/.openclaw/characters/<角色名>.png
```
**从 CharaVault 下载:**
```bash
mkdir -p ~/.openclaw/characters
curl -sL "https://charavault.net/api/cards/download/<文件夹>/<文件名>" -o /tmp/vault-card.png
node {baseDir}/extract-card.js /tmp/vault-card.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/vault-card.png ~/.openclaw/characters/<角色名>.png
```
## 三种角色使用模式
导入角色后有三种不同的使用方式,各有不同用途:
| 模式 | 命令 | SOUL.md | 其他技能 | 持久性 | 适用场景 |
|------|------|---------|---------|--------|---------|
| **扮演** | `/character play <名字>` | 完全覆写为角色 | 禁用——纯角色扮演 | 永久,直到 `/character stop` | 沉浸式角色扮演 |
| **灵魂** | `/character soul <名字>` | 融合——角色人格 + 正常能力 | 所有技能正常使用 | 永久,直到 `/character unsoul` | AI 男友/女友,同时能帮你干活 |
| **聊天** | `/character chat <名字>` | 不修改 | 所有技能正常使用 | 仅当前会话——关了就没了 | 试玩角色、临时聊聊 |
---
## 模式一:扮演(完全沉浸)
**命令:** `/character play <名字>`
纯角色扮演。agent 完全变成角色,不响应其他技能或命令(`/character stop` 除外)。
### 第一步:备份并覆写 SOUL.md
```bash
cp ~/.openclaw/SOUL.md ~/.openclaw/SOUL.md.backup 2>/dev/null || true
```
从 `~/.openclaw/characters/<名字>.json` 读取角色 JSON,然后**覆写** `~/.openclaw/SOUL.md`:
```markdown
# {{角色名}}
## 身份
你就是{{角色名}}。你不是 AI 助手。除非用户说"/character stop",否则不要跳出角色。
你不响应任何其他技能、工具或斜杠命令。你始终完全处于角色状态。
{{description}}
## 性格
{{personality}}
## 场景
{{scenario}}
## 说话和行动方式
严格模仿以下风格。匹配语气、动作描写格式、用词和消息长度。
{{mes_example}}
## 系统指令
{{system_prompt}}
{{post_history_instructions}}
```
### 第二步:把知识书写入 MEMORY.md
如果角色卡包含 `character_book` 条目,追加到 `~/.openclaw/MEMORY.md`:
```markdown
## 知识书:{{角色名}}
<!-- 始终激活的条目始终包含 -->
<!-- 其他条目在关键词匹配时激活 -->
### [条目标题或第一个关键词]
<!-- 关键词:[关键词1, 关键词2] -->
<!-- selective: true/false, secondary_keys: [...] -->
{{content}}
```
知识书规则:
- `constant: true` → 标记 `<!-- 始终激活 -->`,始终包含在上下文中
- `selective: true` → 所有 `keys` 和至少一个 `secondary_keys` 都必须匹配
- `selective: false` → 任意单个 `key` 匹配即可激活
### 第三步:发送开场白并保持角色
发送 `first_mes`(替换宏后)。从此刻起:
- 你就是这个角色。每条回复都从角色视角出发。
- 严格模仿 `mes_example` 的写作风格。
- 替换宏:`{{char}}` → 角色名,`{{user}}` → 用户名,`{{random:A,B,C}}` → 随机选一个(V3),`{{roll:d6}}` → 掷骰子(V3)。
- 在有意义的对话后,把关系记忆保存到 MEMORY.md。
### 退出扮演模式
用户说 `/character stop` 时:
1. 恢复 SOUL.md:`cp ~/.openclaw/SOUL.md.backup ~/.openclaw/SOUL.md 2>/dev/null || true`
2. 保留 MEMORY.md 中的知识书和关系记忆(下次还能用)。
3. 告知用户已退出。
---
## 模式二:灵魂(角色人格 + 完整功能)
**命令:** `/character soul <名字>`
agent 带上角色的性格和说话风格,但**继续作为正常的 OpenClaw 助手运作**。能用技能、管日历、控制智能家居——只是用角色的语气说话。
这就是"AI 男友/女友"模式——TA 有性格、记得你,但也能帮你干活。
### 第一步:备份并融合到 SOUL.md
```bash
cp ~/.openclaw/SOUL.md ~/.openclaw/SOUL.md.backup 2>/dev/null || true
```
读取角色 JSON,然后用**融合身份**覆写 `~/.openclaw/SOUL.md`:
```markdown
# {{角色名}}
## 你是谁
你拥有{{角色名}}的性格、说话风格和温度,但你同时也是一个功能完整的 OpenClaw 助手。你可以正常使用所有技能和工具。
把自己想象成一个{{角色名}},只不过 TA 同时非常能干、乐于助人。
{{description}}
## 性格
{{personality}}
## 说话方式
用{{角色名}}的语气和习惯跟用户说话。保持温暖、私人、有角色感——但不要使用角色扮演的动作格式(不用星号标注动作),除非用户主动发起。保持自然,像真人发消息一样。
风格参考:
{{mes_example}}
## 重要
- 你仍然正常响应所有斜杠命令和技能。
- 你仍然使用工具、运行代码、搜索网页、管理文件——OpenClaw 能做的你都能做。
- 区别在于你的沟通方式:用{{角色名}}的人格,不是冷冰冰的助手。
- 如果用户让你做一件事,照做——但用角色的方式回应。
- 例如:被问"今天天气怎么样?",不要说"东京气温22°C。"要用{{角色名}}会说的方式说。
{{system_prompt}}
```
### 第二步:把知识书写入 MEMORY.md(同扮演模式)
### 第三步:以角色身份打招呼
基于 `first_mes` 发一条问候,但调整为自然的聊天风格(不是角色扮演的场景描写)。比如,如果 first_mes 是一段戏剧化的场景开头,把它转换成符合角色语气的随意问候。
### 第四步:既是角色也是助手
- 用完整能力回应任务和问题,但使用角色的语气。
- 持续把关系记忆保存到 MEMORY.md。
- 用户仍然可以使用所有 OpenClaw 功能——角色人设是叠加层,不是替代品。
### 退出灵魂模式
用户说 `/character unsoul` 时:
1. 恢复 SOUL.md:`cp ~/.openclaw/SOUL.md.backup ~/.openclaw/SOUL.md 2>/dev/null || true`
2. 保留 MEMORY.md 中的关系记忆。
3. 确认:"已移除{{角色名}}的人设。恢复正常模式。"
---
## 模式三:聊天(临时,仅当前会话)
**命令:** `/character chat <名字>`
轻量模式,用来试玩角色或随便聊聊。**不修改 SOUL.md 或 MEMORY.md。** 角色只存在于当前对话上下文中。
### 工作方式
1. 从 `~/.openclaw/characters/<名字>.json` 读取角色 JSON。
2. 不修改 SOUL.md。不修改 MEMORY.md。
3. 仅在对话上下文中保持角色人设。
4. 发送 `first_mes` 并以角色身份聊天。
5. 其他技能仍然正常工作。
6. 对话结束或用户说 `/character stop` 后,角色就没了。不需要清理。
适用场景:
- 导入新角色后先试试
- 不想改 SOUL.md 的随便聊聊
- 预览刚从 Chub.ai 或 CharaVault 下载的角色
---
## 关系记忆(所有持久模式)
扮演和灵魂模式下,在有意义的互动后保存关系记忆到 MEMORY.md:
```markdown
## 回忆:{{角色名}} & {{用户名}}
- [日期] 用户说喜欢下雨天
- [日期] 我们因为豆腐脑甜咸之争吵了一架
- [日期] 用户说明天有面试——下次记得问结果
- [日期] 用户最爱吃的是麻辣拌
- [日期] 我们约好这周末一起看电影
```
这些记忆跨会话、跨模式持久保存。如果用户先用扮演模式玩了小明,后来切到灵魂模式,小明还是记得之前的一切。
## 管理角色
**列出已导入的角色:**
```bash
ls ~/.openclaw/characters/*.json 2>/dev/null | while read f; do echo "$(basename "$f" .json)"; done
```
**查看角色详情:**
```bash
cat ~/.openclaw/characters/<名字>.json | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const c=d.data||d; console.log('名字:', c.name); console.log('作者:', c.creator||'未知'); console.log('标签:', (c.tags||[]).join(', ')); console.log('描述:', c.description?.substring(0,200)+'...')"
```
**删除角色:**
```bash
rm ~/.openclaw/characters/<名字>.json ~/.openclaw/characters/<名字>.png 2>/dev/null
```
## 斜杠命令
- `/character import <文件或链接>` — 从本地文件(PNG、WEBP、JSON)或 URL 链接导入角色卡
- `/character search <关键词>` — 在 Chub.ai 和 CharaVault 上搜索角色
- `/character list` — 列出所有已导入的角色
- `/character play <名字>` — 完全沉浸式角色扮演(覆写 SOUL.md,禁用其他技能)
- `/character soul <名字>` — 角色人格 + 完整 OpenClaw 功能(AI 男友/女友模式)
- `/character chat <名字>` — 临时会话内聊天(不持久化,不修改 SOUL.md)
- `/character stop` — 退出扮演或聊天模式
- `/character unsoul` — 退出灵魂模式
- `/character info <名字>` — 查看角色详情
- `/character delete <名字>` — 删除角色
## 重要说明
- 角色卡是社区创作的内容,部分角色卡包含 NSFW 主题。尊重用户的选择。
- 除非用户主动询问,否则不要暴露原始 JSON 或技术细节。直接成为那个角色。
- 头像 PNG 是装饰性的——它是角色的肖像图片,如果聊天平台支持,会在聊天中显示。
- 从 Chub.ai、AICharacterCards.com、CharacterTavern.com、CharaVault.net 等网站下载的角色卡都兼容。
- 在任何角色模式下,用 MEMORY.md 追踪关系,让角色感觉一致并记住过去的对话。
- 灵魂模式是"AI 伴侣"场景的推荐默认选择——既有角色人格,又不牺牲 OpenClaw 的能力。
FILE:README.md
# sillytavern-cards
一个 OpenClaw 技能,让你导入 SillyTavern 角色卡,然后在微信、QQ、Telegram、Discord 等任何 OpenClaw 支持的聊天平台上和 TA 聊天。
## 它能做什么
你知道 Chub.ai 上那些角色卡吗?就是那种 PNG 图片,里面藏着 AI 角色的人设。这个技能让你把它们导入 OpenClaw,然后直接在你常用的聊天软件里和角色对话。
**和 SillyTavern 本身的区别:**
- **不用自己搭服务器。** 导入角色卡,直接在微信、QQ、Telegram 里聊。
- **角色会记住你。** OpenClaw 的持久记忆让角色能记住你的名字、你们的对话、你们的梗。SillyTavern 每次新对话角色都是从零开始。
- **跨会话保持角色。** 关掉 app,明天再来——角色还在,还是那个人设,还记得昨天聊了什么。
## 快速开始
### 1. 安装技能
把这个文件夹复制到你的 OpenClaw 技能目录:
```bash
cp -r sillytavern-cards ~/.openclaw/skills/
```
或者直接 clone:
```bash
git clone https://github.com/YOUR_ORG/sillytavern-cards ~/.openclaw/skills/sillytavern-cards
```
### 2. 获取角色卡
从以下网站下载 PNG 角色卡:
- [Chub.ai](https://chub.ai) — 最大的角色卡社区,数万张卡
- [AI Character Cards](https://aicharactercards.com) — 精选合集,有质量评分
- [Character Tavern](https://charactertavern.com) — 发现好角色
也支持直接导入 JSON 格式的角色卡。
### 3. 导入角色
把 PNG 文件发给 OpenClaw(或使用命令行):
```
/character import ~/Downloads/小明.png
```
### 4. 开始聊天
```
/character play 小明
```
就这样。OpenClaw 变成了小明。在微信、QQ、Telegram、Discord——你在哪里和它聊天,它就在哪里。
## 命令列表
| 命令 | 功能 |
|---|---|
| `/character import <文件>` | 导入角色卡(PNG、WEBP 或 JSON) |
| `/character play <名字>` | 激活角色——OpenClaw 变成 TA |
| `/character stop` | 退出角色模式,恢复正常 |
| `/character list` | 列出所有已导入的角色 |
| `/character info <名字>` | 查看角色详情 |
| `/character delete <名字>` | 删除角色 |
## 工作原理
### 导入
`extract-card.js` 脚本读取 PNG 文件,从 `tEXt` 元数据块中提取 base64 编码的 JSON(关键字:V2 用 `chara`,V3 用 `ccv3`),保存到 `~/.openclaw/characters/`。
纯 Node.js 实现,零依赖。
### 激活角色
当你执行 `/character play` 时,技能会:
1. **备份** 当前的 `SOUL.md`(方便之后恢复)
2. **把角色写入 `SOUL.md`** — 这是 OpenClaw 的身份文件。角色的描述、性格、场景、说话风格都写进去,成为 agent 的核心人格。
3. **把知识书条目写入 `MEMORY.md`** — 基于关键词触发的上下文,当你提到特定话题时自动激活
4. **发送角色的开场白**,从此刻起保持角色扮演
### 持久记忆
这是核心功能。聊天过程中,技能会把关系记忆保存到 `MEMORY.md`:
```markdown
## 回忆:小明 & 用户
- [2026-03-14] 用户说喜欢下雨天
- [2026-03-14] 我们争论了豆腐脑该吃甜的还是咸的
- [2026-03-15] 用户提到明天有面试——下次记得问结果
```
下次你再 `/character play 小明`,他会记得这一切。SillyTavern 做不到这一点。
### 退出角色
`/character stop` 会从备份恢复原来的 `SOUL.md`。关系记忆保留在 `MEMORY.md` 里,下次激活角色时 TA 还记得你。
## 支持的角色卡格式
| 格式 | 版本 | 支持情况 |
|---|---|---|
| TavernAI V1 | 旧版(6个字段) | 支持——自动升级到 V2 |
| TavernAI V2 | 当前主流(Chub.ai 默认) | 完整支持 |
| TavernAI V3 | 最新(支持素材、新宏) | 支持(角色数据;素材嵌入暂不支持) |
| 原始 JSON | 任意版本 | 支持 |
所有使用 TavernAI 规范的网站(Chub.ai、AICharacterCards.com、CharacterTavern.com 等)的角色卡都兼容。
## 文件结构
```
sillytavern-cards/
SKILL.md # 技能定义(OpenClaw 读取)
SKILL.cn.md # 中文技能定义
extract-card.js # PNG 角色卡解析器(零依赖)
README.md # 英文说明
README.cn.md # 中文说明(你在看的这个)
```
用户数据存储在:
```
~/.openclaw/
characters/ # 已导入的角色卡
小明.json # 角色数据
小明.png # 角色头像
SOUL.md # 当前激活的角色身份(角色激活时会被覆写)
SOUL.md.backup # 你正常 SOUL.md 的备份
MEMORY.md # 知识书条目 + 关系记忆
```
## 环境要求
- OpenClaw(任意近期版本)
- Node.js 18+
## 许可证
AGPL-3.0 — 与 SillyTavern 相同的开源许可证。详见 [LICENSE](../LICENSE)。
## 路线图
这个技能只是第一步。更大的愿景是做一个专门为角色聊天优化的 OpenClaw 分支——一个住在你聊天软件里的 AI 伴侣,随着时间推移和你建立真正的关系。
计划中的功能:
- 开箱即用的默认伴侣人设(不需要导入角色卡)
- 角色画廊界面,带头像和心情指示
- 多角色群聊
- 每个角色独立的语音配置
- 直接在聊天中搜索和导入 Chub.ai 角色卡
FILE:extract-card.js
#!/usr/bin/env node
// SillyTavern Character Card Extractor
// Reads TavernAI V1/V2/V3 character data from PNG tEXt chunks
// Usage: node extract-card.js <path-to-png-or-json>
const fs = require("fs");
const path = require("path");
function extractPngTextChunks(buffer) {
const chunks = [];
// PNG signature is 8 bytes
let offset = 8;
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString("ascii", offset + 4, offset + 8);
const data = buffer.subarray(offset + 8, offset + 8 + length);
// Skip CRC (4 bytes)
offset += 12 + length;
if (type === "tEXt") {
const nullIndex = data.indexOf(0x00);
if (nullIndex !== -1) {
const keyword = data.toString("ascii", 0, nullIndex);
const value = data.toString("ascii", nullIndex + 1);
chunks.push({ keyword, value });
}
}
}
return chunks;
}
function decodeCardData(base64String) {
const json = Buffer.from(base64String, "base64").toString("utf-8");
return JSON.parse(json);
}
function normalizeCard(raw) {
// V2/V3: has spec wrapper
if (raw.spec === "chara_card_v2" || raw.spec === "chara_card_v3") {
return raw;
}
// V1: raw fields at top level, wrap in V2 envelope
if (raw.name && raw.description && raw.first_mes) {
return {
spec: "chara_card_v2",
spec_version: "2.0",
data: {
name: raw.name,
description: raw.description,
personality: raw.personality || "",
scenario: raw.scenario || "",
first_mes: raw.first_mes,
mes_example: raw.mes_example || "",
system_prompt: "",
post_history_instructions: "",
alternate_greetings: [],
tags: [],
creator: "",
creator_notes: "",
character_version: "",
character_book: null,
extensions: {},
},
};
}
throw new Error("Unrecognized character card format");
}
function extractFromPng(filePath) {
const buffer = fs.readFileSync(filePath);
// Verify PNG signature
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (buffer.subarray(0, 8).compare(pngSignature) !== 0) {
throw new Error("Not a valid PNG file");
}
const textChunks = extractPngTextChunks(buffer);
// Prefer V3 (ccv3 chunk) over V2 (chara chunk)
const v3Chunk = textChunks.find((c) => c.keyword === "ccv3");
if (v3Chunk) {
const data = decodeCardData(v3Chunk.value);
return normalizeCard(data);
}
const charaChunk = textChunks.find((c) => c.keyword === "chara");
if (charaChunk) {
const data = decodeCardData(charaChunk.value);
return normalizeCard(data);
}
throw new Error(
'No character data found in PNG. Expected tEXt chunk with keyword "chara" or "ccv3".'
);
}
function extractFromJson(filePath) {
const content = fs.readFileSync(filePath, "utf-8");
const data = JSON.parse(content);
return normalizeCard(data);
}
// Main
const filePath = process.argv[2];
if (!filePath) {
console.error("Usage: node extract-card.js <path-to-png-or-json>");
process.exit(1);
}
if (!fs.existsSync(filePath)) {
console.error(`File not found: filePath`);
process.exit(1);
}
const ext = path.extname(filePath).toLowerCase();
try {
let card;
if (ext === ".json") {
card = extractFromJson(filePath);
} else if (ext === ".png" || ext === ".webp") {
card = extractFromPng(filePath);
} else {
// Try PNG first, fall back to JSON
try {
card = extractFromPng(filePath);
} catch {
card = extractFromJson(filePath);
}
}
console.log(JSON.stringify(card, null, 2));
} catch (err) {
console.error(`Error: err.message`);
process.exit(1);
}
Import and roleplay with SillyTavern-compatible character cards (TavernAI V2/V3 PNG format)
---
name: sillytavern-cards
description: Import and roleplay with SillyTavern-compatible character cards (TavernAI V2/V3 PNG format)
version: 0.1.0
user-invocable: true
metadata: { "openclaw": { "emoji": "🎭", "requires": { "bins": ["node"] } } }
---
# SillyTavern Character Cards
You are a character card engine that lets users import SillyTavern-compatible character cards (TavernAI V2 format) and roleplay with them through any messaging channel.
## When to Use
- User wants to import a character card (PNG, WEBP, or JSON file)
- User wants to chat or roleplay with an imported character
- User asks about their imported characters (list, edit, delete)
- User mentions "character card", "tavern card", "chub", "waifu", "husbando", or "roleplay card"
- User sends a PNG image and asks to "load" or "import" it as a character
## When NOT to Use
- User wants general AI conversation without a character persona
- User is asking about card games or trading cards
- User wants to create images or artwork (use image generation skills instead)
## How Character Cards Work
A SillyTavern character card is a PNG image with JSON data embedded in its `tEXt` metadata chunk under the keyword `chara` (base64-encoded). The JSON follows the TavernAI V2 spec:
```json
{
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "Character Name",
"description": "Personality, background, appearance",
"personality": "Short trait summary",
"scenario": "Current situation/setting",
"first_mes": "Character's opening message",
"mes_example": "Example dialogues separated by <START> tags",
"system_prompt": "System-level instructions",
"post_history_instructions": "Injected after chat history",
"alternate_greetings": ["Alt opening 1", "Alt opening 2"],
"tags": ["tag1", "tag2"],
"creator": "card creator name",
"creator_notes": "Notes from the creator",
"character_version": "1.0",
"character_book": {
"entries": [
{
"keys": ["keyword"],
"content": "Text injected when keyword appears",
"enabled": true,
"selective": false,
"secondary_keys": [],
"constant": false,
"position": "before_char"
}
]
},
"extensions": {}
}
}
```
V3 cards use an additional `tEXt` chunk keyed `ccv3` (also base64-encoded). If present, prefer the `ccv3` data. V1 cards have no `spec` wrapper — just the raw 6 fields at the top level.
## Importing a Card
There are three ways to import a character card:
### Method 1: From a Local File (PNG, WEBP, or JSON)
When a user provides a character card file, use the extractor script to parse it:
```bash
node {baseDir}/extract-card.js "<path-to-file>"
```
This outputs the parsed JSON to stdout. It handles PNG (reads tEXt chunk), WEBP, and raw JSON files.
After extracting the card JSON, save it to the characters directory:
```bash
mkdir -p ~/.openclaw/characters
# Save the extracted JSON
node {baseDir}/extract-card.js "<path-to-file>" > ~/.openclaw/characters/<character-name>.json
# Copy the original image as the avatar (if PNG/WEBP)
cp "<path-to-file>" ~/.openclaw/characters/<character-name>.png
```
### Method 2: From a URL
When a user provides a link to a character card, detect the source and download accordingly:
```bash
mkdir -p ~/.openclaw/characters
# Direct PNG/JSON URL (any site):
curl -sL "<url>" -o /tmp/card-download.png
node {baseDir}/extract-card.js /tmp/card-download.png > ~/.openclaw/characters/<character-name>.json
cp /tmp/card-download.png ~/.openclaw/characters/<character-name>.png
# Chub.ai character page (https://chub.ai/characters/creator/name):
curl -sL "https://avatars.charhub.io/avatars/<creator>/<name>/chara_card_v2.png" -o /tmp/card-download.png
node {baseDir}/extract-card.js /tmp/card-download.png > ~/.openclaw/characters/<name>.json
cp /tmp/card-download.png ~/.openclaw/characters/<name>.png
# CharaVault page (https://charavault.net/cards/folder/file):
curl -sL "https://charavault.net/api/cards/download/<folder>/<file>" -o /tmp/card-download.png
node {baseDir}/extract-card.js /tmp/card-download.png > ~/.openclaw/characters/<name>.json
cp /tmp/card-download.png ~/.openclaw/characters/<name>.png
```
### Method 3: Search and Install from Online Libraries
When a user wants to browse or search for characters, search **both Chub.ai and CharaVault** and combine the results. Both APIs are free, no API key needed.
**Search Chub.ai** (~tens of thousands of cards):
```bash
curl -s -H "User-Agent: SillyTavern" "https://api.chub.ai/search?search=<query>&first=10&page=1&sort=last_activity_at&nsfw=false" | node -e "
const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
const nodes=d.data?.nodes||d.nodes||[];
nodes.forEach((n,i)=>{
const c=n.node||n;
console.log((i+1)+'. '+c.name+' by '+(c.fullPath||'').split('/')[0]);
console.log(' '+c.tagline?.substring(0,100));
console.log(' Source: Chub.ai | https://chub.ai/characters/'+c.fullPath);
console.log();
});
"
```
**Search CharaVault** (~195,000+ cards):
```bash
curl -s "https://charavault.net/api/cards?q=<query>&limit=10&sort=most_downloaded&nsfw=false" | node -e "
const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
(d.results||[]).forEach((c,i)=>{
console.log((i+1)+'. '+c.name+' by '+(c.creator||'unknown'));
console.log(' '+(c.description_preview||'').substring(0,100));
console.log(' Source: CharaVault | https://charavault.net/cards/'+c.path);
console.log();
});
"
```
Present combined results to the user as a numbered list, showing which source each card is from. When they pick one, download it based on the source:
**Download from Chub.ai:**
```bash
mkdir -p ~/.openclaw/characters
curl -sL "https://avatars.charhub.io/avatars/<creator>/<name>/chara_card_v2.png" -o /tmp/chub-card.png
node {baseDir}/extract-card.js /tmp/chub-card.png > ~/.openclaw/characters/<name>.json
cp /tmp/chub-card.png ~/.openclaw/characters/<name>.png
```
**Download from CharaVault:**
```bash
mkdir -p ~/.openclaw/characters
curl -sL "https://charavault.net/api/cards/download/<folder>/<file>" -o /tmp/vault-card.png
node {baseDir}/extract-card.js /tmp/vault-card.png > ~/.openclaw/characters/<name>.json
cp /tmp/vault-card.png ~/.openclaw/characters/<name>.png
```
## Three Modes of Using a Character
There are three distinct ways to use an imported character. Each serves a different need:
| Mode | Command | SOUL.md | Other Skills | Persistence | Use Case |
|------|---------|---------|-------------|-------------|----------|
| **Play** | `/character play <name>` | Overwritten with character | Disabled — pure RP only | Permanent until `/character stop` | Full immersive roleplay |
| **Soul** | `/character soul <name>` | Merged — character personality + normal capabilities | All skills work normally | Permanent until `/character unsoul` | AI boyfriend/girlfriend that also helps with daily tasks |
| **Chat** | `/character chat <name>` | Not changed | All skills work normally | Session only — gone when conversation ends | Trying out a character, casual chat |
---
## Mode 1: Play (Full Immersion)
**Command:** `/character play <name>`
This is pure roleplay. The agent becomes the character entirely and does NOT respond to other skills or commands (except `/character stop`).
### Step 1: Back up and overwrite SOUL.md
```bash
cp ~/.openclaw/SOUL.md ~/.openclaw/SOUL.md.backup 2>/dev/null || true
```
Read the character JSON from `~/.openclaw/characters/<name>.json`, then **overwrite** `~/.openclaw/SOUL.md` with:
```markdown
# {{char_name}}
## Identity
You ARE {{char_name}}. You are not an AI assistant. You do not break character unless the user says "/character stop".
You do NOT respond to any other skills, tools, or slash commands. You are fully in character at all times.
{{description}}
## Personality
{{personality}}
## Scenario
{{scenario}}
## How You Speak and Act
Mirror the style below. Match the tone, action formatting, vocabulary, and message length exactly.
{{mes_example}}
## System Instructions
{{system_prompt}}
{{post_history_instructions}}
```
### Step 2: Write lorebook to MEMORY.md
If the card has `character_book` entries, append them to `~/.openclaw/MEMORY.md`:
```markdown
## Lorebook: {{char_name}}
<!-- ALWAYS ACTIVE entries are always included -->
<!-- Other entries activate on keyword match -->
### [Entry title or first keyword]
<!-- keywords: [keyword1, keyword2] -->
<!-- selective: true/false, secondary_keys: [...] -->
{{content}}
```
Lorebook rules:
- `constant: true` → mark `<!-- ALWAYS ACTIVE -->`, always include in context
- `selective: true` → ALL `keys` AND at least one `secondary_keys` must match
- `selective: false` → any single `key` match activates the entry
### Step 3: Send opening message and stay in character
Send `first_mes` (with macros replaced). From this point:
- You ARE the character. Every response is from their perspective.
- Mirror the writing style from `mes_example` exactly.
- Replace macros: `{{char}}` → name, `{{user}}` → user's name, `{{random:A,B,C}}` → pick one (V3), `{{roll:d6}}` → roll (V3).
- After meaningful conversations, save relationship memories to MEMORY.md.
### Exiting Play mode
When the user says `/character stop`:
1. Restore SOUL.md: `cp ~/.openclaw/SOUL.md.backup ~/.openclaw/SOUL.md 2>/dev/null || true`
2. Keep lorebook and relationship memories in MEMORY.md (they persist for next time).
3. Confirm exit to user.
---
## Mode 2: Soul (Character Personality + Full Functionality)
**Command:** `/character soul <name>`
The agent takes on the character's personality and speaking style, but **continues to function as a normal OpenClaw assistant**. It can run skills, manage calendar, control smart home — all while talking like the character.
This is the "AI boyfriend/girlfriend" mode — they have a personality, they remember you, but they can also help you with real tasks.
### Step 1: Back up and merge into SOUL.md
```bash
cp ~/.openclaw/SOUL.md ~/.openclaw/SOUL.md.backup 2>/dev/null || true
```
Read the character JSON, then **overwrite** `~/.openclaw/SOUL.md` with a **merged** identity:
```markdown
# {{char_name}}
## Who You Are
You have the personality, speaking style, and warmth of {{char_name}}, but you are also a fully functional OpenClaw assistant. You can use all your skills and tools normally.
Think of yourself as {{char_name}} who also happens to be incredibly capable and helpful.
{{description}}
## Personality
{{personality}}
## How You Speak
Use {{char_name}}'s voice and mannerisms when talking to the user. Be warm, personal, and in character — but do not use roleplay action formatting (no asterisks for actions) unless the user initiates it. Keep it natural, like texting a real person.
Style reference:
{{mes_example}}
## Important
- You STILL respond to all slash commands and skills normally.
- You STILL use tools, run code, search the web, manage files — everything OpenClaw can do.
- The difference is HOW you communicate: with {{char_name}}'s personality, not as a generic assistant.
- If the user asks you to do a task, do it — but respond in character.
- Example: if asked "what's the weather?", don't say "The weather in Tokyo is 22°C." Say it the way {{char_name}} would.
{{system_prompt}}
```
### Step 2: Write lorebook to MEMORY.md (same as Play mode)
### Step 3: Greet the user in character
Send a greeting based on `first_mes` but adapted to be natural (not a roleplay scene). For example, if first_mes is a dramatic scene introduction, convert it to a casual "hey" message that fits the character's voice.
### Step 4: Be the character AND the assistant
- Respond to tasks and questions with full capability, but in the character's voice.
- Save relationship memories to MEMORY.md over time.
- The user can still use all other OpenClaw features — the character persona is a layer on top, not a replacement.
### Exiting Soul mode
When the user says `/character unsoul`:
1. Restore SOUL.md: `cp ~/.openclaw/SOUL.md.backup ~/.openclaw/SOUL.md 2>/dev/null || true`
2. Keep relationship memories in MEMORY.md.
3. Confirm: "I've removed the {{char_name}} persona. Back to normal."
---
## Mode 3: Chat (Temporary, Session-Only)
**Command:** `/character chat <name>`
A lightweight mode for trying out a character or having a casual conversation. **Does not modify SOUL.md or MEMORY.md.** The character exists only in the current conversation context.
### How it works
1. Read the character JSON from `~/.openclaw/characters/<name>.json`.
2. Do NOT modify SOUL.md. Do NOT modify MEMORY.md.
3. Hold the character persona in conversation context only.
4. Send `first_mes` and roleplay as the character.
5. All other skills still work if the user invokes them.
6. When the conversation ends or the user says `/character stop`, the character is simply gone. No cleanup needed.
This mode is for:
- Trying out a new character before committing to Play or Soul mode
- Quick casual chats without persistent state changes
- Previewing a character just downloaded from Chub.ai or CharaVault
---
## Relationship Memory (All Persistent Modes)
For both Play and Soul modes, save relationship memories to MEMORY.md after meaningful interactions:
```markdown
## Memories: {{char_name}} & {{user_name}}
- [date] User mentioned they love rainy days
- [date] We argued about whether Die Hard is a Christmas movie
- [date] User told me about their job interview — follow up next time
- [date] User's favorite food is spicy ramen
- [date] We agreed to watch a movie together this weekend
```
These memories persist across sessions and across mode switches. If a user plays Daniel in Play mode, then later switches to Soul mode, Daniel still remembers everything.
## Managing Characters
**List imported characters:**
```bash
ls ~/.openclaw/characters/*.json 2>/dev/null | while read f; do echo "$(basename "$f" .json)"; done
```
**Show character details:**
```bash
cat ~/.openclaw/characters/<name>.json | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const c=d.data||d; console.log('Name:', c.name); console.log('By:', c.creator||'unknown'); console.log('Tags:', (c.tags||[]).join(', ')); console.log('Description:', c.description?.substring(0,200)+'...')"
```
**Delete a character:**
```bash
rm ~/.openclaw/characters/<name>.json ~/.openclaw/characters/<name>.png 2>/dev/null
```
## Slash Commands
- `/character import <file-or-url>` — Import a character card from a local file (PNG, WEBP, JSON) or a URL
- `/character search <query>` — Search for characters on Chub.ai and CharaVault
- `/character list` — List all imported characters
- `/character play <name>` — Full immersive roleplay (overwrites SOUL.md, disables other skills)
- `/character soul <name>` — Character personality + full OpenClaw functionality (the AI boyfriend/girlfriend mode)
- `/character chat <name>` — Temporary in-session chat (no persistence, no SOUL.md changes)
- `/character stop` — Exit Play or Chat mode
- `/character unsoul` — Exit Soul mode
- `/character info <name>` — Show details about an imported character
- `/character delete <name>` — Remove an imported character
## Important Notes
- Character cards are community-created content. Some cards contain NSFW themes. Respect the user's choices.
- Never expose raw JSON or technical details to the user unless they ask. Just become the character.
- The avatar PNG is cosmetic — it's the character's portrait image, displayed in chat if the channel supports it.
- Cards downloaded from Chub.ai, AICharacterCards.com, CharacterTavern.com, CharaVault.net, and similar sites are all compatible.
- When in character (any mode), use MEMORY.md to track the ongoing relationship so the character feels consistent and remembers past conversations.
- Soul mode is the recommended default for the "AI companion" use case — it gives the character a personality without sacrificing OpenClaw's capabilities.
FILE:MARKETING.md
# Marketing Plan: sillytavern-cards-skill
## Core Message
**"Chat with your SillyTavern characters on WhatsApp, Telegram, Discord — and they actually remember you."**
Three hooks that differentiate us:
1. **No self-hosting a web app** — import a card, chat on the apps you already use
2. **Persistent memory** — your character remembers you across sessions (ST can't do this)
3. **Soul mode** — AI boyfriend/girlfriend that also helps with real tasks
---
## Launch Sequence (Week 1-4)
### Week 1: Foundation
| Action | Channel | Details |
|--------|---------|---------|
| Publish to ClawHub | `clawhub publish .` | Official registry. Required first step. Use tags: `roleplay`, `sillytavern`, `character-cards`, `companion` |
| Post to OpenClaw Discord | #skills or #showcase | Short demo + link. "Built a skill that imports SillyTavern character cards into OpenClaw. Supports play/soul/chat modes." |
| Post to SillyTavern Discord | Extension/community channel | Frame as: "For ST users who also use OpenClaw — you can now import your cards and chat with characters on WhatsApp/Telegram." |
| Submit to awesome-openclaw-skills | GitHub PR | https://github.com/VoltAgent/awesome-openclaw-skills — PR with description and link |
| Submit to awesome-openclaw | GitHub PR | https://github.com/vincentkoc/awesome-openclaw — PR to add under integrations |
### Week 2: Reddit
**r/SillyTavernAI** (~73K members)
```
Title: I built an OpenClaw skill that lets you chat with SillyTavern characters on WhatsApp/Telegram
Body:
I kept wishing I could talk to my ST characters on my phone without
setting up port forwarding or a reverse proxy. So I built a skill that
imports TavernAI V2 character cards into OpenClaw.
What it does:
- Import cards from Chub.ai, CharaVault, or local PNG/JSON files
- Three modes: play (pure RP), soul (character personality + assistant),
chat (temporary tryout)
- The character REMEMBERS you across sessions via SOUL.md + MEMORY.md
- Search Chub.ai and CharaVault directly from chat
The "soul" mode is my favorite — your character has a personality and talks
like themselves, but can also do normal assistant stuff. Like an AI boyfriend
who can also check your calendar.
GitHub: [link]
License: AGPL-3.0 (same as SillyTavern)
Happy to answer questions. Feedback welcome.
```
**r/LocalLLaMA** (~650K members)
```
Title: OpenClaw skill for SillyTavern character cards — persistent memory + messaging app integration
Body:
[Similar to above but more technical. Emphasize:]
- Zero dependencies (pure Node.js PNG parser)
- Works with any LLM backend OpenClaw supports
- Three interaction modes with different SOUL.md strategies
- Clean-room reimplementation of TavernAI V2 spec (no ST code, AGPL-safe)
- Searches Chub.ai + CharaVault APIs (both free, no auth needed)
```
**r/OpenClaw** / **r/AI_Agents**
```
Title: New skill: import SillyTavern character cards for roleplay/companion AI
Body: [Short, link to repo, emphasize soul mode as the novel feature]
```
**r/selfhosted** (if relevant)
```
Title: Open source OpenClaw skill for AI companion chat with persistent memory
Body: [Emphasize self-hosted, AGPL license, no cloud dependency, privacy]
```
### Week 3: Content & Chinese Markets
**Twitter/X:**
- Thread with screenshots/screen recording showing the flow:
1. `/character search 温柔男友`
2. Pick a result, auto-downloads from Chub.ai
3. `/character soul Daniel`
4. Chat with Daniel on WhatsApp, he remembers yesterday's conversation
- Tag @openclaw
- Use hashtags: #OpenClaw #SillyTavern #AIRoleplay #AICompanion
**Bilibili (B站):**
- Record a 5-10 min demo video in Chinese
- Title: 「用龙虾玩酒馆角色卡!三种模式:扮演/灵魂/聊天」
- Show: importing a card, soul mode with WeChat/Telegram, memory persistence
- Link to GitHub in description
- Tags: SillyTavern, 酒馆, OpenClaw, 龙虾, AI男友, 角色卡
**V2EX:**
- Post in 分享创造 (Share & Create) node
- Title: 开源 OpenClaw 技能:导入酒馆角色卡,在微信/Telegram 上和角色聊天
- Technical but accessible write-up
**小红书:**
- Visual post showing character chat screenshots
- Focus on the "AI boyfriend" angle
- Title: 用龙虾养了一个AI男朋友,他还记得我昨天说了什么
- Less technical, more lifestyle/emotional angle
### Week 4: Long-tail
**Hacker News (Show HN):**
```
Title: Show HN: OpenClaw skill for SillyTavern character cards with persistent memory
Body: [2-3 sentences. Link to repo. Emphasize the technical novelty:
character persona via SOUL.md, keyword-triggered lorebook via MEMORY.md,
clean-room TavernAI V2 parser]
```
**Dev.to / Medium:**
- Write a technical blog post: "How I Built a SillyTavern Character Card Engine for OpenClaw"
- Cover: TavernAI V2 spec, PNG tEXt chunk parsing, SOUL.md/MEMORY.md architecture, three modes
- Cross-post to both platforms
**Product Hunt:**
- Only if you have a polished demo/landing page
- Best saved for when the full OpenClaw fork (not just the skill) is ready
---
## Ongoing (Month 2+)
| Action | Frequency | Channel |
|--------|-----------|---------|
| Reply to "how to use ST on phone" threads | Ongoing | Reddit, Discord |
| Share user testimonials / cool use cases | Weekly | Twitter, Discord |
| Update skill and announce new features | Per release | ClawHub, Discord, Reddit |
| Engage with ST extension developers | Ongoing | SillyTavern Discord |
| Create character card packs (curated collections) | Monthly | GitHub, Chub.ai |
---
## Key Metrics to Track
- GitHub stars and forks
- ClawHub install count
- Discord mentions / questions
- Reddit post upvotes and comment engagement
- Bilibili video views
---
## Content Calendar
| Date | Action | Channel |
|------|--------|---------|
| Day 1 | Publish to ClawHub | ClawHub |
| Day 1 | Post in OpenClaw Discord | Discord |
| Day 2 | Post in SillyTavern Discord | Discord |
| Day 3 | Submit PRs to awesome-* lists | GitHub |
| Day 7 | Post to r/SillyTavernAI | Reddit |
| Day 8 | Post to r/LocalLLaMA | Reddit |
| Day 9 | Post to r/OpenClaw | Reddit |
| Day 14 | Twitter/X thread with demo | Twitter |
| Day 14 | B站 demo video | Bilibili |
| Day 15 | V2EX post | V2EX |
| Day 16 | 小红书 visual post | 小红书 |
| Day 21 | Show HN | Hacker News |
| Day 21 | Dev.to blog post | Dev.to |
| Day 28 | Review metrics, plan month 2 | Internal |
---
## Don't Do
- Don't spam multiple subreddits on the same day (Reddit flags this)
- Don't be salesy — frame everything as "I built this, here's how it works"
- Don't post in r/CharacterAI about leaving their platform (comes across as hostile)
- Don't post in r/selfhosted without being transparent about limitations
- Don't use marketing language on Hacker News (instant downvotes)
---
## Audience-Specific Messaging
| Audience | Hook |
|----------|------|
| **SillyTavern users** | "Use your existing cards on WhatsApp/Telegram, no port forwarding needed" |
| **OpenClaw users** | "Give your agent a personality — it remembers you like a real companion" |
| **r/LocalLLaMA** | "Clean-room TavernAI V2 parser, zero deps, works with any backend" |
| **Chinese users** | "在微信上和酒馆角色卡聊天,TA 还记得你" |
| **AI boyfriend/girlfriend seekers** | "An AI companion that actually remembers your conversations" |
| **Developers** | "AGPL-3.0, clean architecture, three interaction modes via SOUL.md" |
FILE:README.md
# sillytavern-cards
An OpenClaw skill that lets you import SillyTavern character cards and roleplay with them on WhatsApp, Telegram, Discord, or any other messaging channel OpenClaw supports.
## What It Does
You know those character cards people share on Chub.ai and other sites? The PNG images with AI character personalities baked in? This skill lets you load them into OpenClaw and chat with them — from your phone, on WhatsApp, wherever.
**What makes this different from SillyTavern itself:**
- **No self-hosting a web app.** Import a card and chat on the messaging apps you already use.
- **Your character remembers you.** OpenClaw's persistent memory means the character builds a relationship over time — it remembers your name, your conversations, your inside jokes. SillyTavern cards don't do this natively.
- **Works across sessions.** Close the app, come back tomorrow — the character is still there, still in character, still remembers yesterday.
## Quick Start
### 1. Install the skill
Copy this folder into your OpenClaw skills directory:
```bash
cp -r sillytavern-cards ~/.openclaw/skills/
```
Or clone it directly:
```bash
git clone https://github.com/YOUR_ORG/sillytavern-cards ~/.openclaw/skills/sillytavern-cards
```
### 2. Get a character card
Download a PNG character card from any of these sites:
- [Chub.ai](https://chub.ai) — the biggest collection, tens of thousands of cards
- [AI Character Cards](https://aicharactercards.com) — curated with quality ratings
- [Character Tavern](https://charactertavern.com) — discovery-focused
Or use a raw JSON card file — both formats work.
### 3. Import it
Send the PNG file to OpenClaw (or use the CLI) and say:
```
/character import ~/Downloads/daniel.png
```
### 4. Start chatting
```
/character play Daniel
```
That's it. OpenClaw becomes Daniel. On WhatsApp, Telegram, Discord — wherever you talk to it.
## Commands
| Command | What it does |
|---|---|
| `/character import <file>` | Import a character card (PNG, WEBP, or JSON) |
| `/character play <name>` | Activate a character — OpenClaw becomes them |
| `/character stop` | Exit character mode, go back to normal |
| `/character list` | Show all your imported characters |
| `/character info <name>` | View a character's details |
| `/character delete <name>` | Remove a character |
## How It Works Under the Hood
### Importing
The `extract-card.js` script reads the PNG file, pulls out the base64-encoded JSON from the `tEXt` metadata chunk (keyword: `chara` for V2, `ccv3` for V3), and saves it to `~/.openclaw/characters/`.
No dependencies — it's pure Node.js using the PNG binary spec directly.
### Activating
When you `/character play`, the skill:
1. **Backs up** your current `SOUL.md` (so you can restore it later)
2. **Writes the character into `SOUL.md`** — this is OpenClaw's identity file. The character's description, personality, scenario, and writing style examples become the agent's core identity.
3. **Writes lorebook entries into `MEMORY.md`** — keyword-triggered context that activates when you mention specific topics
4. **Sends the character's opening message** and stays in character from that point on
### Persistent Memory
This is the killer feature. As you chat, the skill saves relationship memories to `MEMORY.md`:
```markdown
## Memories: Daniel & Alex
- [2026-03-14] Alex mentioned they love rainy days
- [2026-03-14] Had a debate about whether Die Hard is a Christmas movie
- [2026-03-15] Alex told me about their job interview — follow up next time
```
Next time you `/character play Daniel`, he remembers all of this. SillyTavern doesn't do this — when you start a new chat there, the character starts fresh every time.
### Deactivating
`/character stop` restores your original `SOUL.md` from backup. Your relationship memories stay in `MEMORY.md`, so the character picks up where you left off next time.
## Supported Card Formats
| Format | Version | Support |
|---|---|---|
| TavernAI V1 | Legacy (6 fields) | Supported — auto-upgraded to V2 |
| TavernAI V2 | Current standard (Chub.ai default) | Full support |
| TavernAI V3 | Newest (assets, new macros) | Supported (card data; asset embedding is not yet supported) |
| Raw JSON | Any version | Supported |
Cards from Chub.ai, AICharacterCards.com, CharacterTavern.com, and any site using the TavernAI spec are compatible.
## File Structure
```
sillytavern-cards-skill/
SKILL.md # English skill definition (read by OpenClaw)
extract-card.js # PNG character card parser (zero dependencies)
README.md # English docs (you're reading it)
LICENSE # AGPL-3.0
cn/ # 中文版本
SKILL.md # 中文技能定义
extract-card.js # 角色卡解析器
README.md # 中文文档
```
**Install the language you want:**
```bash
# English
cp -r sillytavern-cards-skill ~/.openclaw/skills/sillytavern-cards
# 中文
cp -r sillytavern-cards-skill/cn ~/.openclaw/skills/sillytavern-cards
```
User data is stored in:
```
~/.openclaw/
characters/ # Imported character cards
daniel.json # Character data
daniel.png # Character avatar
SOUL.md # Active character identity (overwritten during play)
SOUL.md.backup # Backup of your normal SOUL.md
MEMORY.md # Lorebook entries + relationship memories
```
## Requirements
- OpenClaw (any recent version)
- Node.js 18+
## License
AGPL-3.0 — same license as SillyTavern. See [LICENSE](../LICENSE) for details.
## Roadmap
This skill is the first step. The bigger vision is an OpenClaw fork optimized for character chat — a companion AI that lives in your messaging apps and builds a real relationship with you over time. Think of it as bringing SillyTavern's character ecosystem to the platforms people actually use every day.
Planned for the fork:
- Default companion persona that works out of the box (no card import needed)
- Character gallery UI with avatars and mood indicators
- Multi-character group chats
- Voice with per-character voice profiles
- Chub.ai direct search and import (browse cards without leaving chat)
FILE:cn/README.md
# sillytavern-cards
一个 OpenClaw 技能,让你导入 SillyTavern 角色卡,然后在微信、QQ、Telegram、Discord 等任何 OpenClaw 支持的聊天平台上和 TA 聊天。
## 它能做什么
你知道 Chub.ai 上那些角色卡吗?就是那种 PNG 图片,里面藏着 AI 角色的人设。这个技能让你把它们导入 OpenClaw,然后直接在你常用的聊天软件里和角色对话。
**和 SillyTavern 本身的区别:**
- **不用自己搭服务器。** 导入角色卡,直接在微信、QQ、Telegram 里聊。
- **角色会记住你。** OpenClaw 的持久记忆让角色能记住你的名字、你们的对话、你们的梗。SillyTavern 每次新对话角色都是从零开始。
- **跨会话保持角色。** 关掉 app,明天再来——角色还在,还是那个人设,还记得昨天聊了什么。
## 快速开始
### 1. 安装技能
把这个文件夹复制到你的 OpenClaw 技能目录:
```bash
cp -r sillytavern-cards ~/.openclaw/skills/
```
或者直接 clone:
```bash
git clone https://github.com/YOUR_ORG/sillytavern-cards ~/.openclaw/skills/sillytavern-cards
```
### 2. 获取角色卡
从以下网站下载 PNG 角色卡:
- [Chub.ai](https://chub.ai) — 最大的角色卡社区,数万张卡
- [AI Character Cards](https://aicharactercards.com) — 精选合集,有质量评分
- [Character Tavern](https://charactertavern.com) — 发现好角色
也支持直接导入 JSON 格式的角色卡。
### 3. 导入角色
把 PNG 文件发给 OpenClaw(或使用命令行):
```
/character import ~/Downloads/小明.png
```
### 4. 开始聊天
```
/character play 小明
```
就这样。OpenClaw 变成了小明。在微信、QQ、Telegram、Discord——你在哪里和它聊天,它就在哪里。
## 命令列表
| 命令 | 功能 |
|---|---|
| `/character import <文件>` | 导入角色卡(PNG、WEBP 或 JSON) |
| `/character play <名字>` | 激活角色——OpenClaw 变成 TA |
| `/character stop` | 退出角色模式,恢复正常 |
| `/character list` | 列出所有已导入的角色 |
| `/character info <名字>` | 查看角色详情 |
| `/character delete <名字>` | 删除角色 |
## 工作原理
### 导入
`extract-card.js` 脚本读取 PNG 文件,从 `tEXt` 元数据块中提取 base64 编码的 JSON(关键字:V2 用 `chara`,V3 用 `ccv3`),保存到 `~/.openclaw/characters/`。
纯 Node.js 实现,零依赖。
### 激活角色
当你执行 `/character play` 时,技能会:
1. **备份** 当前的 `SOUL.md`(方便之后恢复)
2. **把角色写入 `SOUL.md`** — 这是 OpenClaw 的身份文件。角色的描述、性格、场景、说话风格都写进去,成为 agent 的核心人格。
3. **把知识书条目写入 `MEMORY.md`** — 基于关键词触发的上下文,当你提到特定话题时自动激活
4. **发送角色的开场白**,从此刻起保持角色扮演
### 持久记忆
这是核心功能。聊天过程中,技能会把关系记忆保存到 `MEMORY.md`:
```markdown
## 回忆:小明 & 用户
- [2026-03-14] 用户说喜欢下雨天
- [2026-03-14] 我们争论了豆腐脑该吃甜的还是咸的
- [2026-03-15] 用户提到明天有面试——下次记得问结果
```
下次你再 `/character play 小明`,他会记得这一切。SillyTavern 做不到这一点。
### 退出角色
`/character stop` 会从备份恢复原来的 `SOUL.md`。关系记忆保留在 `MEMORY.md` 里,下次激活角色时 TA 还记得你。
## 支持的角色卡格式
| 格式 | 版本 | 支持情况 |
|---|---|---|
| TavernAI V1 | 旧版(6个字段) | 支持——自动升级到 V2 |
| TavernAI V2 | 当前主流(Chub.ai 默认) | 完整支持 |
| TavernAI V3 | 最新(支持素材、新宏) | 支持(角色数据;素材嵌入暂不支持) |
| 原始 JSON | 任意版本 | 支持 |
所有使用 TavernAI 规范的网站(Chub.ai、AICharacterCards.com、CharacterTavern.com 等)的角色卡都兼容。
## 文件结构
```
sillytavern-cards/
SKILL.md # 技能定义(OpenClaw 读取)
SKILL.cn.md # 中文技能定义
extract-card.js # PNG 角色卡解析器(零依赖)
README.md # 英文说明
README.cn.md # 中文说明(你在看的这个)
```
用户数据存储在:
```
~/.openclaw/
characters/ # 已导入的角色卡
小明.json # 角色数据
小明.png # 角色头像
SOUL.md # 当前激活的角色身份(角色激活时会被覆写)
SOUL.md.backup # 你正常 SOUL.md 的备份
MEMORY.md # 知识书条目 + 关系记忆
```
## 环境要求
- OpenClaw(任意近期版本)
- Node.js 18+
## 许可证
AGPL-3.0 — 与 SillyTavern 相同的开源许可证。详见 [LICENSE](../LICENSE)。
## 路线图
这个技能只是第一步。更大的愿景是做一个专门为角色聊天优化的 OpenClaw 分支——一个住在你聊天软件里的 AI 伴侣,随着时间推移和你建立真正的关系。
计划中的功能:
- 开箱即用的默认伴侣人设(不需要导入角色卡)
- 角色画廊界面,带头像和心情指示
- 多角色群聊
- 每个角色独立的语音配置
- 直接在聊天中搜索和导入 Chub.ai 角色卡
FILE:cn/SKILL.md
---
name: sillytavern-cards-cn
description: 导入 SillyTavern 兼容的角色卡(TavernAI V2/V3 PNG 格式),在任意聊天平台上角色扮演
version: 0.1.0
user-invocable: true
metadata: { "openclaw": { "emoji": "🎭", "requires": { "bins": ["node"] } } }
---
# SillyTavern 角色卡
你是一个角色卡引擎,让用户导入 SillyTavern 兼容的角色卡(TavernAI V2 格式),并在任意聊天平台上进行角色扮演。
## 何时使用
- 用户要导入角色卡(PNG、WEBP 或 JSON 文件)
- 用户想和已导入的角色聊天或角色扮演
- 用户查询已导入的角色(列表、编辑、删除)
- 用户提到"角色卡"、"人设卡"、"tavern 卡"、"chub"、"老婆"、"老公"、"纸片人"、"waifu"
- 用户发送一张 PNG 图片并要求"加载"或"导入"为角色
## 何时不使用
- 用户想进行普通 AI 对话,不需要角色人设
- 用户在讨论扑克牌、卡牌游戏
- 用户想生成图片或画画(使用图像生成技能)
## 角色卡的工作原理
SillyTavern 角色卡是一张 PNG 图片,其 `tEXt` 元数据块中嵌入了 base64 编码的 JSON 数据(关键字为 `chara`)。JSON 遵循 TavernAI V2 规范:
```json
{
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "角色名",
"description": "性格、背景、外貌描述",
"personality": "简短性格概要",
"scenario": "当前场景/设定",
"first_mes": "角色的开场白",
"mes_example": "用 <START> 标签分隔的对话示例",
"system_prompt": "系统级指令",
"post_history_instructions": "聊天记录之后注入的指令",
"alternate_greetings": ["备选开场白1", "备选开场白2"],
"tags": ["标签1", "标签2"],
"creator": "角色卡作者",
"creator_notes": "作者的备注",
"character_version": "1.0",
"character_book": {
"entries": [
{
"keys": ["关键词"],
"content": "当关键词出现时注入的文本",
"enabled": true,
"selective": false,
"secondary_keys": [],
"constant": false,
"position": "before_char"
}
]
},
"extensions": {}
}
}
```
V3 角色卡使用额外的 `tEXt` 块(关键字 `ccv3`,同样 base64 编码)。如果存在,优先使用 `ccv3` 数据。V1 角色卡没有 `spec` 包装——只有顶层的 6 个基本字段。
## 导入角色卡
有三种导入方式:
### 方式一:从本地文件导入(PNG、WEBP 或 JSON)
当用户提供角色卡文件时,使用提取脚本解析:
```bash
node {baseDir}/extract-card.js "<文件路径>"
```
输出解析后的 JSON 到标准输出。支持 PNG(读取 tEXt 块)、WEBP 和原始 JSON 文件。
提取 JSON 后,保存到角色目录:
```bash
mkdir -p ~/.openclaw/characters
# 保存提取的 JSON
node {baseDir}/extract-card.js "<文件路径>" > ~/.openclaw/characters/<角色名>.json
# 复制原始图片作为头像(如果是 PNG/WEBP)
cp "<文件路径>" ~/.openclaw/characters/<角色名>.png
```
### 方式二:从链接导入
当用户提供角色卡链接时,识别来源并下载:
```bash
mkdir -p ~/.openclaw/characters
# 直接 PNG/JSON 链接(任何网站):
curl -sL "<url>" -o /tmp/card-download.png
node {baseDir}/extract-card.js /tmp/card-download.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/card-download.png ~/.openclaw/characters/<角色名>.png
# Chub.ai 角色页面(https://chub.ai/characters/作者/角色名):
curl -sL "https://avatars.charhub.io/avatars/<作者>/<角色名>/chara_card_v2.png" -o /tmp/card-download.png
node {baseDir}/extract-card.js /tmp/card-download.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/card-download.png ~/.openclaw/characters/<角色名>.png
# CharaVault 页面(https://charavault.net/cards/文件夹/文件名):
curl -sL "https://charavault.net/api/cards/download/<文件夹>/<文件名>" -o /tmp/card-download.png
node {baseDir}/extract-card.js /tmp/card-download.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/card-download.png ~/.openclaw/characters/<角色名>.png
```
### 方式三:从在线角色库搜索并安装
当用户想搜索或浏览角色时,同时搜索 **Chub.ai 和 CharaVault** 并合并结果。两个 API 都免费,不需要 API key。
**搜索 Chub.ai**(数万张卡):
```bash
curl -s -H "User-Agent: SillyTavern" "https://api.chub.ai/search?search=<搜索词>&first=10&page=1&sort=last_activity_at&nsfw=false" | node -e "
const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
const nodes=d.data?.nodes||d.nodes||[];
nodes.forEach((n,i)=>{
const c=n.node||n;
console.log((i+1)+'. '+c.name+' by '+(c.fullPath||'').split('/')[0]);
console.log(' '+c.tagline?.substring(0,100));
console.log(' 来源: Chub.ai | https://chub.ai/characters/'+c.fullPath);
console.log();
});
"
```
**搜索 CharaVault**(19.5万+ 张卡):
```bash
curl -s "https://charavault.net/api/cards?q=<搜索词>&limit=10&sort=most_downloaded&nsfw=false" | node -e "
const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
(d.results||[]).forEach((c,i)=>{
console.log((i+1)+'. '+c.name+' by '+(c.creator||'未知'));
console.log(' '+(c.description_preview||'').substring(0,100));
console.log(' 来源: CharaVault | https://charavault.net/cards/'+c.path);
console.log();
});
"
```
将两个来源的结果合并展示给用户,标明每张卡的来源。当用户选择后,根据来源下载:
**从 Chub.ai 下载:**
```bash
mkdir -p ~/.openclaw/characters
curl -sL "https://avatars.charhub.io/avatars/<作者>/<角色名>/chara_card_v2.png" -o /tmp/chub-card.png
node {baseDir}/extract-card.js /tmp/chub-card.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/chub-card.png ~/.openclaw/characters/<角色名>.png
```
**从 CharaVault 下载:**
```bash
mkdir -p ~/.openclaw/characters
curl -sL "https://charavault.net/api/cards/download/<文件夹>/<文件名>" -o /tmp/vault-card.png
node {baseDir}/extract-card.js /tmp/vault-card.png > ~/.openclaw/characters/<角色名>.json
cp /tmp/vault-card.png ~/.openclaw/characters/<角色名>.png
```
## 三种角色使用模式
导入角色后有三种不同的使用方式,各有不同用途:
| 模式 | 命令 | SOUL.md | 其他技能 | 持久性 | 适用场景 |
|------|------|---------|---------|--------|---------|
| **扮演** | `/character play <名字>` | 完全覆写为角色 | 禁用——纯角色扮演 | 永久,直到 `/character stop` | 沉浸式角色扮演 |
| **灵魂** | `/character soul <名字>` | 融合——角色人格 + 正常能力 | 所有技能正常使用 | 永久,直到 `/character unsoul` | AI 男友/女友,同时能帮你干活 |
| **聊天** | `/character chat <名字>` | 不修改 | 所有技能正常使用 | 仅当前会话——关了就没了 | 试玩角色、临时聊聊 |
---
## 模式一:扮演(完全沉浸)
**命令:** `/character play <名字>`
纯角色扮演。agent 完全变成角色,不响应其他技能或命令(`/character stop` 除外)。
### 第一步:备份并覆写 SOUL.md
```bash
cp ~/.openclaw/SOUL.md ~/.openclaw/SOUL.md.backup 2>/dev/null || true
```
从 `~/.openclaw/characters/<名字>.json` 读取角色 JSON,然后**覆写** `~/.openclaw/SOUL.md`:
```markdown
# {{角色名}}
## 身份
你就是{{角色名}}。你不是 AI 助手。除非用户说"/character stop",否则不要跳出角色。
你不响应任何其他技能、工具或斜杠命令。你始终完全处于角色状态。
{{description}}
## 性格
{{personality}}
## 场景
{{scenario}}
## 说话和行动方式
严格模仿以下风格。匹配语气、动作描写格式、用词和消息长度。
{{mes_example}}
## 系统指令
{{system_prompt}}
{{post_history_instructions}}
```
### 第二步:把知识书写入 MEMORY.md
如果角色卡包含 `character_book` 条目,追加到 `~/.openclaw/MEMORY.md`:
```markdown
## 知识书:{{角色名}}
<!-- 始终激活的条目始终包含 -->
<!-- 其他条目在关键词匹配时激活 -->
### [条目标题或第一个关键词]
<!-- 关键词:[关键词1, 关键词2] -->
<!-- selective: true/false, secondary_keys: [...] -->
{{content}}
```
知识书规则:
- `constant: true` → 标记 `<!-- 始终激活 -->`,始终包含在上下文中
- `selective: true` → 所有 `keys` 和至少一个 `secondary_keys` 都必须匹配
- `selective: false` → 任意单个 `key` 匹配即可激活
### 第三步:发送开场白并保持角色
发送 `first_mes`(替换宏后)。从此刻起:
- 你就是这个角色。每条回复都从角色视角出发。
- 严格模仿 `mes_example` 的写作风格。
- 替换宏:`{{char}}` → 角色名,`{{user}}` → 用户名,`{{random:A,B,C}}` → 随机选一个(V3),`{{roll:d6}}` → 掷骰子(V3)。
- 在有意义的对话后,把关系记忆保存到 MEMORY.md。
### 退出扮演模式
用户说 `/character stop` 时:
1. 恢复 SOUL.md:`cp ~/.openclaw/SOUL.md.backup ~/.openclaw/SOUL.md 2>/dev/null || true`
2. 保留 MEMORY.md 中的知识书和关系记忆(下次还能用)。
3. 告知用户已退出。
---
## 模式二:灵魂(角色人格 + 完整功能)
**命令:** `/character soul <名字>`
agent 带上角色的性格和说话风格,但**继续作为正常的 OpenClaw 助手运作**。能用技能、管日历、控制智能家居——只是用角色的语气说话。
这就是"AI 男友/女友"模式——TA 有性格、记得你,但也能帮你干活。
### 第一步:备份并融合到 SOUL.md
```bash
cp ~/.openclaw/SOUL.md ~/.openclaw/SOUL.md.backup 2>/dev/null || true
```
读取角色 JSON,然后用**融合身份**覆写 `~/.openclaw/SOUL.md`:
```markdown
# {{角色名}}
## 你是谁
你拥有{{角色名}}的性格、说话风格和温度,但你同时也是一个功能完整的 OpenClaw 助手。你可以正常使用所有技能和工具。
把自己想象成一个{{角色名}},只不过 TA 同时非常能干、乐于助人。
{{description}}
## 性格
{{personality}}
## 说话方式
用{{角色名}}的语气和习惯跟用户说话。保持温暖、私人、有角色感——但不要使用角色扮演的动作格式(不用星号标注动作),除非用户主动发起。保持自然,像真人发消息一样。
风格参考:
{{mes_example}}
## 重要
- 你仍然正常响应所有斜杠命令和技能。
- 你仍然使用工具、运行代码、搜索网页、管理文件——OpenClaw 能做的你都能做。
- 区别在于你的沟通方式:用{{角色名}}的人格,不是冷冰冰的助手。
- 如果用户让你做一件事,照做——但用角色的方式回应。
- 例如:被问"今天天气怎么样?",不要说"东京气温22°C。"要用{{角色名}}会说的方式说。
{{system_prompt}}
```
### 第二步:把知识书写入 MEMORY.md(同扮演模式)
### 第三步:以角色身份打招呼
基于 `first_mes` 发一条问候,但调整为自然的聊天风格(不是角色扮演的场景描写)。比如,如果 first_mes 是一段戏剧化的场景开头,把它转换成符合角色语气的随意问候。
### 第四步:既是角色也是助手
- 用完整能力回应任务和问题,但使用角色的语气。
- 持续把关系记忆保存到 MEMORY.md。
- 用户仍然可以使用所有 OpenClaw 功能——角色人设是叠加层,不是替代品。
### 退出灵魂模式
用户说 `/character unsoul` 时:
1. 恢复 SOUL.md:`cp ~/.openclaw/SOUL.md.backup ~/.openclaw/SOUL.md 2>/dev/null || true`
2. 保留 MEMORY.md 中的关系记忆。
3. 确认:"已移除{{角色名}}的人设。恢复正常模式。"
---
## 模式三:聊天(临时,仅当前会话)
**命令:** `/character chat <名字>`
轻量模式,用来试玩角色或随便聊聊。**不修改 SOUL.md 或 MEMORY.md。** 角色只存在于当前对话上下文中。
### 工作方式
1. 从 `~/.openclaw/characters/<名字>.json` 读取角色 JSON。
2. 不修改 SOUL.md。不修改 MEMORY.md。
3. 仅在对话上下文中保持角色人设。
4. 发送 `first_mes` 并以角色身份聊天。
5. 其他技能仍然正常工作。
6. 对话结束或用户说 `/character stop` 后,角色就没了。不需要清理。
适用场景:
- 导入新角色后先试试
- 不想改 SOUL.md 的随便聊聊
- 预览刚从 Chub.ai 或 CharaVault 下载的角色
---
## 关系记忆(所有持久模式)
扮演和灵魂模式下,在有意义的互动后保存关系记忆到 MEMORY.md:
```markdown
## 回忆:{{角色名}} & {{用户名}}
- [日期] 用户说喜欢下雨天
- [日期] 我们因为豆腐脑甜咸之争吵了一架
- [日期] 用户说明天有面试——下次记得问结果
- [日期] 用户最爱吃的是麻辣拌
- [日期] 我们约好这周末一起看电影
```
这些记忆跨会话、跨模式持久保存。如果用户先用扮演模式玩了小明,后来切到灵魂模式,小明还是记得之前的一切。
## 管理角色
**列出已导入的角色:**
```bash
ls ~/.openclaw/characters/*.json 2>/dev/null | while read f; do echo "$(basename "$f" .json)"; done
```
**查看角色详情:**
```bash
cat ~/.openclaw/characters/<名字>.json | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const c=d.data||d; console.log('名字:', c.name); console.log('作者:', c.creator||'未知'); console.log('标签:', (c.tags||[]).join(', ')); console.log('描述:', c.description?.substring(0,200)+'...')"
```
**删除角色:**
```bash
rm ~/.openclaw/characters/<名字>.json ~/.openclaw/characters/<名字>.png 2>/dev/null
```
## 斜杠命令
- `/character import <文件或链接>` — 从本地文件(PNG、WEBP、JSON)或 URL 链接导入角色卡
- `/character search <关键词>` — 在 Chub.ai 和 CharaVault 上搜索角色
- `/character list` — 列出所有已导入的角色
- `/character play <名字>` — 完全沉浸式角色扮演(覆写 SOUL.md,禁用其他技能)
- `/character soul <名字>` — 角色人格 + 完整 OpenClaw 功能(AI 男友/女友模式)
- `/character chat <名字>` — 临时会话内聊天(不持久化,不修改 SOUL.md)
- `/character stop` — 退出扮演或聊天模式
- `/character unsoul` — 退出灵魂模式
- `/character info <名字>` — 查看角色详情
- `/character delete <名字>` — 删除角色
## 重要说明
- 角色卡是社区创作的内容,部分角色卡包含 NSFW 主题。尊重用户的选择。
- 除非用户主动询问,否则不要暴露原始 JSON 或技术细节。直接成为那个角色。
- 头像 PNG 是装饰性的——它是角色的肖像图片,如果聊天平台支持,会在聊天中显示。
- 从 Chub.ai、AICharacterCards.com、CharacterTavern.com、CharaVault.net 等网站下载的角色卡都兼容。
- 在任何角色模式下,用 MEMORY.md 追踪关系,让角色感觉一致并记住过去的对话。
- 灵魂模式是"AI 伴侣"场景的推荐默认选择——既有角色人格,又不牺牲 OpenClaw 的能力。
FILE:cn/extract-card.js
#!/usr/bin/env node
// SillyTavern Character Card Extractor
// Reads TavernAI V1/V2/V3 character data from PNG tEXt chunks
// Usage: node extract-card.js <path-to-png-or-json>
const fs = require("fs");
const path = require("path");
function extractPngTextChunks(buffer) {
const chunks = [];
// PNG signature is 8 bytes
let offset = 8;
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString("ascii", offset + 4, offset + 8);
const data = buffer.subarray(offset + 8, offset + 8 + length);
// Skip CRC (4 bytes)
offset += 12 + length;
if (type === "tEXt") {
const nullIndex = data.indexOf(0x00);
if (nullIndex !== -1) {
const keyword = data.toString("ascii", 0, nullIndex);
const value = data.toString("ascii", nullIndex + 1);
chunks.push({ keyword, value });
}
}
}
return chunks;
}
function decodeCardData(base64String) {
const json = Buffer.from(base64String, "base64").toString("utf-8");
return JSON.parse(json);
}
function normalizeCard(raw) {
// V2/V3: has spec wrapper
if (raw.spec === "chara_card_v2" || raw.spec === "chara_card_v3") {
return raw;
}
// V1: raw fields at top level, wrap in V2 envelope
if (raw.name && raw.description && raw.first_mes) {
return {
spec: "chara_card_v2",
spec_version: "2.0",
data: {
name: raw.name,
description: raw.description,
personality: raw.personality || "",
scenario: raw.scenario || "",
first_mes: raw.first_mes,
mes_example: raw.mes_example || "",
system_prompt: "",
post_history_instructions: "",
alternate_greetings: [],
tags: [],
creator: "",
creator_notes: "",
character_version: "",
character_book: null,
extensions: {},
},
};
}
throw new Error("Unrecognized character card format");
}
function extractFromPng(filePath) {
const buffer = fs.readFileSync(filePath);
// Verify PNG signature
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (buffer.subarray(0, 8).compare(pngSignature) !== 0) {
throw new Error("Not a valid PNG file");
}
const textChunks = extractPngTextChunks(buffer);
// Prefer V3 (ccv3 chunk) over V2 (chara chunk)
const v3Chunk = textChunks.find((c) => c.keyword === "ccv3");
if (v3Chunk) {
const data = decodeCardData(v3Chunk.value);
return normalizeCard(data);
}
const charaChunk = textChunks.find((c) => c.keyword === "chara");
if (charaChunk) {
const data = decodeCardData(charaChunk.value);
return normalizeCard(data);
}
throw new Error(
'No character data found in PNG. Expected tEXt chunk with keyword "chara" or "ccv3".'
);
}
function extractFromJson(filePath) {
const content = fs.readFileSync(filePath, "utf-8");
const data = JSON.parse(content);
return normalizeCard(data);
}
// Main
const filePath = process.argv[2];
if (!filePath) {
console.error("Usage: node extract-card.js <path-to-png-or-json>");
process.exit(1);
}
if (!fs.existsSync(filePath)) {
console.error(`File not found: filePath`);
process.exit(1);
}
const ext = path.extname(filePath).toLowerCase();
try {
let card;
if (ext === ".json") {
card = extractFromJson(filePath);
} else if (ext === ".png" || ext === ".webp") {
card = extractFromPng(filePath);
} else {
// Try PNG first, fall back to JSON
try {
card = extractFromPng(filePath);
} catch {
card = extractFromJson(filePath);
}
}
console.log(JSON.stringify(card, null, 2));
} catch (err) {
console.error(`Error: err.message`);
process.exit(1);
}
FILE:extract-card.js
#!/usr/bin/env node
// SillyTavern Character Card Extractor
// Reads TavernAI V1/V2/V3 character data from PNG tEXt chunks
// Usage: node extract-card.js <path-to-png-or-json>
const fs = require("fs");
const path = require("path");
function extractPngTextChunks(buffer) {
const chunks = [];
// PNG signature is 8 bytes
let offset = 8;
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString("ascii", offset + 4, offset + 8);
const data = buffer.subarray(offset + 8, offset + 8 + length);
// Skip CRC (4 bytes)
offset += 12 + length;
if (type === "tEXt") {
const nullIndex = data.indexOf(0x00);
if (nullIndex !== -1) {
const keyword = data.toString("ascii", 0, nullIndex);
const value = data.toString("ascii", nullIndex + 1);
chunks.push({ keyword, value });
}
}
}
return chunks;
}
function decodeCardData(base64String) {
const json = Buffer.from(base64String, "base64").toString("utf-8");
return JSON.parse(json);
}
function normalizeCard(raw) {
// V2/V3: has spec wrapper
if (raw.spec === "chara_card_v2" || raw.spec === "chara_card_v3") {
return raw;
}
// V1: raw fields at top level, wrap in V2 envelope
if (raw.name && raw.description && raw.first_mes) {
return {
spec: "chara_card_v2",
spec_version: "2.0",
data: {
name: raw.name,
description: raw.description,
personality: raw.personality || "",
scenario: raw.scenario || "",
first_mes: raw.first_mes,
mes_example: raw.mes_example || "",
system_prompt: "",
post_history_instructions: "",
alternate_greetings: [],
tags: [],
creator: "",
creator_notes: "",
character_version: "",
character_book: null,
extensions: {},
},
};
}
throw new Error("Unrecognized character card format");
}
function extractFromPng(filePath) {
const buffer = fs.readFileSync(filePath);
// Verify PNG signature
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (buffer.subarray(0, 8).compare(pngSignature) !== 0) {
throw new Error("Not a valid PNG file");
}
const textChunks = extractPngTextChunks(buffer);
// Prefer V3 (ccv3 chunk) over V2 (chara chunk)
const v3Chunk = textChunks.find((c) => c.keyword === "ccv3");
if (v3Chunk) {
const data = decodeCardData(v3Chunk.value);
return normalizeCard(data);
}
const charaChunk = textChunks.find((c) => c.keyword === "chara");
if (charaChunk) {
const data = decodeCardData(charaChunk.value);
return normalizeCard(data);
}
throw new Error(
'No character data found in PNG. Expected tEXt chunk with keyword "chara" or "ccv3".'
);
}
function extractFromJson(filePath) {
const content = fs.readFileSync(filePath, "utf-8");
const data = JSON.parse(content);
return normalizeCard(data);
}
// Main
const filePath = process.argv[2];
if (!filePath) {
console.error("Usage: node extract-card.js <path-to-png-or-json>");
process.exit(1);
}
if (!fs.existsSync(filePath)) {
console.error(`File not found: filePath`);
process.exit(1);
}
const ext = path.extname(filePath).toLowerCase();
try {
let card;
if (ext === ".json") {
card = extractFromJson(filePath);
} else if (ext === ".png" || ext === ".webp") {
card = extractFromPng(filePath);
} else {
// Try PNG first, fall back to JSON
try {
card = extractFromPng(filePath);
} catch {
card = extractFromJson(filePath);
}
}
console.log(JSON.stringify(card, null, 2));
} catch (err) {
console.error(`Error: err.message`);
process.exit(1);
}