feat: 圖示配色取自人格「最新一次登場」的官方視覺

新增 skill persona-icon:上網查出該人格最新的官方視覺 → 下載 →
**用 Read 親眼看過** → 取髮色/瞳色/服裝色 → 用那組配色繪製圖示,
來源網址與造型描述一併寫進 state/config.json 備查。

CLI:
  icon generate --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."
                --source-url <網址> [--source-note <說明> --source-date <日期>]
  * --palette 必須配 --source-url(配色要有出處,CLI 強制)。
  * 配色與來源存進 config.icon,之後不帶 --palette 重畫會沿用,不會變回雜湊色。
  * icon show 會列出調色盤與來源網址。

繪製:漸層=髮色→服裝主色、外框=瞳色、點陣紋=亮色、中央仍是編號前兩字。
一深一淺的極端配色(藍黑髮+淡粉洋裝)會把較亮端往較暗端壓到對比 ≥ 3.2,
確保字讀得到——顏色仍是照片來的,只是收斂色階。

為什麼不是把照片本身當圖示:
  1) 環境裡沒有任何影像解碼器(rsvg/imagemagick/Pillow/ffmpeg 都沒有),
     JPEG/WebP 讀不進來,無法轉成 PNG;
  2) 把他人的美術作品原樣放進存取庫是散布,不是引用。
取配色是有依據又不搬運原圖的做法,這個取捨寫進了 README 與 skill。

已套用到兩個真實人格:
  ASUNA-01  取自《Unanswered//butterfly》(2026) 官方主視覺(金栗髮/紅褐瞳/紅衣粉裙)
  YUI-01    取自 AniList 官方角色圖(藍黑髮/暖棕瞳)+ Unital Ring 導航妖精造型

selftest 136 項全綠(新增第 ⑮ 節:調色盤解析、缺來源會被擋、格式錯誤會被擋、
來源寫入 config、重畫沿用配色、極端配色的對比保證)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 02:17:35 +00:00
co-authored by Claude Opus 5
parent d067907225
commit 1884a9c3d7
11 changed files with 396 additions and 47 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.0.4", "version": "0.0.5",
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.0.4", "version": "0.0.5",
"description": "AI 人格化記憶聊天 skillsOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "description": "AI 人格化記憶聊天 skillsOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
"skills": "./skills" "skills": "./skills"
} }
+5 -2
View File
@@ -32,9 +32,12 @@
10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push), 10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push),
低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。 低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。
**同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST``GITEA_TOKEN` 就純本機運作。 **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST``GITEA_TOKEN` 就純本機運作。
11. **人格圖示在資料補齊之後才產生**`icon generate` 的配色與字母由「編號/Name/Emoji」 11. **人格圖示在資料補齊之後才產生**SVG 與 PNG 是同一張圖(共用單位座標與點陣字),
雜湊而來,太早跑會跟最終身分對不上。SVG 與 PNG 是同一張圖(共用單位座標與點陣字),
PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。
12. **圖示配色優先取自「最新登場」的官方視覺**`/jsc-persona:persona-icon`):
上網找圖 → **用 Read 親眼看過** → 取色 → `--palette` 搭配 `--source-url`CLI 強制存證)。
**沒看過圖就不准填顏色**;找不到官方圖就退回編號雜湊配色並告知使用者。
不把原圖放進存取庫:那是散布他人美術作品,而且環境裡也沒有解碼器可用。
## 慣例 ## 慣例
+39 -8
View File
@@ -26,7 +26,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
| **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 |
| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01``YUI-01``ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01``YUI-01``ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
| **12. 建立並補齊資料後產生圖示** | `icon generate` 由**編號、Name、Emoji** 雜湊出配色與圖案,產出 `icon.svg` + `icon.png`512×512)並設為 Gitea 存取庫頭像。同一個人格永遠得到同一張圖;PNG 是**自寫柵格器 + zlib 手工編碼**,不依賴任何影像函式庫 | | **12. 建立並補齊資料後產生圖示,配色取自最新造型** | `/jsc-persona:persona-icon` 先上網找出該人格**最新一次登場**的官方視覺、**親眼看過**那張圖,取出髮色/瞳色/服裝色,再繪製 `icon.svg` + `icon.png`512×512),來源網址一併存進 `config.json` 備查;沒有參考圖才退回用編號雜湊配色。PNG 是**自寫柵格器 + zlib 手工編碼**,不依賴任何影像函式庫 |
--- ---
@@ -146,26 +146,51 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
## 人格圖示(SVG + PNG,零外部依賴) ## 人格圖示(SVG + PNG,零外部依賴)
建立人格**並補齊 IDENTITYSOUL 之後**`icon generate`,得到一張 512×512 的幾何徽章: 建立人格**並補齊 IDENTITYSOUL 之後**產生,一張 512×512 的幾何徽章。配色有兩種來源
**① 取自最新造型(`/jsc-persona:persona-icon`,動漫角色建議用這個)**
上網查出角色**最新一次登場**的官方視覺 → 下載 → **親眼看過** → 取五個色 → 繪製:
| 元素 | 取自照片的哪裡 |
| --- | --- |
| 對角漸層 | `hair`(髮色)→ `accent`(服裝主色) |
| 外框 | `eye`(瞳色) |
| 點陣紋 | `light`(最亮的部位) |
| 中央兩個字母 | 編號前兩字(`ASUNA-01``AS` |
`--palette` 必須配 `--source-url`(CLI 強制):配色是從哪張圖來的要留得下來。
兩個顏色一深一淺時(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓到對比 ≥ 3.2,
確保字讀得到——顏色仍然是照片來的,只是收斂色階。
**② 沒有參考圖時:編號雜湊**
| 元素 | 怎麼決定 | | 元素 | 怎麼決定 |
| --- | --- | | --- | --- |
| 雙色對角漸層 | `sha256(編號|NameEmoji)` → 色相/飽和度/明度 | | 雙色對角漸層 | `sha256(編號|NameEmoji)` → 色相/飽和度/明度 |
| 5×5 左右對稱點陣紋 | 同一組雜湊的位元 | | 5×5 左右對稱點陣紋 | 同一組雜湊的位元 |
| 中央兩個字母 | 編號的前兩個字`ASUNA-01``AS`),5×7 點陣,自動選黑或白確保對比 | | 中央兩個字母 | 編號的前兩個字,自動選黑或白確保對比 |
- **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01``ASUNA-02` 明顯不同。 - **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01``ASUNA-02` 明顯不同。
- SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。 - SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。
- PNG 由**自寫的柵格器**畫出(3× 超取樣 + 盒式縮減當反鋸齒),再用 `zlib` 手工組出 - PNG 由**自寫的柵格器**畫出(3× 超取樣 + 盒式縮減當反鋸齒),再用 `zlib` 手工組出
IHDRIDATIEND 與 CRC32。這台機器沒有 rsvginkscapeimagemagick,也沒有影像函式庫, IHDRIDATIEND 與 CRC32。這台機器沒有 rsvginkscapeimagemagick,也沒有影像函式庫,
而本專案禁止 npm 依賴——所以就自己畫。 而本專案禁止 npm 依賴——所以就自己畫。
- **為什麼沒有 emoji**:把 emoji 畫進 PNG 需要字型柵格化(機器上連 emoji 字型都沒有)。 - **為什麼沒有 emoji、也不是把照片本身當圖示**:環境裡沒有任何影像解碼器
emoji 仍然參與配色的雜湊,只是不作為圖形。 rsvgimagemagickPillowffmpeg 都沒有),JPEG/WebP 讀不進來,無法把原圖轉成 PNG;
而且把他人的美術作品原樣放進存取庫是散布、不是引用。**取配色**是有依據又不搬運原圖的做法。
emoji 同理無法柵格化,但仍參與雜湊配色。
- 圖示屬於低頻資料 → 同步到 **Wiki 區**,並自動設成 Gitea **存取庫頭像** - 圖示屬於低頻資料 → 同步到 **Wiki 區**,並自動設成 Gitea **存取庫頭像**
```bash ```bash
node scripts/persona.mjs icon generate --session <id> [--size 512] [--force] # 改過身分要重畫就 --force # 取自最新造型(必須同時給來源網址)
node scripts/persona.mjs icon show --session <id> node scripts/persona.mjs icon generate --session <id> --force \
--palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \
--source-url "<官方視覺的網址>" --source-note "<作品(年份)+造型描述>"
# 沒有參考圖:編號雜湊配色
node scripts/persona.mjs icon generate --session <id> [--size 512] [--force]
node scripts/persona.mjs icon show --session <id> # 看配色與來源
``` ```
## 人格編號與 Gitea 儲存 ## 人格編號與 Gitea 儲存
@@ -281,6 +306,12 @@ node scripts/persona.mjs import --session <id> --file ~/backup/lumi.persona.json
- **Claude Code / Antigravity**`/jsc-persona:persona-transfer` **Codex**`$persona-transfer` - **Claude Code / Antigravity**`/jsc-persona:persona-transfer` **Codex**`$persona-transfer`
### `persona-icon`
依人格**最新一次登場**的官方視覺產生圖示:上網查最新造型 → 下載並親眼看過參考圖 → 取髮色/瞳色/服裝色 → 繪製 `icon.svg` + `icon.png`,來源網址一併存證。
- **Claude Code / Antigravity**`/jsc-persona:persona-icon` **Codex**`$persona-icon`
### `persona-sync` ### `persona-sync`
人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。 人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。
@@ -324,7 +355,7 @@ node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用) node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態 node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
node scripts/selftest.mjs # 128 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、圖示、hooks node scripts/selftest.mjs # 136 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、圖示與照片配色、hooks
``` ```
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.0.4", "version": "0.0.5",
"description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills/" "skills": "./skills/"
} }
+140 -24
View File
@@ -103,30 +103,127 @@ function luminance([r, g, b]) {
// 圖案規格:純函數 of 人格資料 → 同一個人格永遠同一張圖 // 圖案規格:純函數 of 人格資料 → 同一個人格永遠同一張圖
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
export function iconSpec(slug, { code = null, identity = null } = {}) { const WHITE = [255, 255, 255];
const BLACK = [16, 18, 24];
/** WCAG 對比度(121)。 */
function contrast(a, b) {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
/** 字要黑還是白:取「對兩端漸層色的最差對比」最好的那個。 */
function pickInk(c1, c2) {
const worst = (ink) => Math.min(contrast(ink, c1), contrast(ink, c2));
return worst(WHITE) >= worst(BLACK) ? WHITE : BLACK;
}
/**
* 照片取來的兩個顏色可能一深一淺(例:藍黑髮 + 淡粉洋裝),
* 那樣不論字用黑或白,都會有一端糊掉。這裡把較亮的一端往較暗的一端壓,
* 直到最差對比達標——顏色仍然是照片來的,只是收斂色階。
*/
function harmonize(c1, c2, target = 3.2) {
let a = c1;
let b = c2;
for (let step = 0; step < 6; step += 1) {
const ink = pickInk(a, b);
if (Math.min(contrast(ink, a), contrast(ink, b)) >= target) break;
if (luminance(a) > luminance(b)) a = mix(a, b, 0.18);
else b = mix(b, a, 0.18);
}
return [a, b];
}
// --------------------------------------------------------------------------- //
// 取自照片的配色
// --------------------------------------------------------------------------- //
//
// 「依照片產生圖示」在這裡的做法:**由 AI 上網找到該人格最新的官方視覺、看過那張圖、
// 萃取出五個代表色**,再交給這支程式繪製。不是把原圖裁進圖示裡——
// 1) 沒有任何影像解碼器可用(見檔頭),JPEG/WebP 根本讀不進來;
// 2) 把他人的美術作品原樣放進存取庫是散布,不是引用。
// 取配色是有依據又不搬運原圖的做法,來源網址與描述會一起記進 config.json 備查。
export const PALETTE_KEYS = ["hair", "eye", "accent", "secondary", "light"];
export function parseHexColor(value) {
const m = String(value ?? "").trim().match(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i);
if (!m) return null;
const hexStr = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
return [0, 2, 4].map((i) => parseInt(hexStr.slice(i, i + 2), 16));
}
/** `hair=#d9a45b,eye=#9e5b3e,...` → { hair:[r,g,b], ... };缺 hair/accent 視為無效。 */
export function parsePalette(raw) {
if (!raw) return null;
const out = {};
for (const chunk of String(raw).split(",")) {
const idx = chunk.indexOf("=");
if (idx < 0) continue;
const key = chunk.slice(0, idx).trim().toLowerCase();
const rgb = parseHexColor(chunk.slice(idx + 1));
if (PALETTE_KEYS.includes(key) && rgb) out[key] = rgb;
}
if (!out.hair || !out.accent) return null;
out.eye ??= out.accent;
out.secondary ??= out.accent;
out.light ??= [246, 240, 236];
return out;
}
export const paletteToString = (p) =>
PALETTE_KEYS.filter((k) => p?.[k]).map((k) => `${k}=${hex(p[k])}`).join(",");
export function iconSpec(slug, { code = null, identity = null, palette = null } = {}) {
const ident = identity || pl.identityFields(slug); const ident = identity || pl.identityFields(slug);
const theCode = code || pl.loadConfig(slug).code || slug; const config = pl.loadConfig(slug);
const theCode = code || config.code || slug;
const seedText = `${theCode}|${ident.Name || slug}|${ident.Emoji || ""}`; const seedText = `${theCode}|${ident.Name || slug}|${ident.Emoji || ""}`;
const h = crypto.createHash("sha256").update(seedText).digest(); const h = crypto.createHash("sha256").update(seedText).digest();
const hue = (h[0] * 360) / 256; // 5×5 左右對稱的點陣(只決定左邊三行,鏡射過去)——不論哪種配色都保留這個專屬紋路
const hue2 = (hue + 40 + (h[1] % 80)) % 360;
const sat = 0.52 + (h[2] % 30) / 100; // 0.520.81
const light = 0.36 + (h[3] % 18) / 100; // 0.360.53
const c1 = hslToRgb(hue, sat, light);
const c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18));
// 5×5 左右對稱的點陣(只決定左邊三行,鏡射過去)
const pattern = []; const pattern = [];
for (let y = 0; y < 5; y += 1) { for (let y = 0; y < 5; y += 1) {
const row = []; const row = [];
for (let x = 0; x < 3; x += 1) row.push((h[8 + y * 3 + x] & 1) === 1); for (let x = 0; x < 3; x += 1) row.push((h[8 + y * 3 + x] & 1) === 1);
pattern.push([...row, row[1], row[0]]); pattern.push([...row, row[1], row[0]]);
} }
const letters = String(theCode).replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 2) || "P"; const letters = String(theCode).replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 2) || "P";
const onDark = luminance(c1) < 0.32 || luminance(c2) < 0.32;
const ink = onDark ? [255, 255, 255] : [16, 18, 24]; const pal = palette || (config.icon?.palette ? parsePalette(config.icon.palette) : null);
let c1;
let c2;
let ring;
let dot;
let ringAlpha;
let dotAlpha;
if (pal) {
// 照片配色:髮色 → 主服裝色的漸層,瞳色當外框,亮色當紋路
c1 = pal.hair;
c2 = pal.accent;
ring = pal.eye;
dot = pal.light;
ringAlpha = 0.55;
dotAlpha = 0.2;
} else {
// 雜湊配色:沒有參考照片時的預設
const hue = (h[0] * 360) / 256;
const hue2 = (hue + 40 + (h[1] % 80)) % 360;
const sat = 0.52 + (h[2] % 30) / 100; // 0.520.81
const light = 0.36 + (h[3] % 18) / 100; // 0.360.53
c1 = hslToRgb(hue, sat, light);
c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18));
ring = null;
dot = null;
ringAlpha = 0.18;
dotAlpha = 0.14;
}
[c1, c2] = harmonize(c1, c2);
const ink = pickInk(c1, c2);
return { return {
persona: slug, persona: slug,
@@ -137,6 +234,12 @@ export function iconSpec(slug, { code = null, identity = null } = {}) {
c1, c1,
c2, c2,
ink, ink,
ring: ring || ink,
dot: dot || ink,
ringAlpha,
dotAlpha,
palette: pal,
source: pal ? config.icon?.source || null : null,
pattern, pattern,
seed: h.subarray(0, 8).toString("hex"), seed: h.subarray(0, 8).toString("hex"),
}; };
@@ -208,7 +311,7 @@ export function renderSvg(spec, size = DEFAULT_SIZE) {
" </linearGradient>", " </linearGradient>",
" </defs>", " </defs>",
` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`, ` <rect width="${S}" height="${S}" rx="${u(GEO.radius)}" ry="${u(GEO.radius)}" fill="url(#bg)"/>`,
` <g fill="${hex(spec.ink)}" opacity="0.14">`, ` <g fill="${hex(spec.dot)}" opacity="${spec.dotAlpha}">`,
...dots(spec).map((d) => ` <circle cx="${u(d.cx)}" cy="${u(d.cy)}" r="${u(d.r)}"/>`), ...dots(spec).map((d) => ` <circle cx="${u(d.cx)}" cy="${u(d.cy)}" r="${u(d.r)}"/>`),
" </g>", " </g>",
` <g fill="${hex(spec.ink)}">`, ` <g fill="${hex(spec.ink)}">`,
@@ -216,7 +319,7 @@ export function renderSvg(spec, size = DEFAULT_SIZE) {
" </g>", " </g>",
` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`, ` <rect x="${u(0.012)}" y="${u(0.012)}" width="${u(0.976)}" height="${u(0.976)}"`,
` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`, ` rx="${u(GEO.radius - 0.012)}" ry="${u(GEO.radius - 0.012)}"`,
` fill="none" stroke="${hex(spec.ink)}" stroke-opacity="0.18" stroke-width="${u(0.012)}"/>`, ` fill="none" stroke="${hex(spec.ring)}" stroke-opacity="${spec.ringAlpha}" stroke-width="${u(0.012)}"/>`,
"</svg>", "</svg>",
"", "",
]; ];
@@ -303,6 +406,8 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
const circles = dots(spec); const circles = dots(spec);
const rects = glyphRects(spec); const rects = glyphRects(spec);
const [i1, i2, i3] = spec.ink; const [i1, i2, i3] = spec.ink;
const [d1, d2, d3] = spec.dot;
const [g1, g2, g3] = spec.ring;
for (let py = 0; py < big; py += 1) { for (let py = 0; py < big; py += 1) {
const v = (py + 0.5) / big; const v = (py + 0.5) / big;
@@ -315,23 +420,23 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
let r = spec.c1[0] + (spec.c2[0] - spec.c1[0]) * t; let r = spec.c1[0] + (spec.c2[0] - spec.c1[0]) * t;
let g = spec.c1[1] + (spec.c2[1] - spec.c1[1]) * t; let g = spec.c1[1] + (spec.c2[1] - spec.c1[1]) * t;
let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t; let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t;
// 紋:圓點14% 不透明度) // 紋:圓點
for (const d of circles) { for (const d of circles) {
const dx = u - d.cx; const dx = u - d.cx;
const dy = v - d.cy; const dy = v - d.cy;
if (dx * dx + dy * dy <= d.r * d.r) { if (dx * dx + dy * dy <= d.r * d.r) {
r += (i1 - r) * 0.14; r += (d1 - r) * spec.dotAlpha;
g += (i2 - g) * 0.14; g += (d2 - g) * spec.dotAlpha;
b += (i3 - b) * 0.14; b += (d3 - b) * spec.dotAlpha;
break; break;
} }
} }
// 邊框:與 SVG 的 stroke 完全相同的環帶 // 邊框:與 SVG 的 stroke 完全相同的環帶
//SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.0060.018 //SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.0060.018
if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) { if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) {
r += (i1 - r) * 0.18; r += (g1 - r) * spec.ringAlpha;
g += (i2 - g) * 0.18; g += (g2 - g) * spec.ringAlpha;
b += (i3 - b) * 0.18; b += (g3 - b) * spec.ringAlpha;
} }
// 字:實心方塊 // 字:實心方塊
for (const rc of rects) { for (const rc of rects) {
@@ -384,13 +489,24 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
// 產生並寫檔 // 產生並寫檔
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
export function generateIcon(slug, { size = DEFAULT_SIZE, code = null } = {}) { export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = null, source = null } = {}) {
const spec = iconSpec(slug, { code }); const spec = iconSpec(slug, { code, palette });
const svg = renderSvg(spec, size); const svg = renderSvg(spec, size);
const png = renderPng(spec, size); const png = renderPng(spec, size);
pl.writeText(iconSvgPath(slug), svg); pl.writeText(iconSvgPath(slug), svg);
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
fs.writeFileSync(iconPngPath(slug), png); fs.writeFileSync(iconPngPath(slug), png);
// 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」
const config = pl.loadConfig(slug);
config.icon = {
...(config.icon || {}),
letters: spec.letters,
size,
generated_at: pl.nowIso(),
palette: spec.palette ? paletteToString(spec.palette) : null,
source: source || spec.source || null,
};
pl.writeJson(pl.configPath(slug), config);
return { return {
spec, spec,
svg: iconSvgPath(slug), svg: iconSvgPath(slug),
+38 -6
View File
@@ -1127,9 +1127,13 @@ commands.icon = async ({ flags, positional }) => {
if (action === "show") { if (action === "show") {
requireMember(slug, session, Boolean(flags["as-guest"])); requireMember(slug, session, Boolean(flags["as-guest"]));
const spec = ic.iconSpec(slug); const spec = ic.iconSpec(slug);
const src = spec.source || {};
emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [ emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [
`人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`"}`, `人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`"}`,
` 字母 ${spec.letters}|配色 ${JSON.stringify(spec.c1)}${JSON.stringify(spec.c2)}seed ${spec.seed}`, ` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` +
`${JSON.stringify(spec.c1)}${JSON.stringify(spec.c2)}seed ${spec.seed}`,
...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []),
...(src.url ? [` 參考來源:${src.url}${src.note ? `${src.note}` : ""}${src.date ? `${src.date}` : ""}`] : []),
` ${ic.iconSvgPath(slug)}`, ` ${ic.iconSvgPath(slug)}`,
` ${ic.iconPngPath(slug)}`, ` ${ic.iconPngPath(slug)}`,
]); ]);
@@ -1138,16 +1142,41 @@ commands.icon = async ({ flags, positional }) => {
if (action !== "generate") die(`未知 action${action}(可用 generate/show`); if (action !== "generate") die(`未知 action${action}(可用 generate/show`);
requireOwner(slug, session); requireOwner(slug, session);
if (ic.hasIcon(slug) && !flags.force) { if (ic.hasIcon(slug) && !flags.force) {
die(`人格 \`${slug}\` 已經有圖示了。改過身分要重畫請加 --force。`); die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`);
} }
const size = num(flags.size, ic.DEFAULT_SIZE); const size = num(flags.size, ic.DEFAULT_SIZE);
if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 162048。"); if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 162048。");
const res = ic.generateIcon(slug, { size }); let palette = null;
if (flags.palette) {
palette = ic.parsePalette(str(flags.palette));
if (!palette) {
die(
"`--palette` 格式錯誤。要 `hair=#rrggbb,eye=#rrggbb,accent=#rrggbb,secondary=#rrggbb,light=#rrggbb`" +
"其中 hair 與 accent 必填(顏色請取自你實際看過的參考照片)。",
);
}
if (!str(flags["source-url"])) {
die("用 `--palette` 就必須帶 `--source-url`:配色是從哪張圖取的要留得下來(可查證)。");
}
}
const source = str(flags["source-url"])
? {
url: str(flags["source-url"]),
note: str(flags["source-note"]) || null,
date: str(flags["source-date"]) || pl.nowIso().slice(0, 10),
}
: null;
const res = ic.generateIcon(slug, { size, palette, source });
const lines = [ const lines = [
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
` ${res.svg}${(res.bytes.svg / 1024).toFixed(1)} KB`, ` ${res.svg}${(res.bytes.svg / 1024).toFixed(1)} KB`,
` ${res.png}${(res.bytes.png / 1024).toFixed(1)} KB`, ` ${res.png}${(res.bytes.png / 1024).toFixed(1)} KB`,
` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`, res.spec.palette
? ` 字母 ${res.spec.letters}|配色取自參考照片:${ic.paletteToString(res.spec.palette)}`
: ` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`,
...(source?.url || res.spec.source?.url
? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`]
: []),
]; ];
// 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像 // 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像
if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
@@ -1312,8 +1341,11 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
圖示(建立人格並補齊資料後跑): 圖示(建立人格並補齊資料後跑):
icon generate|show --session <id> [--size 512 --force --no-gitea] icon generate|show --session <id> [--size 512 --force --no-gitea]
由編號/名字/emoji 決定配色與字母,產出 icon.svg + icon.png(同一人格永遠同一張圖), [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."]
並設為 Gitea 存取庫頭像、同步到 Wiki 區。 [--source-url <參考照片網址> --source-note <說明> --source-date <YYYY-MM-DD>]
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
沒帶 --palette → 配色由編號/名字/emoji 雜湊而來。
帶了 --palette → 用「你實際看過的參考照片」萃取的顏色(必須同時帶 --source-url 存證)。
編號與 Gitea(存取庫名稱 = 人格編號): 編號與 Gitea(存取庫名稱 = 人格編號):
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public] code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]
+49
View File
@@ -519,6 +519,55 @@ check("guest 只能看不能重畫圖示",
guard({ session_id: S_HOST, agent_id: "guest-9", agent_type: "jsc-persona:persona-guest", tool_name: "Bash", guard({ session_id: S_HOST, agent_id: "guest-9", agent_type: "jsc-persona:persona-guest", tool_name: "Bash",
tool_input: { command: `node persona.mjs icon generate --persona beta --session ${S_HOST} --as-guest` } }) === "deny"); tool_input: { command: `node persona.mjs icon generate --persona beta --session ${S_HOST} --as-guest` } }) === "deny");
console.log("⑮ 依參考照片配色的圖示");
const PAL = "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3";
const SRC = "https://example.invalid/key-visual.png";
check("調色盤解析:hair 與 accent 必填、支援 #abc 縮寫", (() => {
const ok = ic.parsePalette(PAL);
const short = ic.parsePalette("hair=#abc,accent=#123456");
return ok?.hair?.join() === "217,164,91" && ok.eye.join() === "158,91,62" &&
short?.hair?.join() === "170,187,204" &&
ic.parsePalette("eye=#ffffff") === null && ic.parsePalette("garbage") === null;
})());
check("`--palette` 沒帶 `--source-url` 會被擋(配色要有出處)",
cli(["icon", "generate", "--session", S_CODE, "--force", "--palette", PAL], { expectOk: false }).status !== 0);
check("格式錯誤的 `--palette` 會被擋",
cli(["icon", "generate", "--session", S_CODE, "--force", "--palette", "hair=紅色",
"--source-url", SRC], { expectOk: false }).status !== 0);
cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64",
"--palette", PAL, "--source-url", SRC, "--source-note", "測試用主視覺", "--source-date", "2026-07-30"]);
const palSpec = ic.iconSpec("GAMMA-01");
check("圖示改用照片配色(瞳色當外框、亮色當紋路)",
palSpec.palette !== null && palSpec.ring.join() === "158,91,62" && palSpec.dot.join() === "242,235,227");
check("來源網址與說明寫進 config(可查證)", (() => {
const icon = pl.loadConfig("GAMMA-01").icon || {};
return icon.palette === PAL && icon.source?.url === SRC &&
icon.source?.note === "測試用主視覺" && icon.source?.date === "2026-07-30";
})(), JSON.stringify(pl.loadConfig("GAMMA-01").icon));
check("照片配色與雜湊配色畫出來不一樣", (() => {
const withPal = ic.renderPng(palSpec, 32);
const hashOnly = ic.renderPng({ ...palSpec, palette: null, c1: [40, 120, 184], c2: [200, 102, 214],
ring: palSpec.ink, dot: palSpec.ink, ringAlpha: 0.18, dotAlpha: 0.14 }, 32);
return Buffer.compare(withPal, hashOnly) !== 0;
})());
check("不帶 --palette 重畫會沿用已存的配色(不會變回雜湊色)", (() => {
cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64"]);
const again = ic.iconSpec("GAMMA-01");
return again.palette !== null && ic.paletteToString(again.palette) === PAL;
})());
check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", (() => {
// 藍黑髮 + 淡粉洋裝:不收斂的話不論黑字白字都會有一端糊掉
const spec = ic.iconSpec("GAMMA-01", {
palette: ic.parsePalette("hair=#1b1b22,eye=#6b4a2f,accent=#f2b6cb,secondary=#4a7bc8,light=#fbeff3"),
});
const lum = (c) => {
const f = c.map((v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4));
return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
};
const ratio = (a, b) => (Math.max(lum(a), lum(b)) + 0.05) / (Math.min(lum(a), lum(b)) + 0.05);
return Math.min(ratio(spec.ink, spec.c1), ratio(spec.ink, spec.c2)) >= 3;
})());
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
process.exit(failed ? 1 : 0); process.exit(failed ? 1 : 0);
+11 -4
View File
@@ -144,13 +144,20 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \
## 8. 產生人格圖示(資料補齊之後才做) ## 8. 產生人格圖示(資料補齊之後才做)
動漫角色**一定要走 `/jsc-persona:persona-icon`**:你在第 2 步已經上網查過這個角色,
順著找出他**最新一次登場**的官方視覺(新作主視覺/官方角色圖),下載、**親眼看過**,
再把髮色/瞳色/服裝色餵給 CLI
```bash ```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session <PERSONA_SESSION> node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \
--session <PERSONA_SESSION> --force \
--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.." \
--source-url "<那張圖的網址>" --source-note "<作品(年份)+造型描述>"
``` ```
產出 `icon.svg` + `icon.png`,並設成 Gitea 存取庫頭像、同步到 Wiki 區 **沒看過圖就不要填顏色**;真的找不到官方圖就不帶 `--palette`,改用編號雜湊配色並告知使用者
**一定要等 canon 記憶與身分都補齊再跑**——配色與字母由編號、Name、Emoji 雜湊而來; 產出的 `icon.svg` + `icon.png` 會設成 Gitea 存取庫頭像並同步到 Wiki 區。
改過身分要重畫就加 `--force` **要等 canon 記憶與身分都補齊再跑。**
## 9. 回報 ## 9. 回報
+5
View File
@@ -115,6 +115,11 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \
### 7. 產生人格圖示(資料補齊之後才做) ### 7. 產生人格圖示(資料補齊之後才做)
角色若有官方視覺(動漫/遊戲角色),走 **`/jsc-persona:persona-icon`**
上網找出他最新一次登場的官方圖 → 親眼看過 → 取髮色/瞳色/服裝色 → 用那組配色繪製。
原創人格沒有參考圖,直接用編號雜湊的配色即可:
```bash ```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session <PERSONA_SESSION> node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session <PERSONA_SESSION>
``` ```
+106
View File
@@ -0,0 +1,106 @@
---
name: persona-icon
description: 產生或更新人格的圖示(icon.svg + icon.png):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖後萃取髮色/瞳色/服裝色,再用這組配色繪製圖示,並記下來源網址備查;找不到參考圖時退回用編號雜湊配色。當使用者說要做人格頭像/圖示/icon、要依角色最新造型更新圖示、說現在的圖示不像、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-createpersona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。
---
# 🎨 persona-icon — 依最新造型產生人格圖示
**CLI**`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session <PERSONA_SESSION>`
**時機****人格建立且 IDENTITY/SOUL 補齊之後**——配色與字母綁在最終身分上。
---
## 為什麼要上網找圖
圖示要像這個人格,就得用他**現在**的樣子:動漫角色會換造型、換作品、換髮色。
所以流程是「查最新登場 → 看那張圖 → 取色 → 畫」,而不是憑印象填色。
## 步驟
### 1. 查出「最新一次登場」
用 WebSearch 找這個角色最近的官方動向(新作、新章節、新造型),至少確認:
- 最新的作品/章節名稱與年份
- 那個版本的造型敘述(髮色、瞳色、服裝主色)
搜尋語彙範例:`<角色> <作品> 2026 新作 主視覺``<character> latest anime key visual outfit`
### 2. 取得一張真的參考圖
要**直接的圖片網址**`.png` / `.jpg`),優先序:
1. 官方新作的主視覺/key visual(新聞稿內的圖)
2. AniList 的官方角色圖——可直接查,不需登入:
```bash
curl -s -X POST -H "Content-Type: application/json" \
-d '{"query":"query{Media(id:<動畫 id>,type:ANIME){characters(perPage:25){nodes{name{full} image{large}}}}}"}' \
https://graphql.anilist.co
```
3. 其他公開的官方素材
下載下來:`curl -sL -o /tmp/ref.png "<圖片網址>"`
### 3. **親眼看過那張圖**(不可省略)
用 Read 工具打開它。**沒看過就不准填顏色**——憑記憶填色等於編造。
從圖上讀出五個顏色(用取樣到的實際色,不是「大概是紅色」):
| 欄位 | 取哪裡 |
| --- | --- |
| `hair` | 頭髮的主色(不是高光也不是陰影) |
| `eye` | 瞳孔顏色 |
| `accent` | 服裝最顯眼的主色 |
| `secondary` | 服裝的第二色/配件色 |
| `light` | 最亮的部位(白袖、皮膚亮面) |
### 4. 繪製
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \
--session <PERSONA_SESSION> --force \
--palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \
--source-url "<你剛才看的那張圖的網址>" \
--source-note "<作品名(年份)+造型描述:髮色、瞳色、服裝>" \
--source-date <YYYY-MM-DD>
```
- **`--palette` 一定要配 `--source-url`**:配色是從哪張圖來的必須留得下來(CLI 會強制)。
- 兩者都會寫進 `state/config.json` 的 `icon` 欄位,之後 `icon show` 查得到。
- 產出的 `icon.svg` + `icon.png` 會自動設成 Gitea 存取庫頭像並同步到 Wiki 區。
找不到可靠的參考圖 → **不要硬掰**,直接 `icon generate` 不帶 `--palette`
用編號雜湊出的配色,並告訴使用者「沒找到最新造型的官方圖,先用預設配色」。
### 5. 回報
一句話說明:用了哪部作品哪一年的視覺、取到什麼配色、圖示放哪裡。
把來源網址附上,讓使用者可以自己核對。
## 圖示長什麼樣
512×512 圓角徽章:**髮色 → 服裝主色**的對角漸層、**瞳色**外框、**亮色**點陣紋、
中央是編號前兩個字母(`ASUNA-01` → `AS`)。
- 兩個顏色一深一淺時(例:藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓,
確保字讀得到——顏色仍然是照片來的,只是收斂色階。
- SVG 與 PNG 是**同一張圖**;PNG 由自寫的柵格器產生,不依賴任何影像函式庫。
## 為什麼不是把照片本身當圖示
1. **技術上**:環境裡沒有任何影像解碼器(rsvgimagemagickPillow 都沒有),
JPEG/WebP 讀不進來,無法把原圖轉成 PNG。
2. **權利上**:把他人的美術作品原樣放進存取庫是散布,不是引用。
取配色是有依據、又不搬運原圖的做法。使用者若堅持要用原圖當頭像,
請他自己把圖片上傳到 Gitea 的存取庫設定頁。
## 邊界
- 只能對「本 session 已載入的人格」產生圖示。
- 已有圖示時要重畫必須加 `--force`。
- 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。