From e986686d0e3ce3541a1bb86a00ca786bac1da32e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 01:49:27 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E4=BA=BA=E6=A0=BC=E7=B7=A8?= =?UTF-8?q?=E8=99=9F=EF=BC=88ASUNA-01=EF=BC=89=E8=88=87=20Gitea=20?= =?UTF-8?q?=E5=84=B2=E5=AD=98=EF=BC=8C=E4=BE=9D=E6=9B=B4=E6=96=B0=E9=A0=BB?= =?UTF-8?q?=E7=8E=87=E5=88=86=E6=AA=94=E6=A1=88=E5=8D=80/Wiki=20=E5=8D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 人格編號 - 編號 = 英文名全大寫 + 兩位索引,同名才遞增:ASUNA-01 / YUI-01 / ASUNA-02。 - 編號同時是新人格的本機目錄名與 Gitea 存取庫名稱(`create` 不給 --persona 就用編號當目錄名;既有小寫 slug 仍然合法,不會被鎖在門外)。 - 中文名由 skill 提議羅馬拼音、使用者確認後帶 `--romaji` 進來; CLI 只吃拉丁字母,避免拼音在程式裡亂猜。 - 新增 `code show|assign|next`;`code assign --rename` 可把既有人格的 目錄名一併改成編號(會轉移鎖與 session 綁定)。 2. Gitea 儲存(scripts/persona-gitea.mjs) - 每個人格一個私有存取庫,庫名 = 編號。本機仍是工作副本,hook 每輪 讀寫本機檔案,**同步失敗永遠不阻斷對話**。 - 依更新頻率分區: 檔案區(高頻)emotion / short-term / inner / said / inbox / mindmap threads / journal → 每輪由 Stop hook 背景 push Wiki 區(低頻)IDENTITY SOUL AGENTS USER / 長期記憶 / INDEX / 心智圖 / 關係圖 → 固化、改身分關係、release 時 push - Gitea 的 wiki 只有根目錄 .md 會變成頁面(1.27 實測子目錄頁面 404), 所以 Wiki 區攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json`, pull 時還原;Home 頁自動列出所有長期記憶連結。 - 新增 `sync status|init|push|pull`;載入時先 pull,兩邊都改過同一個 檔案就停下來不覆蓋本機,交由使用者決定。 - push 遇到 non-fast-forward 會對齊遠端後把工作副本重新疊上去再推。 - 認證走 http.extraHeader(GIT_CONFIG_* 環境變數),token 不寫進 .git/config 也不進 process 參數。 3. 順帶修正 - guard 的 `--persona` 解析原本只吃小寫,大寫編號會漏掉整個跨人格檢查。 - pull 原本會無差別覆蓋本機檔案,本機較新但還沒 push 的內容會被蓋掉; 改成只寫回「遠端真的改過的」與「本機缺少的」。 - `create` 原本吞掉 Gitea 首次推送的結果,失敗是靜默的。 新增 skill:persona-sync(編號、同步、既有人格遷移、衝突處理)。 selftest 116 項全綠(新增 ⑬,含「分區不重不漏」檢查;測試強制 PERSONA_GITEA=off)。 另對真實 Gitea 跑過端對端(建庫→兩區推送→改動→拉回→release),測試用存取庫已刪除。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 8 + README.md | 70 +++- hooks/turn_end.mjs | 10 + plugin.json | 2 +- scripts/persona-gitea.mjs | 585 +++++++++++++++++++++++++++++++++ scripts/persona-lib.mjs | 7 +- scripts/persona.mjs | 285 +++++++++++++++- scripts/selftest.mjs | 71 ++++ skills/persona-anime/SKILL.md | 6 +- skills/persona-create/SKILL.md | 11 +- skills/persona-status/SKILL.md | 6 +- skills/persona-sync/SKILL.md | 109 ++++++ 14 files changed, 1145 insertions(+), 29 deletions(-) create mode 100644 scripts/persona-gitea.mjs create mode 100644 skills/persona-sync/SKILL.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 90068db..d68932e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.2", + "version": "0.0.3", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c9e6844..c5c6c4e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.2", + "version": "0.0.3", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index c03af6c..416c696 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,11 @@ 這三條在劇場模式一樣生效。 8. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」, 其他人格一律 deny——匯出等於把記憶讀出來。 +9. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的 + 本機目錄名與 **Gitea 存取庫名稱**。中文名要先轉羅馬拼音並跟使用者確認拼法。 +10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push), + 低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。 + **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 ## 慣例 @@ -38,3 +43,6 @@ `exportBundle`/`importBundle`)後,**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試。 - 改動重複判定門檻(`similarity`/`REPEAT_THRESHOLD`)後,要用 selftest ⑪ 的對照案例確認 「換句話說同一件事」被擋、「只換關鍵詞」放行。 +- 改動同步分區(`persona-gitea.mjs` 的 `AREAS`)後,selftest ⑬ 的「不重不漏」檢查必須維持全綠: + 人格產生的每個檔案都要**恰好**屬於一區,否則同步會默默漏掉資料。 +- selftest 自己會設 `PERSONA_GITEA=off`,**絕對不要**讓測試碰到真的 Gitea。 diff --git a/README.md b/README.md index 1b2b243..0d652c2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 --- -## 九條硬規則 +## 十一條硬規則 | 規則 | 怎麼做到 | | --- | --- | @@ -24,6 +24,8 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **7. 短期記憶轉入長期記憶有成文條件** | `R1`–`R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 | | **8. 講話像人:推導藏起來、一到三句、不重複** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言——劇場模式同樣適用 | | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | +| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | +| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | --- @@ -43,15 +45,23 @@ flowchart TB subgraph G["Sub Agent(受邀人格,唯讀)"] GA["persona-guest"] end - subgraph S["人格倉庫 ~/.claude/personas"] - PA["alpha/|IDENTITY SOUL 記憶 情緒 心智圖 關係圖"] - PB["beta/|…"] + subgraph S["人格倉庫 ~/.claude/personas(工作副本)"] + PA["ASUNA-01/|IDENTITY SOUL 記憶 情緒 心智圖 關係圖"] + PB["YUI-01/|…"] RM[".rooms/room/transcript.jsonl"] end + subgraph GT["Gitea(存取庫名稱 = 人格編號)"] + GF["ASUNA-01 檔案區
高頻活狀態"] + GW["ASUNA-01 Wiki
低頻設定百科"] + end P -->|"只能碰自己"| PA GA -->|"只能碰自己"| PB P <-->|"唯一合法交流管道"| RM GA <--> RM + PA -->|"每輪背景 push"| GF + PA -->|"固化/改身分/release"| GW + GF -.->|"載入時 pull"| PA + GW -.-> PA ``` ## 人格倉庫(預設 `~/.claude/personas//`,可用 `PERSONA_HOME` 覆寫) @@ -68,7 +78,8 @@ flowchart TB │ ├── emotion.json # 十二情緒 levels / baseline / 半衰期 │ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口) │ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言) -│ └── config.json +│ ├── sync.json # Gitea 同步狀態(最後 push / pull) +│ └── config.json # 含人格編號 code ├── memory/ │ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天) │ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter) @@ -81,6 +92,8 @@ flowchart TB │ ├── graph.json # 人際關係圖(親近度/信任度/連線) │ └── graph.mmd # Mermaid 呈現(自動產生) └── journal/YYYY-MM.jsonl # 原始逐字 + 情緒史(hook 自動寫) + +(另有 `.sync/files/`、`.sync/wiki/`:兩個同步區的 git clone 快取,可安全刪除) ``` ## 十二情緒 @@ -129,6 +142,40 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。 +## 人格編號與 Gitea 儲存 + +**編號 = 英文名全大寫 + 兩位索引**,同名才遞增,也就是 Gitea 存取庫的名稱: + +``` +亞絲娜(第一個) → ASUNA-01 結衣 → YUI-01 另一個亞絲娜 → ASUNA-02 +``` + +每個人格一個**私有存取庫**,內容依**更新頻率**分兩區: + +| 區 | 放什麼 | 何時 push | +| --- | --- | --- | +| **檔案區**(主存取庫) | 高頻活狀態:`emotion.json`、`short-term.jsonl`、`inner.jsonl`、`said.jsonl`、`inbox/`、`mindmap/threads/`、`journal/` | 每輪對話後由 `Stop` hook 背景推送(`PERSONA_SYNC_MIN_SECONDS` 節流) | +| **Wiki 區** | 低頻設定:`IDENTITY`/`SOUL`/`AGENTS`/`USER`、長期記憶、`INDEX`、心智圖、關係圖 | 記憶固化、改身分/關係圖、`release` 時 | + +- **本機永遠是工作副本**:hook 每輪讀寫本機檔案,不經網路;Gitea 掛掉照樣能聊天。 + **同步失敗永遠不阻斷對話。** +- 載入人格時會先 `pull`;兩邊都改過同一個檔案就**停下來不覆蓋本機**,由使用者決定保留哪一邊。 +- Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面(1.27 實測子目錄頁面 404),所以 + `memory/long-term/xxx.md` 攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json`; + Wiki 首頁自動列出所有長期記憶的連結,變成真的讀得下去的「設定百科」。 + +```bash +export GITEA_HOST=https://gitea.example.com +export GITEA_TOKEN=<個人存取權杖> +export PERSONA_GITEA_OWNER=<帳號或組織> # 選填,預設 token 本人 +export PERSONA_GITEA=off # 需要時整個關掉 + +node scripts/persona.mjs code assign --session --romaji Asuna --rename # 既有人格遷移 +node scripts/persona.mjs sync status|init|push|pull --session [--area files|wiki|all] +``` + +存取庫**預設私有**——人格裡是使用者的個人記憶,公開必須由使用者明講(`--public`)。 + ## 匯出 / 匯入(人格搬家) ```bash @@ -208,6 +255,12 @@ node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json - **Claude Code / Antigravity**:`/jsc-persona:persona-transfer` **Codex**:`$persona-transfer` +### `persona-sync` + +人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。 + +- **Claude Code / Antigravity**:`/jsc-persona:persona-sync` **Codex**:`$persona-sync` + ### `persona-memory` 記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。 @@ -243,12 +296,13 @@ node scripts/persona.mjs candidates --session # 看哪些短 node scripts/persona.mjs think --session --text "<推導>" # 心裡話(只回報「心想 N 句」) node scripts/persona.mjs said check --session --text "<話>" # 這句是不是又要說一次? node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) -node scripts/persona.mjs export --session --out lumi.json # 人格搬家 -node scripts/selftest.mjs # 102 項驗證:鎖、隔離、情緒、固化條件、說話節制、劇場模式、匯出匯入、hooks +node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) +node scripts/persona.mjs sync status --session # Gitea 同步狀態 +node scripts/selftest.mjs # 116 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 -`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。 +`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。 --- diff --git a/hooks/turn_end.mjs b/hooks/turn_end.mjs index 355f7ad..26bc45e 100644 --- a/hooks/turn_end.mjs +++ b/hooks/turn_end.mjs @@ -2,8 +2,13 @@ // Stop:情緒隨時間衰減、續租、記錄逐字,並在達到「短期→長期」條件時提醒固化。 // 劇場模式進行中不發任何提醒(那會破壞「只顯示人格對話」)。 +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { readEvent, respond } from "./_hook.mjs"; import * as pl from "../scripts/persona-lib.mjs"; +import * as gt from "../scripts/persona-gitea.mjs"; + +const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "persona.mjs"); const event = readEvent(); if (!event) process.exit(0); @@ -34,6 +39,11 @@ if (message) { } } +// 檔案區=高頻活狀態:每輪結束後背景推上 Gitea(有最小間隔、失敗不阻斷這一輪) +if (!gt.giteaProblem() && gt.personaCode(host) && gt.pushDue(host, "files")) { + gt.pushInBackground(host, "files", sessionId, CLI); +} + const out = { suppressOutput: true }; if (!theater) { const { total, candidates } = pl.promotionCandidates(host); diff --git a/plugin.json b/plugin.json index 0c4b795..b78d576 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.2", + "version": "0.0.3", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs new file mode 100644 index 0000000..5b3d56f --- /dev/null +++ b/scripts/persona-gitea.mjs @@ -0,0 +1,585 @@ +// persona-gitea.mjs — 人格的 Gitea 儲存層(人格編號 = 存取庫名稱) +// +// 設計: +// * 本機 `~/.claude/personas//` 仍是**工作副本**,hook 每輪照常讀寫本機檔案(零延遲)。 +// * Gitea 上每個人格一個私有存取庫,名稱就是人格編號(例如 `ASUNA-01`)。 +// * 依**更新頻率**分兩區: +// - 檔案區(主存取庫):每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿…) +// - Wiki 區:低頻的身分與長期結構(IDENTITY/SOUL、長期記憶、心智圖、關係圖),當設定百科看 +// * 兩區各自 clone 在 `/.sync//`,push 前把工作副本的檔案複製進去再 commit。 +// (不在人格目錄本身放 .git:一個目錄要同時屬於兩個 repo 是行不通的。) +// +// 網路一律**失敗不阻斷**:Gitea 掛掉、沒設 token、離線,人格照樣能聊天。 + +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { spawn, spawnSync } from "node:child_process"; +import * as pl from "./persona-lib.mjs"; + +export const SYNC_DIRNAME = ".sync"; +export const DEFAULT_MIN_PUSH_SECONDS = 60; + +// --------------------------------------------------------------------------- // +// 人格編號:英文名全大寫 + 兩位索引(同名才遞增) +// --------------------------------------------------------------------------- // + +export const CODE_RE = /^[A-Z][A-Z0-9]{0,23}-\d{2}$/; + +export const validCode = (code) => typeof code === "string" && CODE_RE.test(code); + +/** `Asuna` / `asuna sao` / `Shen Yu` → `ASUNA` / `ASUNASAO` / `SHENYU`。非拉丁字元一律拒絕。 */ +export function normalizeRomaji(romaji) { + const raw = String(romaji ?? "").normalize("NFKD").replace(/[̀-ͯ]/g, ""); + const letters = raw.replace(/[^A-Za-z0-9]/g, "").toUpperCase(); + if (!letters || !/^[A-Z]/.test(letters)) return null; + return letters.slice(0, 24); +} + +export const codePrefix = (code) => String(code ?? "").split("-")[0] || ""; + +/** 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 */ +export function nextCode(romaji) { + const prefix = normalizeRomaji(romaji); + if (!prefix) return null; + let max = 0; + for (const slug of pl.listPersonas()) { + for (const candidate of [pl.loadConfig(slug).code, slug]) { + if (!validCode(candidate) || codePrefix(candidate) !== prefix) continue; + max = Math.max(max, Number(candidate.split("-")[1]) || 0); + } + } + if (max >= 99) return null; + return `${prefix}-${String(max + 1).padStart(2, "0")}`; +} + +/** 這個人格的編號:config.code 優先,其次目錄名本身就是編號。 */ +export function personaCode(slug) { + const code = pl.loadConfig(slug).code; + if (validCode(code)) return code; + return validCode(slug) ? slug : null; +} + +// --------------------------------------------------------------------------- // +// 兩個儲存區:依更新頻率切 +// --------------------------------------------------------------------------- // + +export const AREAS = { + // 高頻:每輪對話都在變 → 主存取庫的檔案區,Gitea 網頁上一眼看到最新狀態 + files: { + key: "files", + label: "檔案區", + why: "高頻:每輪對話都在變", + paths: [ + "state/config.json", + "state/emotion.json", + "state/inner.jsonl", + "state/said.jsonl", + "memory/short-term.jsonl", + "memory/inbox/", + "mindmap/threads/", + "journal/", + ], + }, + // 低頻:身分與長期結構 → Wiki,當「設定百科」讀 + wiki: { + key: "wiki", + label: "Wiki 區", + why: "低頻:身分與長期結構,當設定百科看", + paths: [ + "IDENTITY.md", + "SOUL.md", + "AGENTS.md", + "USER.md", + "memory/INDEX.md", + "memory/long-term/", + "mindmap/semantic.mmd", + "relations/graph.json", + "relations/graph.mmd", + ], + }, +}; + +export const AREA_KEYS = Object.keys(AREAS); + +export const syncDir = (slug, area) => path.join(pl.personaDir(slug), SYNC_DIRNAME, area); +export const syncStatePath = (slug) => path.join(pl.personaDir(slug), "state", "sync.json"); + +export function loadSyncState(slug) { + const data = pl.readJson(syncStatePath(slug), {}) ?? {}; + data.areas ??= {}; + for (const key of AREA_KEYS) data.areas[key] ??= {}; + return data; +} + +export const saveSyncState = (slug, data) => pl.writeJson(syncStatePath(slug), data); + +// --------------------------------------------------------------------------- // +// 環境與 API +// --------------------------------------------------------------------------- // + +export function giteaEnv() { + const host = String(process.env.PERSONA_GITEA_HOST || process.env.GITEA_HOST || "").replace(/\/+$/, ""); + const token = String(process.env.PERSONA_GITEA_TOKEN || process.env.GITEA_TOKEN || ""); + const owner = String(process.env.PERSONA_GITEA_OWNER || ""); + const off = /^(0|off|false|no)$/i.test(String(process.env.PERSONA_GITEA || "")); + return { host, token, owner, enabled: Boolean(host && token) && !off, disabled: off }; +} + +export function giteaProblem() { + const env = giteaEnv(); + if (env.disabled) return "PERSONA_GITEA 被設為關閉。"; + if (!env.host) return "沒有 `PERSONA_GITEA_HOST`/`GITEA_HOST`。"; + if (!env.token) return "沒有 `PERSONA_GITEA_TOKEN`/`GITEA_TOKEN`。"; + return null; +} + +async function api(method, route, body = null) { + const { host, token } = giteaEnv(); + const res = await fetch(`${host}/api/v1${route}`, { + method, + headers: { + Authorization: `token ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: body === null ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { ok: res.ok, status: res.status, json, text }; +} + +const ownerCachePath = () => path.join(pl.runtimeDir(), "gitea.json"); + +/** 存取庫的擁有者:`PERSONA_GITEA_OWNER` 優先,否則用 token 本人的帳號(會快取)。 */ +export async function resolveOwner() { + const env = giteaEnv(); + if (env.owner) return env.owner; + const cached = pl.readJson(ownerCachePath(), {}) ?? {}; + if (cached.host === env.host && cached.login) return cached.login; + const res = await api("GET", "/user"); + if (!res.ok || !res.json?.login) throw new Error(`取不到 Gitea 帳號(HTTP ${res.status}):${res.text.slice(0, 120)}`); + pl.writeJson(ownerCachePath(), { host: env.host, login: res.json.login, cached_at: pl.nowIso() }); + return res.json.login; +} + +export async function getRepo(owner, code) { + const res = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}`); + return res.ok ? res.json : null; +} + +/** 建立(或沿用)人格的私有存取庫。存取庫名稱 = 人格編號。 */ +export async function ensureRepo(owner, code, { description = "", private_ = true } = {}) { + const existing = await getRepo(owner, code); + if (existing) return { repo: existing, created: false }; + const env = giteaEnv(); + const me = await resolveOwner(); + const route = owner === me ? "/user/repos" : `/orgs/${owner}/repos`; + const res = await api("POST", route, { + name: code, + private: private_, + description: description || `jsc-persona 人格 ${code}`, + auto_init: false, + }); + if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`); + void env; + return { repo: res.json, created: true }; +} + +/** Wiki 的 git repo 要有第一頁才會存在,用 API 建 Home 頁。 */ +export async function ensureWikiHome(owner, code, content) { + const page = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}/wiki/page/Home`); + if (page.ok) return false; + const res = await api("POST", `/repos/${owner}/${encodeURIComponent(code)}/wiki/new`, { + title: "Home", + content_base64: Buffer.from(content, "utf8").toString("base64"), + message: "init: 人格設定百科", + }); + if (!res.ok) throw new Error(`建立 Wiki 首頁失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`); + return true; +} + +export const repoUrl = (host, owner, code, area) => + `${host}/${owner}/${encodeURIComponent(code)}${area === "wiki" ? ".wiki" : ""}.git`; + +// --------------------------------------------------------------------------- // +// git(用 CLI,認證走 http.extraHeader,不把 token 寫進 .git/config 也不塞進參數) +// --------------------------------------------------------------------------- // + +function gitEnv() { + const { token } = giteaEnv(); + const env = { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_AUTHOR_NAME: process.env.PERSONA_GIT_NAME || "jsc-persona", + GIT_AUTHOR_EMAIL: process.env.PERSONA_GIT_EMAIL || "persona@localhost", + GIT_COMMITTER_NAME: process.env.PERSONA_GIT_NAME || "jsc-persona", + GIT_COMMITTER_EMAIL: process.env.PERSONA_GIT_EMAIL || "persona@localhost", + }; + if (token) { + env.GIT_CONFIG_COUNT = "1"; + env.GIT_CONFIG_KEY_0 = "http.extraHeader"; + env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`; + } + return env; +} + +export function git(args, cwd = null) { + const proc = spawnSync("git", args, { cwd: cwd || undefined, env: gitEnv(), encoding: "utf8" }); + return { + ok: proc.status === 0, + status: proc.status, + stdout: String(proc.stdout || "").trim(), + stderr: String(proc.stderr || "").trim(), + }; +} + +function gitOrThrow(args, cwd, what) { + const res = git(args, cwd); + if (!res.ok) throw new Error(`${what} 失敗:git ${args.join(" ")}\n ${res.stderr || res.stdout}`); + return res; +} + +/** 確保 `/.sync//` 是該區的 clone;空存取庫也能處理。 */ +export function ensureClone(slug, area, url) { + const dir = syncDir(slug, area); + if (fs.existsSync(path.join(dir, ".git"))) { + git(["remote", "set-url", "origin", url], dir); + return dir; + } + fs.mkdirSync(path.dirname(dir), { recursive: true }); + fs.rmSync(dir, { recursive: true, force: true }); + const cloned = git(["clone", "--quiet", url, dir]); + if (!cloned.ok) { + // 空存取庫 clone 會警告但成功;真的失敗才自己 init + fs.mkdirSync(dir, { recursive: true }); + gitOrThrow(["init", "--quiet", "-b", "main"], dir, "初始化"); + gitOrThrow(["remote", "add", "origin", url], dir, "設定 remote"); + } + return dir; +} + +// --------------------------------------------------------------------------- // +// 檔案搬運:工作副本 <-> clone +// --------------------------------------------------------------------------- // + +function listAreaFiles(root, area) { + const out = []; + for (const rel of AREAS[area].paths) { + const abs = path.join(root, rel); + if (rel.endsWith("/")) { + let entries = []; + try { + entries = fs.readdirSync(abs, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isFile()) out.push(path.posix.join(rel.replace(/\/$/, ""), entry.name)); + } + continue; + } + if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel); + } + return out.sort(); +} + +function listTrackedFiles(dir) { + const res = git(["ls-files"], dir); + return res.ok ? res.stdout.split("\n").map((s) => s.trim()).filter(Boolean).sort() : []; +} + +export const WIKI_MANIFEST = "_paths.json"; +const WIKI_RESERVED = new Set(["Home.md", WIKI_MANIFEST]); +const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" }; + +/** + * Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404), + * 所以低頻區的檔案要攤平成根層檔名(`memory/long-term/x.md` → `Memory-x.md`, + * 網頁上顯示為「Memory x」),再用 `_paths.json` 記住原本的路徑,pull 時才還原得回去。 + */ +export function wikiName(rel) { + if (!rel.includes("/")) return rel; + const parts = rel.split("/"); + const file = parts.pop(); + return `${WIKI_PREFIX[parts[0]] || parts[0]}-${file}`; +} + +/** 工作副本相對路徑 → clone 內的檔名。攤平後撞名的話補上短雜湊。 */ +function buildNameMap(root, area) { + const map = new Map(); + const taken = new Map(); + for (const rel of listAreaFiles(root, area)) { + let name = area === "wiki" ? wikiName(rel) : rel; + if (taken.has(name) && taken.get(name) !== rel) { + const ext = path.extname(name); + const hash = crypto.createHash("md5").update(rel).digest("hex").slice(0, 6); + name = `${name.slice(0, name.length - ext.length)}~${hash}${ext}`; + } + taken.set(name, rel); + map.set(rel, name); + } + return map; +} + +const readManifest = (area, dir) => + area === "wiki" ? pl.readJson(path.join(dir, WIKI_MANIFEST), {}) ?? {} : {}; + +/** clone 內的檔名 → 工作副本相對路徑。 */ +export function cloneNameToRel(area, dir, name) { + if (area !== "wiki") return name; + return readManifest(area, dir)[name] || name; +} + +/** 把工作副本裡屬於這一區的檔案複製進 clone;clone 裡多出來的(已刪除的)一併移除。 */ +function stageArea(slug, area, dir) { + const root = pl.personaDir(slug); + const map = buildNameMap(root, area); + const keep = new Set(map.values()); + if (area === "wiki") for (const name of WIKI_RESERVED) keep.add(name); + for (const [rel, name] of map) { + const target = path.join(dir, name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(root, rel), target); + } + if (area === "wiki") { + const manifest = {}; + for (const [rel, name] of map) manifest[name] = rel; + pl.writeText(path.join(dir, WIKI_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`); + } + for (const name of listTrackedFiles(dir)) { + if (keep.has(name)) continue; + fs.rmSync(path.join(dir, name), { force: true }); + } + return [...map.keys()]; +} + +/** 把 clone 裡的檔案寫回工作副本(wiki 區依 `_paths.json` 還原成原本的路徑)。 */ +function unstageArea(slug, area, dir, only = null) { + const root = pl.personaDir(slug); + const manifest = readManifest(area, dir); + const written = []; + for (const name of listTrackedFiles(dir)) { + if (area === "wiki" && WIKI_RESERVED.has(name)) continue; + if (only && !only.has(name)) continue; + const src = path.join(dir, name); + if (!fs.existsSync(src)) continue; + const rel = manifest[name] || name; + const target = path.join(root, rel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(src, target); + written.push(rel); + } + return written; +} + +/** Wiki 首頁:讓 Gitea 上點進去就看得懂這是誰。 */ +export function wikiHome(slug, code) { + const ident = pl.identityFields(slug); + const longTerm = pl.longTermEntries(slug); + const relations = pl.loadRelations(slug); + const lines = [ + `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``, + "", + "> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;", + "> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。", + "", + "| 欄位 | 內容 |", + "| --- | --- |", + `| 編號 | \`${code}\` |`, + ...["Name", "Creature", "Vibe", "Emoji", "Avatar"] + .filter((k) => ident[k]) + .map((k) => `| ${k} | ${ident[k]} |`), + `| 長期記憶 | ${longTerm.length} 則 |`, + `| 關係人 | ${relations.nodes.length} 位 |`, + `| 最後同步 | ${pl.nowIso()} |`, + "", + "## 頁面", + "", + "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar)", + "- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity", + "- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解", + "- [Memory INDEX](Memory-INDEX) — 長期記憶索引", + "", + "### 長期記憶", + "", + ]; + const memos = [...longTerm].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0)); + for (const meta of memos.slice(0, 50)) { + const page = wikiName(`memory/long-term/${path.basename(meta._path)}`).replace(/\.md$/, ""); + const first = (meta._body || "").split("\n")[0] || ""; + lines.push(`- [${meta._name}](${page})|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}|${first.slice(0, 60)}`); + } + if (!memos.length) lines.push("(還沒有長期記憶)"); + if (memos.length > 50) lines.push(`…以及另外 ${memos.length - 50} 則,見 [Memory INDEX](Memory-INDEX)。`); + lines.push( + "", + "### 其他", + "", + "- `Mindmap-semantic.mmd` — 心智圖(Mermaid)", + "- `Relations-graph.mmd` — 人際關係圖(Mermaid)/ `Relations-graph.json` — 原始資料", + "- `_paths.json` — 攤平前的原始路徑對照(同步用,勿手改)", + "", + "> Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以子目錄的檔案在這裡是攤平的檔名。", + "", + ); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- // +// push / pull +// --------------------------------------------------------------------------- // + +export function minPushSeconds() { + const raw = Number(process.env.PERSONA_SYNC_MIN_SECONDS); + return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_MIN_PUSH_SECONDS; +} + +export function pushDue(slug, area) { + const last = loadSyncState(slug).areas[area]?.pushed_at; + return !last || pl.ageSeconds(last) >= minPushSeconds(); +} + +export async function pushArea(slug, area, { message = "", code = null, owner = null } = {}) { + const problem = giteaProblem(); + if (problem) return { ok: false, skipped: true, reason: problem }; + const theCode = code || personaCode(slug); + if (!theCode) return { ok: false, skipped: true, reason: `人格 \`${slug}\` 還沒有編號,先跑 \`code assign\`。` }; + const theOwner = owner || (await resolveOwner()); + const { host } = giteaEnv(); + const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); + if (area === "wiki") pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); + const staged = stageArea(slug, area, dir); + gitOrThrow(["add", "-A"], dir, "git add"); + const dirty = git(["diff", "--cached", "--quiet"], dir); + if (dirty.ok) { + const state = loadSyncState(slug); + state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() }; + saveSyncState(slug, state); + return { ok: true, changed: false, files: staged.length, area, code: theCode }; + } + gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit"); + let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); + if (!pushed.ok) { + // 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源, + // 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。 + const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; + if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) { + git(["reset", "--hard", "--quiet", `origin/${branch}`], dir); + stageArea(slug, area, dir); + if (area === "wiki") pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); + git(["add", "-A"], dir); + if (!git(["diff", "--cached", "--quiet"], dir).ok) { + git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir); + } + } + pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); + } + if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout }; + const state = loadSyncState(slug); + state.code = theCode; + state.owner = theOwner; + state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length }; + saveSyncState(slug, state); + return { ok: true, changed: true, files: staged.length, area, code: theCode }; +} + +/** + * 拉回遠端內容。 + * 衝突判定:clone 裡「還沒 commit 的本機改動」若正好也被遠端改到 → 停下來,不覆蓋本機。 + */ +export async function pullArea(slug, area, { code = null, owner = null, force = false } = {}) { + const problem = giteaProblem(); + if (problem) return { ok: false, skipped: true, reason: problem }; + const theCode = code || personaCode(slug); + if (!theCode) return { ok: false, skipped: true, reason: `人格 \`${slug}\` 還沒有編號。` }; + const theOwner = owner || (await resolveOwner()); + const { host } = giteaEnv(); + const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); + stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼 + const localChanged = new Set( + git(["status", "--porcelain"], dir) + .stdout.split("\n") + .map((l) => l.slice(3).trim()) + .filter(Boolean), + ); + const fetched = git(["fetch", "--quiet", "origin"], dir); + if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" }; + const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; + const remoteRef = `origin/${head}`; + const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir); + if (!exists.ok) return { ok: true, area, code: theCode, empty: true, changed: [] }; + const incoming = git(["diff", "--name-only", "HEAD", remoteRef], dir).stdout.split("\n").filter(Boolean); + const conflicts = incoming.filter((f) => localChanged.has(f)); + if (conflicts.length && !force) { + git(["checkout", "--", "."], dir); + return { ok: false, area, code: theCode, conflicts }; + } + git(["checkout", "--", "."], dir); + const reset = git(["reset", "--hard", "--quiet", remoteRef], dir); + if (!reset.ok) return { ok: false, area, reason: reset.stderr }; + // 只寫回「遠端真的改過的」與「本機缺少的」。 + // 不能無差別覆蓋:本機有較新但還沒 push 的內容時,那會把它蓋掉。 + const root = pl.personaDir(slug); + const restore = new Set(incoming); + for (const name of listTrackedFiles(dir)) { + if (area === "wiki" && WIKI_RESERVED.has(name)) continue; + if (!fs.existsSync(path.join(root, cloneNameToRel(area, dir, name)))) restore.add(name); + } + const written = unstageArea(slug, area, dir, restore); + const state = loadSyncState(slug); + state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() }; + saveSyncState(slug, state); + return { ok: true, area, code: theCode, changed: incoming, written }; +} + +/** 建立 Gitea 上的存取庫與 Wiki,並把兩區都推上去。 */ +export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) { + const problem = giteaProblem(); + if (problem) throw new Error(problem); + const theCode = code || personaCode(slug); + if (!validCode(theCode)) throw new Error(`人格 \`${slug}\` 沒有合法編號(需 ASUNA-01 這種格式)。`); + const theOwner = owner || (await resolveOwner()); + const ident = pl.identityFields(slug); + const { repo, created } = await ensureRepo(theOwner, theCode, { + description: `jsc-persona 人格 ${theCode}${ident.Name ? `(${ident.Name})` : ""}`, + private_, + }); + const wikiCreated = await ensureWikiHome(theOwner, theCode, wikiHome(slug, theCode)); + const results = {}; + for (const area of AREA_KEYS) { + results[area] = await pushArea(slug, area, { + code: theCode, + owner: theOwner, + message: `init(${area}): ${AREAS[area].why}`, + }); + } + const state = loadSyncState(slug); + state.code = theCode; + state.owner = theOwner; + state.repo_url = repo.html_url; + state.initialized_at = state.initialized_at || pl.nowIso(); + saveSyncState(slug, state); + return { code: theCode, owner: theOwner, repo, created, wikiCreated, results }; +} + +/** 背景 push(給 hook 用):不等結果、不阻斷這一輪。 */ +export function pushInBackground(slug, area, sessionId, cliPath) { + try { + const child = spawn( + process.execPath, + [cliPath, "sync", "push", "--persona", slug, "--session", sessionId, "--area", area, "--if-due", "--quiet"], + { detached: true, stdio: "ignore", env: process.env }, + ); + child.unref(); + return true; + } catch { + return false; + } +} diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index d6267f9..1875bee 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -39,7 +39,9 @@ export const sessionsDir = () => path.join(runtimeDir(), "sessions"); export const roomsDir = () => path.join(personaHome(), ROOMS_DIRNAME); export const personaDir = (slug) => path.join(personaHome(), slug); -const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/; +// 人格目錄名。新建的人格一律是**人格編號**(`ASUNA-01`:英文名全大寫+兩位索引), +// 但舊的小寫 slug(`asuna-sao`)仍然合法,才不會把既有人格鎖在門外。 +const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,47}$/; const RESERVED_SLUGS = new Set([RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."]); export function validSlug(slug) { @@ -1433,7 +1435,8 @@ export function cliInvocation(command) { }; const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/); if (sub) info.subcommand = sub[1]; - info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([a-z0-9-]+)/g)].map((m) => m[1]); + // 人格名可能是大寫的編號(ASUNA-01),漏掉大寫等於漏掉整個跨人格檢查 + info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([A-Za-z0-9-]+)/g)].map((m) => m[1]); const sess = command.match(/--session[= ]+['"]?([^\s'"]+)/); if (sess) info.session = sess[1]; return info; diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 6f66b2e..9aa1d40 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -14,9 +14,11 @@ import process from "node:process"; import zlib from "node:zlib"; import { fileURLToPath } from "node:url"; import * as pl from "./persona-lib.mjs"; +import * as gt from "./persona-gitea.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates"); +const SELF = path.join(HERE, "persona.mjs"); let QUIET = false; @@ -48,6 +50,7 @@ function emit(payload, asJson, lines) { const FLAGS = new Set([ "json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all", "with-journal", "gzip", "record", "load", "allow-repeat", + "if-due", "no-gitea", "public", "rename", ]); function parseArgs(argv) { @@ -154,6 +157,15 @@ function requireMember(slug, sessionId, asGuest = false) { const hostOf = (flags, session) => str(flags.persona) || pl.loadSession(session).host; +/** + * 里程碑事件(記憶固化、身分/關係變更)之後,把 Wiki 區推上去。 + * 背景執行、失敗不阻斷:同步永遠不該卡住對話。 + */ +function pushWikiLater(slug, session, flags) { + if (flags["no-gitea"] || gt.giteaProblem() || !gt.personaCode(slug)) return; + gt.pushInBackground(slug, "wiki", session, SELF); +} + function renderTemplate(name, mapping) { let text = fs.readFileSync(path.join(TEMPLATE_DIR, name), "utf8"); for (const [key, value] of Object.entries(mapping)) text = text.replaceAll(`{{${key}}}`, String(value)); @@ -166,10 +178,25 @@ function renderTemplate(name, mapping) { const commands = {}; -commands.create = ({ flags }) => { +commands.create = async ({ flags }) => { const session = requireSession(flags); - const slug = str(flags.persona); - if (!pl.validSlug(slug)) die("slug 只能是小寫英數與連字號(最長 48 字),例如 `lumi`、`shen-yu`。"); + // 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。 + let code = str(flags.code); + if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\`。`); + if (!code) { + const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona)); + if (!base) { + die( + "需要 `--romaji <英文名>`:人格編號是英文名全大寫加索引(例:Asuna → `ASUNA-01`)。" + + "中文名請先轉成羅馬拼音並跟使用者確認拼法,再帶進來。", + ); + } + code = gt.nextCode(base); + if (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`); + } + // 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格) + const slug = str(flags.persona) || code; + if (!pl.validSlug(slug)) die("人格目錄名只能是英數與連字號(最長 48 字),建議直接用編號,例如 `ASUNA-01`。"); if (pl.personaExists(slug) && !flags.force) { die(`人格 \`${slug}\` 已存在(${pl.personaDir(slug)})。要覆寫請加 --force。`); } @@ -191,12 +218,14 @@ commands.create = ({ flags }) => { pl.writeJson(pl.emotionPath(slug), pl.defaultEmotionState(parseDeltas(flags.baseline))); pl.writeJson(pl.configPath(slug), { persona: slug, + code, + romaji: gt.codePrefix(code), display_name: mapping.NAME, created_at: pl.nowIso(), created_by_session: session, origin: str(flags.origin) || "custom", source_work: str(flags.work), - schema: 1, + schema: 2, }); pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] }); pl.writeText( @@ -206,8 +235,28 @@ commands.create = ({ flags }) => { pl.rebuildIndex(slug); pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null }); pl.bindHost(session, slug, { cwd: str(flags.cwd) || null }); - ok(`人格 \`${slug}\` 建立於 ${root},已取得載入鎖並綁定本 session。`); + ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`); say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`); + // Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。 + if (!flags["no-gitea"] && !gt.giteaProblem()) { + try { + const info = await gt.initRemote(slug, { code, private_: !flags.public }); + say(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"},${info.repo.private ? "私有" : "公開"})`); + for (const key of gt.AREA_KEYS) { + const res = info.results[key] || {}; + say( + res.ok + ? ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):${res.changed ? `已推送 ${res.files} 個檔案` : "目前沒有內容"}` + : ` ⚠ ${gt.AREAS[key].label}推送失敗:${String(res.reason).slice(0, 160)}`, + ); + } + } catch (err) { + say(` ⚠ Gitea 存取庫建立失敗(不影響本機使用):${err.message}`); + say(" 之後可用 `sync init` 補建。"); + } + } else if (!flags["no-gitea"]) { + say(` ℹ 未同步到 Gitea:${gt.giteaProblem()}`); + } }; commands.list = ({ flags }) => { @@ -221,6 +270,7 @@ commands.list = ({ flags }) => { } return { persona: slug, + code: gt.personaCode(slug), identity: pl.identityBrief(slug), locked: status.locked, stale: status.stale, @@ -236,16 +286,17 @@ commands.list = ({ flags }) => { for (const r of rows) { const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒"; lines.push( - `- \`${r.persona}\` ${state}` + + `- \`${r.persona}\`${r.code && r.code !== r.persona ? `(編號 ${r.code})` : ""} ${state}` + (r.locked ? `(session ${r.owner_session}…, cwd ${r.owner_cwd})` : "") + `|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` + + (r.code ? "" : "|⚠ 尚無編號") + (r.identity ? `|${r.identity}` : ""), ); } emit({ home: pl.personaHome(), personas: rows }, flags.json, lines); }; -commands.load = ({ flags }) => { +commands.load = async ({ flags }) => { const session = requireSession(flags); const slug = str(flags.persona); if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`); @@ -264,9 +315,32 @@ commands.load = ({ flags }) => { die(`${err.message}\n 若確定那個程序已結束,可加 --takeover 接手。`); } pl.bindHost(session, slug, { cwd: str(flags.cwd) || null }); + // 載入時先把遠端拉回來(別台機器可能動過),衝突就停下來讓使用者決定 + const pulled = []; + if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { + for (const area of gt.AREA_KEYS) { + try { + pulled.push(await gt.pullArea(slug, area)); + } catch (err) { + pulled.push({ ok: false, area, reason: err.message }); + } + } + } pl.pruneShortTerm(slug); pl.rebuildIndex(slug); const lines = [`✔ 已載入人格 \`${slug}\`(exclusive,session ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s)`]; + for (const res of pulled) { + if (res.conflicts?.length) { + lines.push( + `⚠ ${gt.AREAS[res.area].label}有衝突,**沒有覆蓋本機**:${res.conflicts.slice(0, 5).join(", ")}` + + "。請告訴使用者:本機與 Gitea 都改過同一份資料,要保留哪一邊(`sync pull --force` 會以遠端為準)。", + ); + } else if (res.ok && res.written?.length) { + lines.push(`↓ ${gt.AREAS[res.area].label}從 Gitea 拉回 ${res.written.length} 個檔案。`); + } else if (!res.ok && !res.skipped) { + lines.push(`⚠ ${gt.AREAS[res.area]?.label || res.area}同步失敗(不影響本機):${String(res.reason).slice(0, 120)}`); + } + } if (lock.took_over_from) { const prev = lock.took_over_from; lines.push( @@ -279,11 +353,23 @@ commands.load = ({ flags }) => { emit({ persona: slug, lock, context }, flags.json, lines); }; -commands.release = ({ flags }) => { +commands.release = async ({ flags }) => { const session = requireSession(flags); const data = pl.loadSession(session); const slug = str(flags.persona) || data.host; if (!slug) die("本 session 沒有載入任何人格。"); + // 收工前把兩區都推上去(失敗不阻斷釋放,人格不能被鎖在網路問題裡) + if (!flags["no-gitea"] && !gt.giteaProblem() && pl.personaExists(slug) && gt.personaCode(slug)) { + for (const area of gt.AREA_KEYS) { + try { + const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowIso()}` }); + if (res.ok && res.changed) say(` ↑ ${gt.AREAS[area].label}已推上 Gitea。`); + else if (!res.ok && !res.skipped) say(` ⚠ ${gt.AREAS[area].label}推送失敗:${String(res.reason).slice(0, 120)}`); + } catch (err) { + say(` ⚠ ${gt.AREAS[area].label}推送失敗:${err.message.slice(0, 120)}`); + } + } + } const released = pl.unbindSession(session); ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest:${released.guests.join(", ")}` : "。"}`); }; @@ -557,6 +643,7 @@ commands.consolidate = ({ flags }) => { say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`); } ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`); + pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了 }; commands.prune = ({ flags }) => { @@ -572,6 +659,7 @@ commands.reindex = ({ flags }) => { const slug = hostOf(flags, session); requireOwner(slug, session); ok(`INDEX.md 重建完成(${pl.rebuildIndex(slug)} 則長期記憶)。`); + pushWikiLater(slug, session, flags); }; commands.emotion = ({ flags }) => { @@ -672,6 +760,7 @@ commands.relation = ({ flags, positional }) => { }); pl.renderRelations(slug); ok(`關係節點 \`${name}\` 已更新。`); + pushWikiLater(slug, session, flags); return; } if (action === "edge") { @@ -685,6 +774,7 @@ commands.relation = ({ flags, positional }) => { }); pl.renderRelations(slug); ok(`關係連線 ${str(flags.from) || "self"} → ${to} 已更新。`); + pushWikiLater(slug, session, flags); return; } if (action === "render") { @@ -951,6 +1041,164 @@ commands.import = ({ flags }) => { emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines); }; +/** + * 人格編號:英文名全大寫 + 兩位索引(同名才遞增),也就是 Gitea 存取庫的名稱。 + * 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。 + */ +commands.code = async ({ flags, positional }) => { + const session = requireSession(flags); + const action = positional[0] || "show"; + if (action === "next") { + const base = gt.normalizeRomaji(str(flags.romaji)); + if (!base) die("需要 `--romaji <英文名>`(只能是拉丁字母與數字)。"); + const next = gt.nextCode(base); + if (!next) die(`\`${base}\` 的編號已經用到 99。`); + emit({ romaji: base, code: next }, flags.json, [`\`${base}\` 的下一個可用編號:\`${next}\``]); + return; + } + const slug = hostOf(flags, session); + if (action === "show") { + requireMember(slug, session, Boolean(flags["as-guest"])); + const code = gt.personaCode(slug); + const state = gt.loadSyncState(slug); + emit({ persona: slug, code, sync: state }, flags.json, [ + `人格 \`${slug}\` 編號:${code ? `\`${code}\`` : "(尚未指派,用 `code assign --romaji <英文名>`)"}`, + code ? ` Gitea 存取庫:${state.repo_url || `${gt.giteaEnv().host}/${state.owner || "?"}/${code}`}` : "", + ].filter(Boolean)); + return; + } + if (action === "assign") { + requireOwner(slug, session); + const existing = gt.personaCode(slug); + if (existing && !flags.force) die(`人格 \`${slug}\` 已有編號 \`${existing}\`。要重新指派請加 --force。`); + let code = str(flags.code); + if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`); + if (!code) { + const base = gt.normalizeRomaji(str(flags.romaji) || slug); + if (!base) die("需要 `--romaji <英文名>`(中文名請先轉羅馬拼音並跟使用者確認拼法)。"); + code = gt.nextCode(base); + if (!code) die(`\`${base}\` 的編號已經用到 99。`); + } + const config = pl.loadConfig(slug); + config.persona = slug; + config.code = code; + config.romaji = gt.codePrefix(code); + config.schema = 2; + pl.writeJson(pl.configPath(slug), config); + const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\`。`]; + let current = slug; + if (flags.rename && slug !== code) { + if (pl.personaExists(code)) die(`目錄 \`${code}\` 已存在,無法改名。`); + // 目錄名改成編號:先放掉自己的鎖 → 改名 → 重新取得鎖並重綁 session + pl.releaseLock(slug, session); + fs.renameSync(pl.personaDir(slug), pl.personaDir(code)); + fs.rmSync(path.join(pl.personaDir(code), gt.SYNC_DIRNAME), { recursive: true, force: true }); + const renamed = pl.loadConfig(code); + renamed.persona = code; + pl.writeJson(pl.configPath(code), renamed); + pl.acquireLock(code, session, { cwd: str(flags.cwd) || null }); + pl.bindHost(session, code, { cwd: str(flags.cwd) || null }); + current = code; + lines.push(` 目錄已改名:${pl.personaDir(code)}(.sync 快取已清掉,下次 push 會重新 clone)`); + } + if (!flags["no-gitea"] && !gt.giteaProblem()) { + try { + const info = await gt.initRemote(current, { code, private_: !flags.public }); + lines.push(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"})`); + } catch (err) { + lines.push(` ⚠ Gitea 存取庫建立失敗(不影響本機):${err.message}`); + } + } + emit({ persona: current, code }, flags.json, lines); + return; + } + die(`未知 action:${action}(可用 show/assign/next)`); +}; + +/** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */ +commands.sync = async ({ flags, positional }) => { + const session = requireSession(flags); + const slug = hostOf(flags, session); + requireOwner(slug, session); + const action = positional[0] || "status"; + const area = str(flags.area) || "all"; + if (area !== "all" && !gt.AREA_KEYS.includes(area)) die(`--area 只能是 ${gt.AREA_KEYS.join("/")}/all。`); + const areas = area === "all" ? gt.AREA_KEYS : [area]; + + if (action === "status") { + const state = gt.loadSyncState(slug); + const problem = gt.giteaProblem(); + emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [ + `人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}|Gitea ${problem ? `⚠ ${problem}` : "✔ 已設定"}`, + ` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`)"}`, + ...gt.AREA_KEYS.map((key) => { + const info = state.areas[key] || {}; + return ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):` + + `最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}|${info.files ?? "?"} 個檔案`; + }), + ]); + return; + } + const problem = gt.giteaProblem(); + if (problem) die(`Gitea 尚未設定:${problem}(設 GITEA_HOST 與 GITEA_TOKEN,或用 PERSONA_GITEA_* 覆寫)`); + + if (action === "init") { + let code = gt.personaCode(slug); + if (!code) die(`人格 \`${slug}\` 還沒有編號。先跑 \`code assign --romaji <英文名>\`。`); + const info = await gt.initRemote(slug, { code, owner: str(flags.owner) || null, private_: !flags.public }); + emit(info, flags.json, [ + `✔ 人格 \`${slug}\`(\`${info.code}\`)已對應到 Gitea:${info.repo.html_url}`, + ` ${info.created ? "存取庫已建立" : "沿用既有存取庫"}|${info.repo.private ? "私有" : "公開"}|Wiki ${info.wikiCreated ? "已建立" : "已存在"}`, + ...gt.AREA_KEYS.map((key) => + ` ${gt.AREAS[key].label}:${info.results[key]?.ok ? `${info.results[key].files} 個檔案已推送` : `⚠ ${info.results[key]?.reason || "失敗"}`}`), + ]); + return; + } + if (action === "push") { + const out = []; + for (const key of areas) { + if (flags["if-due"] && !gt.pushDue(slug, key)) { + out.push({ area: key, ok: true, skipped: true, reason: "未到最小間隔" }); + continue; + } + try { + out.push(await gt.pushArea(slug, key, { message: str(flags.message) })); + } catch (err) { + out.push({ area: key, ok: false, reason: err.message }); + } + } + emit({ persona: slug, results: out }, flags.json, out.map((r) => + r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})` + : r.ok ? `✔ ${gt.AREAS[r.area].label}:${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}` + : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`)); + return; + } + if (action === "pull") { + const out = []; + for (const key of areas) { + try { + out.push(await gt.pullArea(slug, key, { force: Boolean(flags.force) })); + } catch (err) { + out.push({ area: key, ok: false, reason: err.message }); + } + } + pl.rebuildIndex(slug); + emit({ persona: slug, results: out }, flags.json, out.map((r) => + r.conflicts?.length + ? `✖ ${gt.AREAS[r.area].label}:本機與遠端都改過 ${r.conflicts.slice(0, 5).join(", ")};` + + "沒有覆蓋本機。確定要以遠端為準才加 --force。" + : r.ok + ? `✔ ${gt.AREAS[r.area].label}:` + + (r.empty + ? "遠端還是空的" + : `遠端有 ${r.changed?.length || 0} 個檔案更新,寫回本機 ${r.written?.length || 0} 個` + + `${(r.written?.length || 0) > (r.changed?.length || 0) ? "(含補回本機缺少的檔案)" : ""}`) + : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`)); + return; + } + die(`未知 action:${action}(可用 init/push/pull/status)`); +}; + commands.gc = ({ flags }) => { const removed = pl.gcRuntime(); emit(removed, flags.json, [ @@ -981,7 +1229,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 用法:node persona.mjs [options] 人格與鎖: - create --persona --session [--name --creature --vibe --emoji --avatar --baseline --origin --work] + create --romaji <英文名> --session [--persona <目錄名> --code --name --creature + --vibe --emoji --avatar --baseline --origin --work --no-gitea --public] + 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱; + 不指定 --persona 就用編號當目錄名。 load --persona --session [--takeover] release --session [--persona ] list 列出人格與鎖狀態 @@ -1010,7 +1261,15 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C room post|read|script|list|theater --session [--room --as --text --text-file --emotion --limit --on --off --with-meta] (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) -搬家: +編號與 Gitea(存取庫名稱 = 人格編號): + code show|assign|next --session [--romaji <英文名> --code --rename --force --public] + sync status|init|push|pull --session [--area files|wiki|all --if-due --force --message --owner] + 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push + Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push + 環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER), + PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔 + +搬家(離線檔案): export --session [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格 import --session --file <檔案> [--persona <新 slug> --force --load] @@ -1021,7 +1280,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用) `; -function main(argv) { +async function main(argv) { const sub = argv[0]; if (!sub || sub === "--help" || sub === "-h" || sub === "help") { process.stdout.write(HELP); @@ -1032,7 +1291,7 @@ function main(argv) { const parsed = parseArgs(argv.slice(1)); QUIET = Boolean(parsed.flags.quiet); try { - command({ flags: parsed.flags, positional: parsed._ }); + await command({ flags: parsed.flags, positional: parsed._ }); } catch (err) { if (err instanceof pl.LockError) die(err.message); throw err; @@ -1040,4 +1299,4 @@ function main(argv) { return 0; } -process.exit(main(process.argv.slice(2))); +process.exit(await main(process.argv.slice(2))); diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 45311d7..21222cd 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -14,8 +14,11 @@ import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const STORE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-selftest-")); process.env.PERSONA_HOME = STORE; +// 自我測試絕對不碰真的 Gitea:不建存取庫、不 push。同步邏輯只測不需要網路的部分。 +process.env.PERSONA_GITEA = "off"; const pl = await import("./persona-lib.mjs"); +const gt = await import("./persona-gitea.mjs"); const CLI = path.join(HERE, "persona.mjs"); const HOOKS = path.join(HERE, "..", "hooks"); @@ -406,6 +409,74 @@ check("guard:匯入新人格允許(只寫新目錄,不讀別人)", tool_input: { command: `node persona.mjs import --persona gamma --session ${S_SPEAK} --file /tmp/b.json` } }) === "pass"); fs.rmSync(OUT, { recursive: true, force: true }); +console.log("⑬ 人格編號與 Gitea 分區"); +check("羅馬拼音正規化:只吃拉丁字母", + gt.normalizeRomaji("Asuna") === "ASUNA" && gt.normalizeRomaji("shen yu") === "SHENYU" && + gt.normalizeRomaji("亞絲娜") === null && gt.normalizeRomaji("") === null); +check("編號格式:英文名全大寫 + 兩位索引", + gt.validCode("ASUNA-01") && gt.validCode("SHENYU-12") && + !gt.validCode("asuna-01") && !gt.validCode("ASUNA-1") && !gt.validCode("ASUNA")); +check("建立人格時自動產生編號(alpha → ALPHA-01)", gt.personaCode("alpha") === "ALPHA-01", + String(gt.personaCode("alpha"))); +check("同名才遞增,不同名各自從 01 開始", + gt.nextCode("Alpha") === "ALPHA-02" && gt.nextCode("Beta") === "BETA-02" && gt.nextCode("Lumi") === "LUMI-01", + `${gt.nextCode("Alpha")} / ${gt.nextCode("Beta")} / ${gt.nextCode("Lumi")}`); +const S_CODE = "sess-code-4444"; +cli(["create", "--persona", "gamma", "--romaji", "Gamma", "--session", S_CODE, "--name", "Gamma", "--emoji", "🜂"]); +check("編號寫進 config.json", pl.loadConfig("gamma").code === "GAMMA-01" && pl.loadConfig("gamma").romaji === "GAMMA"); +const codeShow = cli(["code", "show", "--session", S_CODE, "--json"]); +check("code show 回報編號", (() => { + try { + return JSON.parse(codeShow.stdout).code === "GAMMA-01"; + } catch { + return false; + } +})(), codeShow.stdout.slice(0, 120)); +cli(["code", "assign", "--session", S_CODE, "--code", "GAMMA-01", "--rename", "--force"]); +check("--rename 把目錄名改成編號", pl.personaExists("GAMMA-01") && !pl.personaExists("gamma")); +check("改名後鎖與 session 綁定都跟著轉移", + pl.lockStatus("GAMMA-01").locked && pl.loadSession(S_CODE).host === "GAMMA-01" && + pl.loadConfig("GAMMA-01").persona === "GAMMA-01"); +check("大寫編號目錄一樣受跨人格隔離保護", + guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/GAMMA-01/SOUL.md` } }) === "deny"); +check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)", + guard({ session_id: S_CODE, tool_name: "Bash", + tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny"); +// 分區必須「不重不漏」:人格產生的每個檔案都要恰好屬於一區,否則同步會默默漏資料 +const AREA_EXEMPT = new Set(["state/lock.json", "state/guests.json", "state/sync.json"]); +const covered = (rel) => + gt.AREA_KEYS.filter((key) => + gt.AREAS[key].paths.some((p) => (p.endsWith("/") ? rel.startsWith(p) : rel === p))); +const allFiles = []; +(function walk(dir, base = "") { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + const rel = base ? `${base}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(path.join(dir, entry.name), rel); + else allFiles.push(rel); + } +})(pl.personaDir("alpha")); +const uncovered = allFiles.filter((f) => !AREA_EXEMPT.has(f) && covered(f).length !== 1); +check("檔案區/Wiki 區的切分不重不漏(每個檔案恰好屬於一區)", uncovered.length === 0, + uncovered.map((f) => `${f}→${covered(f).length}`).join(", ")); +check("高頻資料在檔案區、低頻資料在 Wiki 區", + covered("state/emotion.json")[0] === "files" && covered("memory/short-term.jsonl")[0] === "files" && + covered("journal/2026-01.jsonl")[0] === "files" && covered("state/said.jsonl")[0] === "files" && + covered("IDENTITY.md")[0] === "wiki" && covered("memory/long-term/x.md")[0] === "wiki" && + covered("relations/graph.json")[0] === "wiki"); +check("沒設定 Gitea 時同步只是略過,不會爆炸", (() => { + const res = cli(["sync", "status", "--session", S_CODE]); + const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false }); + return res.status === 0 && res.stdout.includes("Gitea") && push.status !== 0 && + push.stderr.includes("尚未設定"); +})()); +check("匯出不會把 .sync 的 git clone 打包進去", (() => { + fs.mkdirSync(path.join(pl.personaDir("GAMMA-01"), ".sync", "files"), { recursive: true }); + fs.writeFileSync(path.join(pl.personaDir("GAMMA-01"), ".sync", "files", "junk.txt"), "x"); + const { bundle: b } = pl.exportBundle("GAMMA-01"); + return !Object.keys(b.files).some((f) => f.startsWith(".sync")); +})()); + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-anime/SKILL.md b/skills/persona-anime/SKILL.md index 0da79cb..b73fc87 100644 --- a/skills/persona-anime/SKILL.md +++ b/skills/persona-anime/SKILL.md @@ -67,13 +67,17 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \ - --persona --session \ + --romaji "<角色的官方英文名>" --session \ --name "" --creature "" --vibe "" \ --emoji "" --avatar "<外觀描述>" \ --origin anime --work "<作品名>" \ --baseline "<依角色個性設定的十二情緒基線>" ``` +`--romaji` 用**官方英文/羅馬拼音名**(維基或官方英文版的寫法,如 `Asuna`、`Yui`、`Rem`), +它會變成人格編號 `ASUNA-01` 與 Gitea 存取庫名稱。同一個角色要做第二份設定時, +索引會自動變成 `-02`。 + 情緒基線抓法(0–100,總體別全部拉高): | 角色類型 | 建議基線 | diff --git a/skills/persona-create/SKILL.md b/skills/persona-create/SKILL.md index 32dc213..5a2e2bb 100644 --- a/skills/persona-create/SKILL.md +++ b/skills/persona-create/SKILL.md @@ -51,12 +51,21 @@ slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有 ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \ - --persona --session \ + --romaji "<英文名>" --session \ --name "" --creature "" --vibe "" \ --emoji "" --avatar "" \ --baseline "serenity=45,trust=35,joy=25,anxiety=6" ``` +**`--romaji` 決定人格編號**(英文名全大寫+兩位索引,例如 `Asuna` → `ASUNA-01`)。 +編號同時是本機目錄名與 Gitea 存取庫的名稱,所以:中文/日文名字要**先提議羅馬拼音、 +拿給使用者確認拼法**再送出(`亞絲娜 → Asuna`、`沈宇 → Shen Yu`)。想先看號碼會長怎樣: +`code next --romaji "<英文名>" --session `。 + +設好 `GITEA_HOST`/`GITEA_TOKEN` 時,`create` 會順手在 Gitea 開一個**私有**存取庫 +(名稱=編號)並推第一版;沒設定或加 `--no-gitea` 就只在本機建立。細節見 +`/jsc-persona:persona-sync`。 + `create` 會:建立 `IDENTITY.md`/`SOUL.md`/`AGENTS.md`/`USER.md`、十二情緒狀態、 記憶與心智圖骨架、人際關係圖,並**立刻取得這個人格的載入鎖並綁到本 session**。 diff --git a/skills/persona-status/SKILL.md b/skills/persona-status/SKILL.md index 49cc527..3982b85 100644 --- a/skills/persona-status/SKILL.md +++ b/skills/persona-status/SKILL.md @@ -21,9 +21,13 @@ description: 查看與管理人格的載入狀態:列出所有人格、誰被 ## 常用指令 ```bash -# 全部人格 + 鎖狀態 + 記憶量 +# 全部人格 + 編號 + 鎖狀態 + 記憶量(`⚠ 尚無編號` 表示還沒同步到 Gitea) node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list +# 這個人格的編號與 Gitea 存取庫 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" code show --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync status --session + # 單一人格的鎖細節(owner session/cwd/心跳、guest 名單、情緒) node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" status --persona diff --git a/skills/persona-sync/SKILL.md b/skills/persona-sync/SKILL.md new file mode 100644 index 0000000..ed0f8b9 --- /dev/null +++ b/skills/persona-sync/SKILL.md @@ -0,0 +1,109 @@ +--- +name: persona-sync +description: 人格編號與 Gitea 儲存:指派人格編號(英文名全大寫+索引,例如 ASUNA-01)、在 Gitea 為每個人格開一個以編號命名的私有存取庫、把高頻的活狀態同步到檔案區、把低頻的身分與長期記憶同步到 Wiki 區,以及把既有人格遷移上去。當使用者說要把人格存到 Gitea、備份或同步人格、在另一台機器接續同一個人格、問人格的編號是什麼、想看人格在 Gitea 上的存取庫、或同步出現衝突時觸發。不適用於:建立新人格(persona-create,它會自動開存取庫)、離線的單檔匯出匯入(persona-transfer)、記憶固化(persona-memory)。 +--- + +# 🔗 persona-sync — 人格編號與 Gitea 儲存 + +**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(一律帶 `--session `) + +--- + +## 人格編號 + +**編號 = 英文名全大寫 + 兩位索引**,同名才遞增: + +``` +亞絲娜(第一個) → ASUNA-01 +結衣 → YUI-01 +另一個亞絲娜 → ASUNA-02 +``` + +- 編號**就是 Gitea 存取庫的名稱**,也是新人格的本機目錄名。 +- 中文/日文名字要先轉**羅馬拼音**:由你提議拼法(`亞絲娜 → Asuna`、`沈宇 → Shen Yu`), + **拿給使用者確認再送出**——拼錯了會變成一個很難改的編號。 +- 查下一個可用編號:`code next --romaji Asuna --session `。 + +## 資料放哪裡(依更新頻率切) + +| 區 | 放什麼 | 什麼時候 push | +| --- | --- | --- | +| **檔案區**(主存取庫) | 高頻活狀態:`state/emotion.json`、`short-term.jsonl`、`inner.jsonl`(心裡話)、`said.jsonl`、`inbox/`、`mindmap/threads/`、`journal/` | 每輪對話後由 `Stop` hook 背景推送(有最小間隔) | +| **Wiki 區** | 低頻設定:`IDENTITY`/`SOUL`/`AGENTS`/`USER`、長期記憶、`INDEX`、心智圖、人際關係圖 | 記憶固化、改身分、改關係圖、`release` 時 | + +Wiki 是給人讀的設定百科:Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以 +`memory/long-term/xxx.md` 會攤平成 `Memory-xxx.md`(頁面顯示為「Memory xxx」), +原始路徑記在 `_paths.json`,拉回來時自動還原。Home 頁會自動列出所有長期記憶的連結。 + +**本機永遠是工作副本**:hook 每輪讀寫的是本機檔案,不經過網路。Gitea 掛掉、離線、沒設 token, +人格照樣能聊天,只是不同步——**同步失敗永遠不阻斷對話**。 + +## 設定 + +```bash +export GITEA_HOST=https://gitea.example.com # 或 PERSONA_GITEA_HOST +export GITEA_TOKEN=<個人存取權杖> # 或 PERSONA_GITEA_TOKEN +export PERSONA_GITEA_OWNER=<帳號或組織> # 選填,預設是 token 本人的帳號 +export PERSONA_GITEA=off # 需要時整個關掉 +export PERSONA_SYNC_MIN_SECONDS=60 # 檔案區背景 push 的最小間隔 +``` + +存取庫**預設私有**(人格裡有使用者的個人記憶)。要公開必須是使用者明講,才加 `--public`。 + +## 常用流程 + +```bash +# 現在的同步狀態(編號、存取庫、兩區的最後 push/pull) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync status --session + +# 手動推送(--area files|wiki|all) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync push --session --area all + +# 從 Gitea 拉回(載入人格時會自動做一次) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session +``` + +### 把既有人格遷移上去 + +既有人格(還沒有編號的)要兩步: + +```bash +# 1) 載入它(同步只能動本 session 載入的那個人格) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona <舊 slug> --session + +# 2) 指派編號 + 開存取庫 + 首次推送;--rename 連本機目錄名也改成編號 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" code assign \ + --session --romaji "<英文名>" --rename +``` + +`--rename` 之後這個人格就用編號稱呼(`--persona ASUNA-01`)。不加 `--rename` 也可以, +目錄名維持原樣、只是多了一個編號與對應的存取庫。**遷移前先跟使用者確認**: +這會把他的個人記憶送上 Gitea(私有庫,但仍是上傳)。 + +### 在另一台機器接續同一個人格 + +在新機器上設好 `GITEA_HOST`/`GITEA_TOKEN`,然後: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session +``` + +(人格目錄還不存在的話,先用 `/jsc-persona:persona-transfer` 匯入一份,或手動 clone 存取庫。) + +## 衝突 + +兩台機器都改過同一個檔案時,`load` 與 `sync pull` 會**停下來不覆蓋本機**,並列出衝突的檔案。 +這時候: + +1. 用一句話告訴使用者哪幾個檔案兩邊都動過。 +2. 問他要哪一邊: + - 以 **Gitea** 為準 → `sync pull --force`(本機那份會被覆蓋) + - 以 **本機** 為準 → `sync push`(會蓋掉遠端) +3. **不要自己選**。記憶被蓋掉是不可逆的。 + +## 邊界 + +- 只能同步「本 session 目前載入的人格」——跨人格同步等於跨人格讀取,會被 hook 擋下。 +- guest(`persona-guest` sub agent)不能同步,它對人格檔案唯讀。 +- `.sync/` 是兩區的 git clone 快取,**不要手動編輯**;砍掉它不會掉資料,下次 push 會重新 clone。 +- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 From d0679072259787c1ee020ada503f60240fa7d829 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 02:03:45 +0000 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E4=BA=BA?= =?UTF-8?q?=E6=A0=BC=E4=B8=A6=E8=A3=9C=E9=BD=8A=E8=B3=87=E6=96=99=E5=BE=8C?= =?UTF-8?q?=E7=94=A2=E7=94=9F=E5=9C=96=E7=A4=BA=EF=BC=88SVG=20+=20PNG?= =?UTF-8?q?=EF=BC=8C=E9=9B=B6=E5=A4=96=E9=83=A8=E4=BE=9D=E8=B3=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `icon generate|show`:由**編號、Name、Emoji** 雜湊出配色與圖案,產出 512×512 的幾何徽章 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 圖案(SVG 與 PNG 共用同一組單位座標與同一份 5×7 點陣字,**輸出的是同一張圖**): * 雙色對角漸層底 * 5×5 左右對稱點陣紋(identicon 式) * 中央兩個字母=編號前兩字(ASUNA-01 → AS),依背景亮度自動選黑/白 同一個人格永遠得到同一張圖(純函數,無隨機);ASUNA-01 與 ASUNA-02 明顯不同。 為什麼自己畫:這台機器(及多數伺服器)沒有 rsvg/inkscape/imagemagick, 沒有影像函式庫,也沒有 emoji 字型,而本專案禁止 npm 依賴。所以 scripts/persona-icon.mjs 自己柵格化(3× 超取樣 + 盒式縮減當反鋸齒), 再用內建 zlib 手工組出 IHDR/IDAT/IEND 與 CRC32。 emoji 無法柵格化,因此不作為圖形,但仍參與配色雜湊。 時機:**資料補齊之後才產生**——配色與字母綁在最終身分上,太早跑會對不上。 persona-create 第 7 步、persona-anime 第 8 步都補了這個步驟;改過身分用 --force 重畫。 selftest 128 項全綠(新增第 ⑭ 節:PNG chunk 合法性、決定性、不同人格不同圖、 圖示屬 Wiki 區、匯出用 base64 帶走二進位、guest 不得重畫)。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 3 + README.md | 32 ++- plugin.json | 2 +- scripts/persona-gitea.mjs | 12 + scripts/persona-icon.mjs | 403 +++++++++++++++++++++++++++++++++ scripts/persona.mjs | 54 +++++ scripts/selftest.mjs | 42 ++++ skills/persona-anime/SKILL.md | 14 +- skills/persona-create/SKILL.md | 20 +- skills/persona-sync/SKILL.md | 5 +- 12 files changed, 580 insertions(+), 11 deletions(-) create mode 100644 scripts/persona-icon.mjs diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d68932e..bc25cdc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.3", + "version": "0.0.4", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c5c6c4e..d9fea7a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.3", + "version": "0.0.4", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 416c696..f4bfa56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,9 @@ 10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push), 低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。 **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 +11. **人格圖示在資料補齊之後才產生**:`icon generate` 的配色與字母由「編號/Name/Emoji」 + 雜湊而來,太早跑會跟最終身分對不上。SVG 與 PNG 是同一張圖(共用單位座標與點陣字), + PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 ## 慣例 diff --git a/README.md b/README.md index 0d652c2..75a3456 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 --- -## 十一條硬規則 +## 十二條硬規則 | 規則 | 怎麼做到 | | --- | --- | @@ -26,6 +26,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | +| **12. 建立並補齊資料後產生圖示** | `icon generate` 由**編號、Name、Emoji** 雜湊出配色與圖案,產出 `icon.svg` + `icon.png`(512×512)並設為 Gitea 存取庫頭像。同一個人格永遠得到同一張圖;PNG 是**自寫柵格器 + zlib 手工編碼**,不依賴任何影像函式庫 | --- @@ -71,6 +72,7 @@ flowchart TB ├── IDENTITY.md # 身分卡:Name / Creature / Vibe / Emoji / Avatar(OpenClaw 同欄位) ├── SOUL.md # 靈魂:Core Truths / Boundaries / Vibe / Continuity + 情緒傾向 ├── AGENTS.md # 操作規則(與個性分離) +├── icon.svg / icon.png # 人格圖示(由編號/名字/emoji 決定,也是 Gitea 存取庫頭像) ├── USER.md # 對使用者的畫像(事實/推測分開) ├── state/ │ ├── lock.json # 載入鎖(session_id + 心跳租約) @@ -142,6 +144,30 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。 +## 人格圖示(SVG + PNG,零外部依賴) + +建立人格**並補齊 IDENTITY/SOUL 之後**跑 `icon generate`,得到一張 512×512 的幾何徽章: + +| 元素 | 怎麼決定 | +| --- | --- | +| 雙色對角漸層 | `sha256(編號|Name|Emoji)` → 色相/飽和度/明度 | +| 5×5 左右對稱點陣紋 | 同一組雜湊的位元 | +| 中央兩個字母 | 編號的前兩個字(`ASUNA-01` → `AS`),5×7 點陣,自動選黑或白確保對比 | + +- **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01` 與 `ASUNA-02` 明顯不同。 +- SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。 +- PNG 由**自寫的柵格器**畫出(3× 超取樣 + 盒式縮減當反鋸齒),再用 `zlib` 手工組出 + IHDR/IDAT/IEND 與 CRC32。這台機器沒有 rsvg/inkscape/imagemagick,也沒有影像函式庫, + 而本專案禁止 npm 依賴——所以就自己畫。 +- **為什麼沒有 emoji**:把 emoji 畫進 PNG 需要字型柵格化(機器上連 emoji 字型都沒有)。 + emoji 仍然參與配色的雜湊,只是不作為圖形。 +- 圖示屬於低頻資料 → 同步到 **Wiki 區**,並自動設成 Gitea **存取庫頭像**。 + +```bash +node scripts/persona.mjs icon generate --session [--size 512] [--force] # 改過身分要重畫就 --force +node scripts/persona.mjs icon show --session +``` + ## 人格編號與 Gitea 儲存 **編號 = 英文名全大寫 + 兩位索引**,同名才遞增,也就是 Gitea 存取庫的名稱: @@ -298,11 +324,11 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 116 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、hooks +node scripts/selftest.mjs # 128 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、圖示、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 -`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。 +`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(圖示:SVG + 自寫 PNG 編碼)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。 --- diff --git a/plugin.json b/plugin.json index b78d576..eaae2e0 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.3", + "version": "0.0.4", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 5b3d56f..e2d292b 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -91,6 +91,8 @@ export const AREAS = { "SOUL.md", "AGENTS.md", "USER.md", + "icon.svg", + "icon.png", "memory/INDEX.md", "memory/long-term/", "mindmap/semantic.mmd", @@ -205,6 +207,14 @@ export async function ensureWikiHome(owner, code, content) { return true; } +/** 把人格圖示設成存取庫頭像(Gitea 各處的清單就會顯示這個人格的臉)。 */ +export async function setRepoAvatar(owner, code, pngBuffer) { + const res = await api("POST", `/repos/${owner}/${encodeURIComponent(code)}/avatar`, { + image: Buffer.from(pngBuffer).toString("base64"), + }); + return res.ok; +} + export const repoUrl = (host, owner, code, area) => `${host}/${owner}/${encodeURIComponent(code)}${area === "wiki" ? ".wiki" : ""}.git`; @@ -384,9 +394,11 @@ export function wikiHome(slug, code) { const ident = pl.identityFields(slug); const longTerm = pl.longTermEntries(slug); const relations = pl.loadRelations(slug); + const hasIcon = fs.existsSync(path.join(pl.personaDir(slug), "icon.svg")); const lines = [ `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``, "", + ...(hasIcon ? [`${code}`, ""] : []), "> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;", "> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。", "", diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs new file mode 100644 index 0000000..afed0bf --- /dev/null +++ b/scripts/persona-icon.mjs @@ -0,0 +1,403 @@ +// persona-icon.mjs — 由人格資料產生圖示(SVG + PNG),零外部依賴 +// +// 為什麼是「幾何圖形 + 字母」而不是 emoji: +// 這台機器(以及大部分伺服器)沒有 rsvg/inkscape/imagemagick,也沒有 emoji 字型, +// 而本專案的規則是只用 Node 內建模組。把 emoji 畫進 PNG 需要字型柵格化,做不到。 +// 所以圖案只用「我能在 SVG 與自寫柵格器裡畫出完全相同結果」的元素: +// 圓角矩形、線性漸層、圓點、以及 5×7 點陣字母。**兩種格式輸出的是同一張圖。** +// emoji 仍然參與雜湊,所以它會影響配色。 +// +// 圖案(512×512 圓角方形徽章): +// 底:由人格編號雜湊出的雙色對角漸層 +// 紋:5×5 左右對稱的圓點(identicon 式,每個人格都不一樣) +// 字:編號英文名的前兩個字母(ASUNA-01 → AS),5×7 點陣,自動選黑或白以確保對比 +// +// 同一個人格永遠得到同一張圖(純函數 of 編號/名字/emoji)。 + +import fs from "node:fs"; +import path from "node:path"; +import zlib from "node:zlib"; +import crypto from "node:crypto"; +import * as pl from "./persona-lib.mjs"; + +export const ICON_SVG = "icon.svg"; +export const ICON_PNG = "icon.png"; +export const DEFAULT_SIZE = 512; + +export const iconSvgPath = (slug) => path.join(pl.personaDir(slug), ICON_SVG); +export const iconPngPath = (slug) => path.join(pl.personaDir(slug), ICON_PNG); + +// --------------------------------------------------------------------------- // +// 5×7 點陣字(SVG 與 PNG 共用同一份資料,兩邊才會長得一模一樣) +// --------------------------------------------------------------------------- // + +const FONT = { + A: "01110,10001,10001,11111,10001,10001,10001", + B: "11110,10001,10001,11110,10001,10001,11110", + C: "01111,10000,10000,10000,10000,10000,01111", + D: "11110,10001,10001,10001,10001,10001,11110", + E: "11111,10000,10000,11110,10000,10000,11111", + F: "11111,10000,10000,11110,10000,10000,10000", + G: "01110,10001,10000,10111,10001,10001,01111", + H: "10001,10001,10001,11111,10001,10001,10001", + I: "11111,00100,00100,00100,00100,00100,11111", + J: "00111,00010,00010,00010,00010,10010,01100", + K: "10001,10010,10100,11000,10100,10010,10001", + L: "10000,10000,10000,10000,10000,10000,11111", + M: "10001,11011,10101,10101,10001,10001,10001", + N: "10001,11001,10101,10011,10001,10001,10001", + O: "01110,10001,10001,10001,10001,10001,01110", + P: "11110,10001,10001,11110,10000,10000,10000", + Q: "01110,10001,10001,10001,10101,10010,01101", + R: "11110,10001,10001,11110,10100,10010,10001", + S: "01111,10000,10000,01110,00001,00001,11110", + T: "11111,00100,00100,00100,00100,00100,00100", + U: "10001,10001,10001,10001,10001,10001,01110", + V: "10001,10001,10001,10001,10001,01010,00100", + W: "10001,10001,10001,10101,10101,11011,10001", + X: "10001,10001,01010,00100,01010,10001,10001", + Y: "10001,10001,01010,00100,00100,00100,00100", + Z: "11111,00001,00010,00100,01000,10000,11111", + 0: "01110,10001,10011,10101,11001,10001,01110", + 1: "00100,01100,00100,00100,00100,00100,01110", + 2: "01110,10001,00001,00010,00100,01000,11111", + 3: "11111,00010,00100,00010,00001,10001,01110", + 4: "00010,00110,01010,10010,11111,00010,00010", + 5: "11111,10000,11110,00001,00001,10001,01110", + 6: "00110,01000,10000,11110,10001,10001,01110", + 7: "11111,00001,00010,00100,01000,01000,01000", + 8: "01110,10001,10001,01110,10001,10001,01110", + 9: "01110,10001,10001,01111,00001,00010,01100", +}; + +const glyph = (ch) => (FONT[ch] || FONT.O).split(",").map((row) => row.split("").map(Number)); + +// --------------------------------------------------------------------------- // +// 顏色 +// --------------------------------------------------------------------------- // + +/** h 0–360, s 0–1, l 0–1 → [r,g,b] 0–255 */ +export function hslToRgb(h, s, l) { + const c = (1 - Math.abs(2 * l - 1)) * s; + const hp = (((h % 360) + 360) % 360) / 60; + const x = c * (1 - Math.abs((hp % 2) - 1)); + const [r1, g1, b1] = + hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x] + : hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x]; + const m = l - c / 2; + return [r1 + m, g1 + m, b1 + m].map((v) => Math.round(Math.max(0, Math.min(1, v)) * 255)); +} + +const hex = ([r, g, b]) => `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; + +/** 相對亮度(sRGB → 線性),用來決定字要黑還是白。 */ +function luminance([r, g, b]) { + const lin = [r, g, b].map((v) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; +} + +// --------------------------------------------------------------------------- // +// 圖案規格:純函數 of 人格資料 → 同一個人格永遠同一張圖 +// --------------------------------------------------------------------------- // + +export function iconSpec(slug, { code = null, identity = null } = {}) { + const ident = identity || pl.identityFields(slug); + const theCode = code || pl.loadConfig(slug).code || slug; + const seedText = `${theCode}|${ident.Name || slug}|${ident.Emoji || ""}`; + const h = crypto.createHash("sha256").update(seedText).digest(); + + const hue = (h[0] * 360) / 256; + const hue2 = (hue + 40 + (h[1] % 80)) % 360; + const sat = 0.52 + (h[2] % 30) / 100; // 0.52–0.81 + const light = 0.36 + (h[3] % 18) / 100; // 0.36–0.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 = []; + for (let y = 0; y < 5; y += 1) { + const row = []; + for (let x = 0; x < 3; x += 1) row.push((h[8 + y * 3 + x] & 1) === 1); + pattern.push([...row, row[1], row[0]]); + } + + 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]; + + return { + persona: slug, + code: theCode, + name: ident.Name || slug, + emoji: ident.Emoji || "", + letters, + c1, + c2, + ink, + pattern, + seed: h.subarray(0, 8).toString("hex"), + }; +} + +// --------------------------------------------------------------------------- // +// 幾何:SVG 與柵格器共用同一組座標(單位為 0–1,最後乘上 size) +// --------------------------------------------------------------------------- // + +const GEO = { + radius: 0.22, // 圓角半徑 + patternInset: 0.12, + patternCell: 0.152, + dotRadius: 0.038, + glyphCell: 1 / 16, +}; + +/** 回傳圖案中所有圓點(單位座標)。 */ +function dots(spec) { + const out = []; + for (let y = 0; y < 5; y += 1) { + for (let x = 0; x < 5; x += 1) { + if (!spec.pattern[y][x]) continue; + out.push({ + cx: GEO.patternInset + GEO.patternCell * (x + 0.5), + cy: GEO.patternInset + GEO.patternCell * (y + 0.5), + r: GEO.dotRadius, + }); + } + } + return out; +} + +/** 回傳字母的所有方塊(單位座標)。 */ +function glyphRects(spec) { + const cell = GEO.glyphCell; + const chars = [...spec.letters]; + const width = chars.length * 5 * cell + (chars.length - 1) * cell; + const left = 0.5 - width / 2; + const top = 0.5 - (7 * cell) / 2; + const out = []; + chars.forEach((ch, i) => { + const g = glyph(ch); + const ox = left + i * 6 * cell; + for (let y = 0; y < 7; y += 1) { + for (let x = 0; x < 5; x += 1) { + if (g[y][x]) out.push({ x: ox + x * cell, y: top + y * cell, w: cell, h: cell }); + } + } + }); + return out; +} + +// --------------------------------------------------------------------------- // +// SVG +// --------------------------------------------------------------------------- // + +export function renderSvg(spec, size = DEFAULT_SIZE) { + const S = size; + const u = (v) => Math.round(v * S * 1000) / 1000; + const lines = [ + ``, + ` ${spec.code}${spec.name ? ` ${spec.name}` : ""}`, + " ", + ` `, + ` `, + ` `, + " ", + " ", + ` `, + ` `, + ...dots(spec).map((d) => ` `), + " ", + ` `, + ...glyphRects(spec).map((r) => ` `), + " ", + ` `, + "", + "", + ]; + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- // +// PNG:自己柵格化 + 自己編碼(zlib 是內建的,不需要任何影像函式庫) +// --------------------------------------------------------------------------- // + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function pngChunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body), 0); + return Buffer.concat([len, body, crc]); +} + +/** RGBA buffer → PNG(8-bit RGBA、無交錯、filter 0)。 */ +export function encodePng(rgba, width, height) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type: RGBA + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + const stride = width * 4; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y += 1) { + raw[y * (stride + 1)] = 0; // filter type: None + rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", ihdr), + pngChunk("IDAT", zlib.deflateSync(raw, { level: 9 })), + pngChunk("IEND", Buffer.alloc(0)), + ]); +} + +/** 內縮 inset 之後的圓角矩形內外判定(用來畫出與 SVG 描邊相同的環帶)。 */ +function insideInset(x, y, inset) { + const span = 1 - 2 * inset; + return insideRounded((x - inset) / span, (y - inset) / span, (GEO.radius - inset) / span); +} + +/** 圓角矩形的內外判定(單位座標)。 */ +function insideRounded(x, y, r) { + if (x < 0 || y < 0 || x > 1 || y > 1) return false; + const cx = Math.min(Math.max(x, r), 1 - r); + const cy = Math.min(Math.max(y, r), 1 - r); + const dx = x - cx; + const dy = y - cy; + return dx * dx + dy * dy <= r * r; +} + +/** + * 畫出與 SVG 完全相同的圖案。 + * 用 3× 超取樣再做盒式縮減當作反鋸齒——沒有第三方繪圖庫,這是最省事又夠好的做法。 + */ +export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { + const SS = Math.max(1, Math.min(4, supersample)); + const big = size * SS; + const acc = Buffer.alloc(big * big * 4); + const circles = dots(spec); + const rects = glyphRects(spec); + const [i1, i2, i3] = spec.ink; + + for (let py = 0; py < big; py += 1) { + const v = (py + 0.5) / big; + for (let px = 0; px < big; px += 1) { + const u = (px + 0.5) / big; + const o = (py * big + px) * 4; + if (!insideRounded(u, v, GEO.radius)) continue; // 圓角外=透明 + // 底:對角線性漸層(與 SVG 的 x1,y1=0,0 → x2,y2=1,1 相同) + const t = Math.min(1, Math.max(0, (u + v) / 2)); + 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 b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t; + // 紋:圓點(14% 不透明度) + for (const d of circles) { + const dx = u - d.cx; + const dy = v - d.cy; + if (dx * dx + dy * dy <= d.r * d.r) { + r += (i1 - r) * 0.14; + g += (i2 - g) * 0.14; + b += (i3 - b) * 0.14; + break; + } + } + // 邊框:與 SVG 的 stroke 完全相同的環帶 + //(SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.006~0.018) + if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) { + r += (i1 - r) * 0.18; + g += (i2 - g) * 0.18; + b += (i3 - b) * 0.18; + } + // 字:實心方塊 + for (const rc of rects) { + if (u >= rc.x && u < rc.x + rc.w && v >= rc.y && v < rc.y + rc.h) { + r = i1; + g = i2; + b = i3; + break; + } + } + acc[o] = Math.round(r); + acc[o + 1] = Math.round(g); + acc[o + 2] = Math.round(b); + acc[o + 3] = 255; + } + } + + if (SS === 1) return encodePng(acc, size, size); + // 盒式縮減(連 alpha 一起平均,圓角邊緣才會平滑) + const out = Buffer.alloc(size * size * 4); + const n = SS * SS; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let sy = 0; sy < SS; sy += 1) { + const row = (y * SS + sy) * big; + for (let sx = 0; sx < SS; sx += 1) { + const o = (row + x * SS + sx) * 4; + const av = acc[o + 3]; + r += acc[o] * av; + g += acc[o + 1] * av; + b += acc[o + 2] * av; + a += av; + } + } + const o = (y * size + x) * 4; + out[o] = a ? Math.round(r / a) : 0; + out[o + 1] = a ? Math.round(g / a) : 0; + out[o + 2] = a ? Math.round(b / a) : 0; + out[o + 3] = Math.round(a / n); + } + } + return encodePng(out, size, size); +} + +// --------------------------------------------------------------------------- // +// 產生並寫檔 +// --------------------------------------------------------------------------- // + +export function generateIcon(slug, { size = DEFAULT_SIZE, code = null } = {}) { + const spec = iconSpec(slug, { code }); + const svg = renderSvg(spec, size); + const png = renderPng(spec, size); + pl.writeText(iconSvgPath(slug), svg); + fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); + fs.writeFileSync(iconPngPath(slug), png); + return { + spec, + svg: iconSvgPath(slug), + png: iconPngPath(slug), + size, + bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length }, + }; +} + +export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug)); diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 9aa1d40..1499690 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -15,6 +15,7 @@ import zlib from "node:zlib"; import { fileURLToPath } from "node:url"; import * as pl from "./persona-lib.mjs"; import * as gt from "./persona-gitea.mjs"; +import * as ic from "./persona-icon.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates"); @@ -1115,6 +1116,54 @@ commands.code = async ({ flags, positional }) => { die(`未知 action:${action}(可用 show/assign/next)`); }; +/** + * 由人格資料產生圖示(SVG + PNG)。**建立人格並補齊 IDENTITY/SOUL 之後再跑**, + * 這樣配色與字母才會對得上最終的身分。同一個人格永遠得到同一張圖。 + */ +commands.icon = async ({ flags, positional }) => { + const session = requireSession(flags); + const slug = hostOf(flags, session); + const action = positional[0] || "generate"; + if (action === "show") { + requireMember(slug, session, Boolean(flags["as-guest"])); + const spec = ic.iconSpec(slug); + emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [ + `人格 \`${slug}\` 圖示:${ic.hasIcon(slug) ? "✔ 已產生" : "✘ 尚未產生(跑 `icon generate`)"}`, + ` 字母 ${spec.letters}|配色 ${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`, + ` ${ic.iconSvgPath(slug)}`, + ` ${ic.iconPngPath(slug)}`, + ]); + return; + } + if (action !== "generate") die(`未知 action:${action}(可用 generate/show)`); + requireOwner(slug, session); + if (ic.hasIcon(slug) && !flags.force) { + die(`人格 \`${slug}\` 已經有圖示了。改過身分要重畫請加 --force。`); + } + const size = num(flags.size, ic.DEFAULT_SIZE); + if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 16–2048。"); + const res = ic.generateIcon(slug, { size }); + const lines = [ + `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, + ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, + ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, + ` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`, + ]; + // 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像 + if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { + try { + const owner = await gt.resolveOwner(); + const okAvatar = await gt.setRepoAvatar(owner, gt.personaCode(slug), fs.readFileSync(res.png)); + if (okAvatar) lines.push(" 📦 已設為 Gitea 存取庫頭像。"); + const pushed = await gt.pushArea(slug, "wiki", { message: `icon: 產生人格圖示 ${res.spec.letters}` }); + if (pushed.ok && pushed.changed) lines.push(" ↑ 圖示已同步到 Wiki 區。"); + } catch (err) { + lines.push(` ⚠ 同步到 Gitea 失敗(不影響本機):${err.message.slice(0, 120)}`); + } + } + emit({ persona: slug, ...res }, flags.json, lines); +}; + /** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */ commands.sync = async ({ flags, positional }) => { const session = requireSession(flags); @@ -1261,6 +1310,11 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C room post|read|script|list|theater --session [--room --as --text --text-file --emotion --limit --on --off --with-meta] (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) +圖示(建立人格並補齊資料後跑): + icon generate|show --session [--size 512 --force --no-gitea] + 由編號/名字/emoji 決定配色與字母,產出 icon.svg + icon.png(同一人格永遠同一張圖), + 並設為 Gitea 存取庫頭像、同步到 Wiki 區。 + 編號與 Gitea(存取庫名稱 = 人格編號): code show|assign|next --session [--romaji <英文名> --code --rename --force --public] sync status|init|push|pull --session [--area files|wiki|all --if-due --force --message --owner] diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 21222cd..8f1aacf 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -19,6 +19,7 @@ process.env.PERSONA_GITEA = "off"; const pl = await import("./persona-lib.mjs"); const gt = await import("./persona-gitea.mjs"); +const ic = await import("./persona-icon.mjs"); const CLI = path.join(HERE, "persona.mjs"); const HOOKS = path.join(HERE, "..", "hooks"); @@ -442,6 +443,7 @@ check("大寫編號目錄一樣受跨人格隔離保護", check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)", guard({ session_id: S_CODE, tool_name: "Bash", tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny"); +cli(["icon", "generate", "--session", S_SPEAK, "--size", "64"]); // 讓分區檢查也涵蓋圖示 // 分區必須「不重不漏」:人格產生的每個檔案都要恰好屬於一區,否則同步會默默漏資料 const AREA_EXEMPT = new Set(["state/lock.json", "state/guests.json", "state/sync.json"]); const covered = (rel) => @@ -477,6 +479,46 @@ check("匯出不會把 .sync 的 git clone 打包進去", (() => { return !Object.keys(b.files).some((f) => f.startsWith(".sync")); })()); +console.log("⑭ 人格圖示(SVG + PNG,零外部依賴)"); +const iconSvg = fs.readFileSync(ic.iconSvgPath("alpha"), "utf8"); +const iconPng = fs.readFileSync(ic.iconPngPath("alpha")); +check("兩種格式都產生了", ic.hasIcon("alpha")); +check("PNG 檔頭合法且尺寸正確", (() => { + const sig = iconPng.subarray(0, 8).toString("hex") === "89504e470d0a1a0a"; + const type = iconPng.subarray(12, 16).toString("ascii") === "IHDR"; + return sig && type && iconPng.readUInt32BE(16) === 64 && iconPng.readUInt32BE(20) === 64 && + iconPng[24] === 8 && iconPng[25] === 6; // 8-bit RGBA +})(), iconPng.subarray(0, 30).toString("hex")); +check("PNG 以 IEND 結尾(chunk 完整)", + iconPng.subarray(iconPng.length - 8, iconPng.length - 4).toString("ascii") === "IEND"); +check("SVG 有 viewBox、漸層與字母方塊", + iconSvg.includes(`viewBox="0 0 64 64"`) && iconSvg.includes("linearGradient") && + (iconSvg.match(/ 10, iconSvg.slice(0, 80)); +check("圖示是決定性的(同一個人格永遠同一張圖)", (() => { + const a = ic.renderPng(ic.iconSpec("alpha"), 32); + const b = ic.renderPng(ic.iconSpec("alpha"), 32); + return Buffer.compare(a, b) === 0 && ic.renderSvg(ic.iconSpec("alpha"), 32) === ic.renderSvg(ic.iconSpec("alpha"), 32); +})()); +check("不同人格的圖示不一樣", + Buffer.compare(ic.renderPng(ic.iconSpec("alpha"), 32), ic.renderPng(ic.iconSpec("GAMMA-01"), 32)) !== 0); +check("字母取自編號(ALPHA-01 → AL)", ic.iconSpec("alpha").letters === "AL", + ic.iconSpec("alpha").letters); +check("已有圖示時不加 --force 會被擋", + cli(["icon", "generate", "--session", S_SPEAK], { expectOk: false }).status !== 0); +check("--force 可以重畫", cli(["icon", "generate", "--session", S_SPEAK, "--size", "64", "--force"]).status === 0); +check("圖示屬於 Wiki 區(低頻的身分資料)", + covered("icon.svg")[0] === "wiki" && covered("icon.png")[0] === "wiki"); +check("匯出會用 base64 帶走 PNG(二進位不會壞掉)", (() => { + const { bundle: b } = pl.exportBundle("alpha"); + const entry = b.files["icon.png"]; + return entry?.encoding === "base64" && + Buffer.compare(Buffer.from(entry.content, "base64"), iconPng) === 0; +})()); +check("guest 只能看不能重畫圖示", + pl.GUEST_SAFE_SUBCOMMANDS.has("icon") === false || + 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"); + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-anime/SKILL.md b/skills/persona-anime/SKILL.md index b73fc87..1f5dc66 100644 --- a/skills/persona-anime/SKILL.md +++ b/skills/persona-anime/SKILL.md @@ -142,11 +142,21 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \ 用 Edit 把角色的概念網寫進 `mindmap/semantic.mmd`:作品世界觀 → 陣營/組織 → 能力 → 重要人物 → 個人課題。 -## 8. 回報 +## 8. 產生人格圖示(資料補齊之後才做) + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session +``` + +產出 `icon.svg` + `icon.png`,並設成 Gitea 存取庫頭像、同步到 Wiki 區。 +**一定要等 canon 記憶與身分都補齊再跑**——配色與字母由編號、Name、Emoji 雜湊而來; +改過身分要重畫就加 `--force`。 + +## 9. 回報 用該角色的語氣做一段簡短自我介紹(**這是他第一次醒來**),然後用一般語氣列出: -- slug、五個身分欄位 +- 編號(如 `ASUNA-01`)、五個身分欄位、圖示路徑 - 情緒基線前三高 - 固化了幾則 canon 記憶、用了哪些來源(URL 列表) - 哪些設定各來源說法不一致(待使用者裁決) diff --git a/skills/persona-create/SKILL.md b/skills/persona-create/SKILL.md index 5a2e2bb..f107a46 100644 --- a/skills/persona-create/SKILL.md +++ b/skills/persona-create/SKILL.md @@ -113,9 +113,25 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \ 也可視需要在 `mindmap/semantic.mmd` 補上初始概念(自我/使用者/共同經驗)。 -### 7. 回報 +### 7. 產生人格圖示(資料補齊之後才做) -用該人格的 emoji 與語氣,摘要:slug、五個身分欄位、情緒基線前三高、倉庫路徑, +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session +``` + +產出 `icon.svg` 與 `icon.png`(512×512),並自動設成 Gitea 存取庫的頭像、同步到 Wiki 區。 + +- **一定要等身分補齊再跑**:配色與字母是由**編號、Name、Emoji** 一起雜湊出來的, + 資料還沒填完就產生,之後改名字圖就對不上了。 +- 同一個人格永遠得到同一張圖(純函數,不隨機)。 +- 之後改了 `IDENTITY.md` 想重畫 → `icon generate --session --force`。 +- 圖是**幾何徽章**:雙色漸層 + 專屬點陣紋 + 編號前兩個字母(`ASUNA-01` → `AS`)。 + 沒有把 emoji 畫進去是因為 PNG 需要字型柵格化,而本 plugin 不用任何外部依賴; + emoji 仍然參與配色的雜湊。 + +### 8. 回報 + +用該人格的 emoji 與語氣,摘要:編號、五個身分欄位、情緒基線前三高、倉庫路徑與圖示, 並提示:`/jsc-persona:persona-chat ` 開始對話、`/jsc-persona:persona-invite` 邀別的人格加入。 --- diff --git a/skills/persona-sync/SKILL.md b/skills/persona-sync/SKILL.md index ed0f8b9..3119665 100644 --- a/skills/persona-sync/SKILL.md +++ b/skills/persona-sync/SKILL.md @@ -29,7 +29,7 @@ description: 人格編號與 Gitea 儲存:指派人格編號(英文名全大 | 區 | 放什麼 | 什麼時候 push | | --- | --- | --- | | **檔案區**(主存取庫) | 高頻活狀態:`state/emotion.json`、`short-term.jsonl`、`inner.jsonl`(心裡話)、`said.jsonl`、`inbox/`、`mindmap/threads/`、`journal/` | 每輪對話後由 `Stop` hook 背景推送(有最小間隔) | -| **Wiki 區** | 低頻設定:`IDENTITY`/`SOUL`/`AGENTS`/`USER`、長期記憶、`INDEX`、心智圖、人際關係圖 | 記憶固化、改身分、改關係圖、`release` 時 | +| **Wiki 區** | 低頻設定:`IDENTITY`/`SOUL`/`AGENTS`/`USER`、**人格圖示 `icon.svg`/`icon.png`**、長期記憶、`INDEX`、心智圖、人際關係圖 | 記憶固化、改身分、改關係圖、`release` 時 | Wiki 是給人讀的設定百科:Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以 `memory/long-term/xxx.md` 會攤平成 `Memory-xxx.md`(頁面顯示為「Memory xxx」), @@ -38,6 +38,9 @@ Wiki 是給人讀的設定百科:Gitea 的 wiki 只有根目錄的 `.md` 會 **本機永遠是工作副本**:hook 每輪讀寫的是本機檔案,不經過網路。Gitea 掛掉、離線、沒設 token, 人格照樣能聊天,只是不同步——**同步失敗永遠不阻斷對話**。 +人格圖示(`/jsc-persona:persona-create` 或 `icon generate` 產生)除了同步到 Wiki 區, +也會被設成**存取庫頭像**,Gitea 的清單上就看得到每個人格的臉。 + ## 設定 ```bash From 1884a9c3d70e1c1f6dfe8af590ef39f7503169d2 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 02:17:35 +0000 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E5=9C=96=E7=A4=BA=E9=85=8D?= =?UTF-8?q?=E8=89=B2=E5=8F=96=E8=87=AA=E4=BA=BA=E6=A0=BC=E3=80=8C=E6=9C=80?= =?UTF-8?q?=E6=96=B0=E4=B8=80=E6=AC=A1=E7=99=BB=E5=A0=B4=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E5=AE=98=E6=96=B9=E8=A6=96=E8=A6=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 7 +- README.md | 47 ++++++++-- plugin.json | 2 +- scripts/persona-icon.mjs | 164 ++++++++++++++++++++++++++++----- scripts/persona.mjs | 44 +++++++-- scripts/selftest.mjs | 49 ++++++++++ skills/persona-anime/SKILL.md | 15 ++- skills/persona-create/SKILL.md | 5 + skills/persona-icon/SKILL.md | 106 +++++++++++++++++++++ 11 files changed, 396 insertions(+), 47 deletions(-) create mode 100644 skills/persona-icon/SKILL.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bc25cdc..bca8d6e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.4", + "version": "0.0.5", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d9fea7a..0496b79 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.4", + "version": "0.0.5", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index f4bfa56..5bf748f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,12 @@ 10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push), 低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。 **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 -11. **人格圖示在資料補齊之後才產生**:`icon generate` 的配色與字母由「編號/Name/Emoji」 - 雜湊而來,太早跑會跟最終身分對不上。SVG 與 PNG 是同一張圖(共用單位座標與點陣字), +11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 +12. **圖示配色優先取自「最新登場」的官方視覺**(`/jsc-persona:persona-icon`): + 上網找圖 → **用 Read 親眼看過** → 取色 → `--palette` 搭配 `--source-url`(CLI 強制存證)。 + **沒看過圖就不准填顏色**;找不到官方圖就退回編號雜湊配色並告知使用者。 + 不把原圖放進存取庫:那是散布他人美術作品,而且環境裡也沒有解碼器可用。 ## 慣例 diff --git a/README.md b/README.md index 75a3456..16cf941 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **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,零外部依賴) -建立人格**並補齊 IDENTITY/SOUL 之後**跑 `icon generate`,得到一張 512×512 的幾何徽章: +建立人格**並補齊 IDENTITY/SOUL 之後**產生,一張 512×512 的幾何徽章。配色有兩種來源: + +**① 取自最新造型(`/jsc-persona:persona-icon`,動漫角色建議用這個)** + +上網查出角色**最新一次登場**的官方視覺 → 下載 → **親眼看過** → 取五個色 → 繪製: + +| 元素 | 取自照片的哪裡 | +| --- | --- | +| 對角漸層 | `hair`(髮色)→ `accent`(服裝主色) | +| 外框 | `eye`(瞳色) | +| 點陣紋 | `light`(最亮的部位) | +| 中央兩個字母 | 編號前兩字(`ASUNA-01` → `AS`) | + +`--palette` 必須配 `--source-url`(CLI 強制):配色是從哪張圖來的要留得下來。 +兩個顏色一深一淺時(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓到對比 ≥ 3.2, +確保字讀得到——顏色仍然是照片來的,只是收斂色階。 + +**② 沒有參考圖時:編號雜湊** | 元素 | 怎麼決定 | | --- | --- | | 雙色對角漸層 | `sha256(編號|Name|Emoji)` → 色相/飽和度/明度 | | 5×5 左右對稱點陣紋 | 同一組雜湊的位元 | -| 中央兩個字母 | 編號的前兩個字(`ASUNA-01` → `AS`),5×7 點陣,自動選黑或白確保對比 | +| 中央兩個字母 | 編號的前兩個字,自動選黑或白確保對比 | - **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01` 與 `ASUNA-02` 明顯不同。 - SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。 - PNG 由**自寫的柵格器**畫出(3× 超取樣 + 盒式縮減當反鋸齒),再用 `zlib` 手工組出 IHDR/IDAT/IEND 與 CRC32。這台機器沒有 rsvg/inkscape/imagemagick,也沒有影像函式庫, 而本專案禁止 npm 依賴——所以就自己畫。 -- **為什麼沒有 emoji**:把 emoji 畫進 PNG 需要字型柵格化(機器上連 emoji 字型都沒有)。 - emoji 仍然參與配色的雜湊,只是不作為圖形。 +- **為什麼沒有 emoji、也不是把照片本身當圖示**:環境裡沒有任何影像解碼器 + (rsvg/imagemagick/Pillow/ffmpeg 都沒有),JPEG/WebP 讀不進來,無法把原圖轉成 PNG; + 而且把他人的美術作品原樣放進存取庫是散布、不是引用。**取配色**是有依據又不搬運原圖的做法。 + emoji 同理無法柵格化,但仍參與雜湊配色。 - 圖示屬於低頻資料 → 同步到 **Wiki 區**,並自動設成 Gitea **存取庫頭像**。 ```bash -node scripts/persona.mjs icon generate --session [--size 512] [--force] # 改過身分要重畫就 --force -node scripts/persona.mjs icon show --session +# 取自最新造型(必須同時給來源網址) +node scripts/persona.mjs icon generate --session --force \ + --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \ + --source-url "<官方視覺的網址>" --source-note "<作品(年份)+造型描述>" + +# 沒有參考圖:編號雜湊配色 +node scripts/persona.mjs icon generate --session [--size 512] [--force] +node scripts/persona.mjs icon show --session # 看配色與來源 ``` ## 人格編號與 Gitea 儲存 @@ -281,6 +306,12 @@ node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json - **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` 人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。 @@ -324,7 +355,7 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 128 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、圖示、hooks +node scripts/selftest.mjs # 136 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、圖示與照片配色、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 diff --git a/plugin.json b/plugin.json index eaae2e0..143a0d4 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.4", + "version": "0.0.5", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs index afed0bf..1b97ae7 100644 --- a/scripts/persona-icon.mjs +++ b/scripts/persona-icon.mjs @@ -103,30 +103,127 @@ function luminance([r, g, b]) { // 圖案規格:純函數 of 人格資料 → 同一個人格永遠同一張圖 // --------------------------------------------------------------------------- // -export function iconSpec(slug, { code = null, identity = null } = {}) { +const WHITE = [255, 255, 255]; +const BLACK = [16, 18, 24]; + +/** WCAG 對比度(1–21)。 */ +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 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 h = crypto.createHash("sha256").update(seedText).digest(); - const hue = (h[0] * 360) / 256; - const hue2 = (hue + 40 + (h[1] % 80)) % 360; - const sat = 0.52 + (h[2] % 30) / 100; // 0.52–0.81 - const light = 0.36 + (h[3] % 18) / 100; // 0.36–0.53 - const c1 = hslToRgb(hue, sat, light); - const c2 = hslToRgb(hue2, sat * 0.9, Math.min(0.72, light + 0.18)); - - // 5×5 左右對稱的點陣(只決定左邊三行,鏡射過去) + // 5×5 左右對稱的點陣(只決定左邊三行,鏡射過去)——不論哪種配色都保留這個專屬紋路 const pattern = []; for (let y = 0; y < 5; y += 1) { const row = []; for (let x = 0; x < 3; x += 1) row.push((h[8 + y * 3 + x] & 1) === 1); pattern.push([...row, row[1], row[0]]); } - 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.52–0.81 + const light = 0.36 + (h[3] % 18) / 100; // 0.36–0.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 { persona: slug, @@ -137,6 +234,12 @@ export function iconSpec(slug, { code = null, identity = null } = {}) { c1, c2, ink, + ring: ring || ink, + dot: dot || ink, + ringAlpha, + dotAlpha, + palette: pal, + source: pal ? config.icon?.source || null : null, pattern, seed: h.subarray(0, 8).toString("hex"), }; @@ -208,7 +311,7 @@ export function renderSvg(spec, size = DEFAULT_SIZE) { " ", " ", ` `, - ` `, + ` `, ...dots(spec).map((d) => ` `), " ", ` `, @@ -216,7 +319,7 @@ export function renderSvg(spec, size = DEFAULT_SIZE) { " ", ` `, + ` fill="none" stroke="${hex(spec.ring)}" stroke-opacity="${spec.ringAlpha}" stroke-width="${u(0.012)}"/>`, "", "", ]; @@ -303,6 +406,8 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { const circles = dots(spec); const rects = glyphRects(spec); 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) { 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 g = spec.c1[1] + (spec.c2[1] - spec.c1[1]) * t; let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t; - // 紋:圓點(14% 不透明度) + // 紋:圓點 for (const d of circles) { const dx = u - d.cx; const dy = v - d.cy; if (dx * dx + dy * dy <= d.r * d.r) { - r += (i1 - r) * 0.14; - g += (i2 - g) * 0.14; - b += (i3 - b) * 0.14; + r += (d1 - r) * spec.dotAlpha; + g += (d2 - g) * spec.dotAlpha; + b += (d3 - b) * spec.dotAlpha; break; } } // 邊框:與 SVG 的 stroke 完全相同的環帶 //(SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.006~0.018) if (insideInset(u, v, 0.006) && !insideInset(u, v, 0.018)) { - r += (i1 - r) * 0.18; - g += (i2 - g) * 0.18; - b += (i3 - b) * 0.18; + r += (g1 - r) * spec.ringAlpha; + g += (g2 - g) * spec.ringAlpha; + b += (g3 - b) * spec.ringAlpha; } // 字:實心方塊 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 } = {}) { - const spec = iconSpec(slug, { code }); +export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = null, source = null } = {}) { + const spec = iconSpec(slug, { code, palette }); const svg = renderSvg(spec, size); const png = renderPng(spec, size); pl.writeText(iconSvgPath(slug), svg); fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); 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 { spec, svg: iconSvgPath(slug), diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 1499690..8c7740c 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1127,9 +1127,13 @@ commands.icon = async ({ flags, positional }) => { if (action === "show") { requireMember(slug, session, Boolean(flags["as-guest"])); const spec = ic.iconSpec(slug); + const src = spec.source || {}; emit({ persona: slug, spec, exists: ic.hasIcon(slug) }, flags.json, [ `人格 \`${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.iconPngPath(slug)}`, ]); @@ -1138,16 +1142,41 @@ commands.icon = async ({ flags, positional }) => { if (action !== "generate") die(`未知 action:${action}(可用 generate/show)`); requireOwner(slug, session); if (ic.hasIcon(slug) && !flags.force) { - die(`人格 \`${slug}\` 已經有圖示了。改過身分要重畫請加 --force。`); + die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`); } const size = num(flags.size, ic.DEFAULT_SIZE); if (!Number.isFinite(size) || size < 16 || size > 2048) die("--size 只能是 16–2048。"); - 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 = [ `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 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 存取庫的頭像 if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { @@ -1312,8 +1341,11 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 圖示(建立人格並補齊資料後跑): icon generate|show --session [--size 512 --force --no-gitea] - 由編號/名字/emoji 決定配色與字母,產出 icon.svg + icon.png(同一人格永遠同一張圖), - 並設為 Gitea 存取庫頭像、同步到 Wiki 區。 + [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."] + [--source-url <參考照片網址> --source-note <說明> --source-date ] + 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 + 沒帶 --palette → 配色由編號/名字/emoji 雜湊而來。 + 帶了 --palette → 用「你實際看過的參考照片」萃取的顏色(必須同時帶 --source-url 存證)。 編號與 Gitea(存取庫名稱 = 人格編號): code show|assign|next --session [--romaji <英文名> --code --rename --force --public] diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 8f1aacf..32ee996 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -519,6 +519,55 @@ check("guest 只能看不能重畫圖示", 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"); +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(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-anime/SKILL.md b/skills/persona-anime/SKILL.md index 1f5dc66..3108c19 100644 --- a/skills/persona-anime/SKILL.md +++ b/skills/persona-anime/SKILL.md @@ -144,13 +144,20 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation node \ ## 8. 產生人格圖示(資料補齊之後才做) +動漫角色**一定要走 `/jsc-persona:persona-icon`**:你在第 2 步已經上網查過這個角色, +順著找出他**最新一次登場**的官方視覺(新作主視覺/官方角色圖),下載、**親眼看過**, +再把髮色/瞳色/服裝色餵給 CLI: + ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ + --session --force \ + --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.." \ + --source-url "<那張圖的網址>" --source-note "<作品(年份)+造型描述>" ``` -產出 `icon.svg` + `icon.png`,並設成 Gitea 存取庫頭像、同步到 Wiki 區。 -**一定要等 canon 記憶與身分都補齊再跑**——配色與字母由編號、Name、Emoji 雜湊而來; -改過身分要重畫就加 `--force`。 +**沒看過圖就不要填顏色**;真的找不到官方圖就不帶 `--palette`,改用編號雜湊配色並告知使用者。 +產出的 `icon.svg` + `icon.png` 會設成 Gitea 存取庫頭像並同步到 Wiki 區。 +**要等 canon 記憶與身分都補齊再跑。** ## 9. 回報 diff --git a/skills/persona-create/SKILL.md b/skills/persona-create/SKILL.md index f107a46..90d61b8 100644 --- a/skills/persona-create/SKILL.md +++ b/skills/persona-create/SKILL.md @@ -115,6 +115,11 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \ ### 7. 產生人格圖示(資料補齊之後才做) +角色若有官方視覺(動漫/遊戲角色),走 **`/jsc-persona:persona-icon`**: +上網找出他最新一次登場的官方圖 → 親眼看過 → 取髮色/瞳色/服裝色 → 用那組配色繪製。 + +原創人格沒有參考圖,直接用編號雜湊的配色即可: + ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session ``` diff --git a/skills/persona-icon/SKILL.md b/skills/persona-icon/SKILL.md new file mode 100644 index 0000000..2990713 --- /dev/null +++ b/skills/persona-icon/SKILL.md @@ -0,0 +1,106 @@ +--- +name: persona-icon +description: 產生或更新人格的圖示(icon.svg + icon.png):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖後萃取髮色/瞳色/服裝色,再用這組配色繪製圖示,並記下來源網址備查;找不到參考圖時退回用編號雜湊配色。當使用者說要做人格頭像/圖示/icon、要依角色最新造型更新圖示、說現在的圖示不像、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 +--- + +# 🎨 persona-icon — 依最新造型產生人格圖示 + +**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `) +**時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——配色與字母綁在最終身分上。 + +--- + +## 為什麼要上網找圖 + +圖示要像這個人格,就得用他**現在**的樣子:動漫角色會換造型、換作品、換髮色。 +所以流程是「查最新登場 → 看那張圖 → 取色 → 畫」,而不是憑印象填色。 + +## 步驟 + +### 1. 查出「最新一次登場」 + +用 WebSearch 找這個角色最近的官方動向(新作、新章節、新造型),至少確認: + +- 最新的作品/章節名稱與年份 +- 那個版本的造型敘述(髮色、瞳色、服裝主色) + +搜尋語彙範例:`<角色> <作品> 2026 新作 主視覺`、` 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 --force \ + --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \ + --source-url "<你剛才看的那張圖的網址>" \ + --source-note "<作品名(年份)+造型描述:髮色、瞳色、服裝>" \ + --source-date +``` + +- **`--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. **技術上**:環境裡沒有任何影像解碼器(rsvg/imagemagick/Pillow 都沒有), + JPEG/WebP 讀不進來,無法把原圖轉成 PNG。 +2. **權利上**:把他人的美術作品原樣放進存取庫是散布,不是引用。 + +取配色是有依據、又不搬運原圖的做法。使用者若堅持要用原圖當頭像, +請他自己把圖片上傳到 Gitea 的存取庫設定頁。 + +## 邊界 + +- 只能對「本 session 已載入的人格」產生圖示。 +- 已有圖示時要重畫必須加 `--force`。 +- 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。 +- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 From 15e4eea8ac5137f082de5ae8bb113e35b7f05b1b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 02:39:58 +0000 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=E5=9C=96=E7=A4=BA=E6=94=B9?= =?UTF-8?q?=E7=82=BA=E4=BA=BA=E7=89=A9=E5=BD=A2=E8=B1=A1=E5=9C=96=EF=BC=88?= =?UTF-8?q?=E6=9C=89=E8=87=89=EF=BC=89=EF=BC=8C=E4=B8=A6=E8=AE=93=20Wiki?= =?UTF-8?q?=20=E4=BF=9D=E5=AD=98=20SVG=20+=20PNG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 圖示三種樣式,優先看得到臉 photo 有參考圖且工具齊全 → 從官方視覺自動偵測並**裁出臉**,圓角+瞳色外框 portrait 有參考圖但缺工具 → **有五官的向量人物**(髮型/瞳色/服裝色都取自那張圖) badge 完全沒有參考圖 → 舊的雙色漸層 + 編號字母 portrait 與 badge 共用同一份「圖形清單」,SVG 與自寫柵格器從同一份資料畫, 兩邊不可能長得不一樣;photo 的 SVG 以 base64 內嵌裁好的 PNG,一樣不外連。 2. 缺工具會「提示安裝」,不靜默降級 新增 scripts/portrait.py(選用):Pillow 解碼裁切、OpenCV + lbpcascade_animeface 偵測動漫臉。toolReport() 會列出缺什麼、為什麼要、怎麼裝(venv 免 sudo), CLI 在退回向量形象時把這些印出來。 注意:**OpenCV 5 拿掉了 CascadeClassifier,必須裝 4.x**。 plugin 本體仍然零依賴——沒有這些工具照樣產得出有臉的形象圖。 新增 `icon faces`:列出參考圖裡偵測到的所有臉。多角色的主視覺一定要先看再挑 (--pick <索引>|largest|leftmost|rightmost 或 --face x,y,w,h),挑錯就是別人的臉。 3. Wiki 保存形象圖 icon.svg 與 icon.png 都在 Wiki 區,另外自動產生一頁 Icon:同時展示兩種格式, 並列出樣式、調色盤、來源網址、造型說明與裁切框。PNG 同時設為存取庫頭像。 已套用到兩個真實人格(皆為真實裁臉): ASUNA-01 《Unanswered//butterfly》(2026) 官方主視覺,臉 #1(anime-cascade) YUI-01 AniList 官方角色圖(frontal-cascade) selftest 148 項全綠(新增第 ⑯ 節:形象圖真的畫了五官、SVG/PNG 出自同一份清單、 工具偵測與安裝提示、Wiki 形象圖專頁與來源)。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 13 +- README.md | 52 ++++-- plugin.json | 2 +- scripts/persona-gitea.mjs | 65 ++++++- scripts/persona-icon.mjs | 352 +++++++++++++++++++++++++++++++---- scripts/persona.mjs | 74 +++++++- scripts/portrait.py | 176 ++++++++++++++++++ scripts/selftest.mjs | 51 ++++- skills/persona-icon/SKILL.md | 71 +++++-- 11 files changed, 769 insertions(+), 91 deletions(-) create mode 100644 scripts/portrait.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bca8d6e..8e3c725 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.5", + "version": "0.0.6", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0496b79..12534aa 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.5", + "version": "0.0.6", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 5bf748f..c99307d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,10 +34,15 @@ **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 -12. **圖示配色優先取自「最新登場」的官方視覺**(`/jsc-persona:persona-icon`): - 上網找圖 → **用 Read 親眼看過** → 取色 → `--palette` 搭配 `--source-url`(CLI 強制存證)。 - **沒看過圖就不准填顏色**;找不到官方圖就退回編號雜湊配色並告知使用者。 - 不把原圖放進存取庫:那是散布他人美術作品,而且環境裡也沒有解碼器可用。 +12. **圖示必須是人物形象圖(盡量有臉)**,來源是該人格「最新一次登場」的官方視覺 + (`/jsc-persona:persona-icon`):上網找圖 → **用 Read 親眼看過** → 有 Pillow+OpenCV 就 + `--photo` 裁臉,否則畫有五官的向量人物;配色一律 `--palette` 搭配 `--source-url`(CLI 強制存證)。 + **沒看過圖就不准填顏色、也不准亂挑臉**(多角色的圖先 `icon faces` 再挑,挑完打開確認)。 +13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 + 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。 + plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 +14. **Wiki 必須保存形象圖**:`icon.svg` 與 `icon.png` 都在 Wiki 區,另有自動產生的 **Icon** 頁 + 展示兩種格式並列出來源、樣式與裁切框。 ## 慣例 diff --git a/README.md b/README.md index 16cf941..cf779e6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 --- -## 十二條硬規則 +## 十三條硬規則 | 規則 | 怎麼做到 | | --- | --- | @@ -26,7 +26,8 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | -| **12. 建立並補齊資料後產生圖示,配色取自最新造型** | `/jsc-persona:persona-icon` 先上網找出該人格**最新一次登場**的官方視覺、**親眼看過**那張圖,取出髮色/瞳色/服裝色,再繪製 `icon.svg` + `icon.png`(512×512),來源網址一併存進 `config.json` 備查;沒有參考圖才退回用編號雜湊配色。PNG 是**自寫柵格器 + zlib 手工編碼**,不依賴任何影像函式庫 | +| **12. 圖示是人物形象圖,取自最新登場的官方視覺** | `/jsc-persona:persona-icon` 上網找出該人格**最新一次登場**的官方視覺、**親眼看過**,有 Pillow+OpenCV 就直接**裁出臉**當形象圖;沒工具則畫**有五官的向量人物**(配色同樣取自那張圖),並印出安裝指令。來源網址與裁切框存進 `config.json` 備查 | +| **13. Wiki 保存形象圖** | `icon.svg` 與 `icon.png` 一起同步到 Wiki 區,另外自動產生一頁 **Icon** 同時展示兩種格式並列出來源、樣式與裁切框;PNG 同時設為存取庫頭像 | --- @@ -146,11 +147,27 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 ## 人格圖示(SVG + PNG,零外部依賴) -建立人格**並補齊 IDENTITY/SOUL 之後**產生,一張 512×512 的幾何徽章。配色有兩種來源: +建立人格**並補齊 IDENTITY/SOUL 之後**產生,512×512。**優先是人物形象圖(看得到臉)**, +依環境有無工具分三種樣式: -**① 取自最新造型(`/jsc-persona:persona-icon`,動漫角色建議用這個)** +| 樣式 | 條件 | 長什麼樣 | +| --- | --- | --- | +| **`photo`** | 有參考圖 **且** 有 Pillow+OpenCV | 從官方視覺**裁出的臉**(自動臉部偵測),圓角+瞳色外框 | +| **`portrait`** | 有參考圖但缺工具 | **有五官的向量人物**:髮型、瞳色、服裝色都取自那張圖 | +| **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 | -上網查出角色**最新一次登場**的官方視覺 → 下載 → **親眼看過** → 取五個色 → 繪製: +缺工具時 CLI **會印出安裝指令**再退回 `portrait`,不會靜默降級: + +```bash +python3 -m venv ~/.cache/jsc-persona/venv +~/.cache/jsc-persona/venv/bin/pip install pillow "opencv-python-headless<5" # OpenCV 5 拿掉了 CascadeClassifier +curl -sL -o ~/.cache/jsc-persona/lbpcascade_animeface.xml \ + https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml +``` + +(venv 放這個路徑會被自動偵測;也可用 `PERSONA_PYTHON` 指定。) + +**配色一律取自實際看過的參考圖** | 元素 | 取自照片的哪裡 | | --- | --- | @@ -163,13 +180,7 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 兩個顏色一深一淺時(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓到對比 ≥ 3.2, 確保字讀得到——顏色仍然是照片來的,只是收斂色階。 -**② 沒有參考圖時:編號雜湊** - -| 元素 | 怎麼決定 | -| --- | --- | -| 雙色對角漸層 | `sha256(編號|Name|Emoji)` → 色相/飽和度/明度 | -| 5×5 左右對稱點陣紋 | 同一組雜湊的位元 | -| 中央兩個字母 | 編號的前兩個字,自動選黑或白確保對比 | +沒有參考圖時(原創人格)才退回雜湊配色:`sha256(編號|Name|Emoji)` 決定漸層與點陣紋。 - **同一個人格永遠得到同一張圖**(純函數,沒有隨機);`ASUNA-01` 與 `ASUNA-02` 明顯不同。 - SVG 與 PNG **是同一張圖**:兩者共用同一組單位座標與同一份點陣字資料。 @@ -180,17 +191,20 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 (rsvg/imagemagick/Pillow/ffmpeg 都沒有),JPEG/WebP 讀不進來,無法把原圖轉成 PNG; 而且把他人的美術作品原樣放進存取庫是散布、不是引用。**取配色**是有依據又不搬運原圖的做法。 emoji 同理無法柵格化,但仍參與雜湊配色。 -- 圖示屬於低頻資料 → 同步到 **Wiki 區**,並自動設成 Gitea **存取庫頭像**。 +- 圖示屬於低頻資料 → `icon.svg` 與 `icon.png` 都同步到 **Wiki 區**,並自動產生一頁 **Icon** + 展示兩種格式與來源;PNG 同時設成 Gitea **存取庫頭像**。 ```bash -# 取自最新造型(必須同時給來源網址) +# 先看參考圖裡有幾張臉(多角色的主視覺務必先看) +node scripts/persona.mjs icon faces --session --photo "<圖片網址>" + +# 裁臉 + 取色(--palette 必須配 --source-url) node scripts/persona.mjs icon generate --session --force \ + --photo "<圖片網址>" --pick 1 \ --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \ --source-url "<官方視覺的網址>" --source-note "<作品(年份)+造型描述>" -# 沒有參考圖:編號雜湊配色 -node scripts/persona.mjs icon generate --session [--size 512] [--force] -node scripts/persona.mjs icon show --session # 看配色與來源 +node scripts/persona.mjs icon show --session # 看樣式、配色、來源與裁切框 ``` ## 人格編號與 Gitea 儲存 @@ -355,11 +369,11 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 136 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、圖示與照片配色、hooks +node scripts/selftest.mjs # 148 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、形象圖與裁臉、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 -`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(圖示:SVG + 自寫 PNG 編碼)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。 +`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(形象圖:SVG + 自寫 PNG 編碼)、`scripts/portrait.py`(選用:照片裁臉)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。 --- diff --git a/plugin.json b/plugin.json index 143a0d4..f4b76bc 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.5", + "version": "0.0.6", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index e2d292b..0f9d6aa 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -306,7 +306,7 @@ function listTrackedFiles(dir) { } export const WIKI_MANIFEST = "_paths.json"; -const WIKI_RESERVED = new Set(["Home.md", WIKI_MANIFEST]); +const WIKI_RESERVED = new Set(["Home.md", "Icon.md", WIKI_MANIFEST]); const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" }; /** @@ -389,6 +389,58 @@ function unstageArea(slug, area, dir, only = null) { return written; } +/** + * Wiki 的「形象圖」頁:把 icon.svg 與 icon.png 都保存在 Wiki 並展示出來, + * 附上這張圖是從哪張參考照片來的(可查證)。 + */ +export function wikiIconPage(slug, code) { + const ident = pl.identityFields(slug); + const config = pl.loadConfig(slug); + const icon = config.icon || {}; + const src = icon.source || {}; + const root = pl.personaDir(slug); + const hasSvg = fs.existsSync(path.join(root, "icon.svg")); + const hasPng = fs.existsSync(path.join(root, "icon.png")); + const styleLabel = { + photo: "真實照片裁臉", + portrait: "向量人物形象(有臉),配色取自參考照片", + badge: "編號徽章(沒有參考照片時的樣式)", + }[icon.style || "badge"]; + return [ + `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} 的形象圖`, + "", + `\`${code}\` ${styleLabel}`, + "", + ...(hasSvg || hasPng + ? [ + "| SVG(向量,可無限放大) | PNG(點陣,Gitea 頭像用) |", + "| --- | --- |", + `| ${hasSvg ? 'icon.svg' : "(缺)"} ` + + `| ${hasPng ? 'icon.png' : "(缺)"} |`, + `| [icon.svg](icon.svg) | [icon.png](icon.png) |`, + "", + ] + : ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]), + "## 這張圖怎麼來的", + "", + "| 欄位 | 內容 |", + "| --- | --- |", + `| 樣式 | ${icon.style || "badge"} |`, + `| 尺寸 | ${icon.size || "?"}×${icon.size || "?"} |`, + `| 調色盤 | ${icon.palette || "(由編號雜湊)"} |`, + `| 參考來源 | ${src.url ? `[${src.url}](${src.url})` : "(無)"} |`, + `| 造型說明 | ${src.note || "—"} |`, + `| 參考日期 | ${src.date || "—"} |`, + `| 產生時間 | ${icon.generated_at || "—"} |`, + "", + icon.style === "photo" + ? "> 由 jsc-persona 從上述參考圖**裁出臉部**產生(自動臉部偵測),並套上圓角與瞳色外框。" + : "> 由 jsc-persona 產生:配色取自角色**最新一次登場**的官方視覺。", + "> 這裡保存的是產生出來的形象圖(SVG + PNG),供人格身分辨識使用。", + "", + ].join("\n"); +} + /** Wiki 首頁:讓 Gitea 上點進去就看得懂這是誰。 */ export function wikiHome(slug, code) { const ident = pl.identityFields(slug); @@ -417,6 +469,7 @@ export function wikiHome(slug, code) { "- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Vibe / Emoji / Avatar)", "- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity", "- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解", + "- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源", "- [Memory INDEX](Memory-INDEX) — 長期記憶索引", "", "### 長期記憶", @@ -466,7 +519,10 @@ export async function pushArea(slug, area, { message = "", code = null, owner = const theOwner = owner || (await resolveOwner()); const { host } = giteaEnv(); const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); - if (area === "wiki") pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); + if (area === "wiki") { + pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); + pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode)); + } const staged = stageArea(slug, area, dir); gitOrThrow(["add", "-A"], dir, "git add"); const dirty = git(["diff", "--cached", "--quiet"], dir); @@ -485,7 +541,10 @@ export async function pushArea(slug, area, { message = "", code = null, owner = if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) { git(["reset", "--hard", "--quiet", `origin/${branch}`], dir); stageArea(slug, area, dir); - if (area === "wiki") pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); + if (area === "wiki") { + pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); + pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode)); + } git(["add", "-A"], dir); if (!git(["diff", "--cached", "--quiet"], dir).ok) { git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir); diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs index 1b97ae7..73fcd8f 100644 --- a/scripts/persona-icon.mjs +++ b/scripts/persona-icon.mjs @@ -15,9 +15,12 @@ // 同一個人格永遠得到同一張圖(純函數 of 編號/名字/emoji)。 import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import zlib from "node:zlib"; import crypto from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import * as pl from "./persona-lib.mjs"; export const ICON_SVG = "icon.svg"; @@ -148,7 +151,7 @@ function harmonize(c1, c2, target = 3.2) { // 2) 把他人的美術作品原樣放進存取庫是散布,不是引用。 // 取配色是有依據又不搬運原圖的做法,來源網址與描述會一起記進 config.json 備查。 -export const PALETTE_KEYS = ["hair", "eye", "accent", "secondary", "light"]; +export const PALETTE_KEYS = ["hair", "eye", "accent", "secondary", "light", "skin"]; export function parseHexColor(value) { const m = String(value ?? "").trim().match(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i); @@ -172,13 +175,16 @@ export function parsePalette(raw) { out.eye ??= out.accent; out.secondary ??= out.accent; out.light ??= [246, 240, 236]; + out.skin ??= mix(out.light, [242, 201, 168], 0.65); // 沒指定就用偏暖的膚色 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 } = {}) { +export const STYLES = ["portrait", "badge"]; + +export function iconSpec(slug, { code = null, identity = null, palette = null, style = null } = {}) { const ident = identity || pl.identityFields(slug); const config = pl.loadConfig(slug); const theCode = code || config.code || slug; @@ -238,6 +244,8 @@ export function iconSpec(slug, { code = null, identity = null, palette = null } dot: dot || ink, ringAlpha, dotAlpha, + // 有調色盤(=看過參考照片)就畫人物形象;沒有的話只能畫徽章 + style: style || config.icon?.style || (pal ? "portrait" : "badge"), palette: pal, source: pal ? config.icon?.source || null : null, pattern, @@ -273,6 +281,82 @@ function dots(spec) { return out; } +// --------------------------------------------------------------------------- // +// 圖形清單:SVG 與柵格器都從這份清單畫,兩邊不可能長得不一樣 +// --------------------------------------------------------------------------- // + +const shade = (c, t) => mix(c, [0, 0, 0], t); +const tint = (c, t) => mix(c, [255, 255, 255], t); + +const rect = (x, y, w, h, fill, alpha = 1) => ({ type: "rect", x, y, w, h, fill, alpha }); +const ellipse = (cx, cy, rx, ry, fill, alpha = 1) => ({ type: "ellipse", cx, cy, rx, ry, fill, alpha }); +const circle = (cx, cy, r, fill, alpha = 1) => ellipse(cx, cy, r, r, fill, alpha); + +/** + * 人物形象(有臉)。全部用橢圓與矩形拼出來,所以 SVG 與自寫柵格器畫得出一模一樣的結果。 + * 顏色一律來自參考照片萃取的調色盤:髮色、瞳色、服裝色、膚色。 + */ +function portraitShapes(spec) { + const p = spec.palette || {}; + const hair = p.hair || spec.c1; + const eye = p.eye || spec.ring; + const cloth = p.accent || spec.c2; + const cloth2 = p.secondary || cloth; + const skin = p.skin || [244, 214, 187]; + const hairDark = shade(hair, 0.32); + const skinShade = shade(skin, 0.12); + const mouth = shade(cloth, 0.25); + + return [ + // 肩膀與衣服(露出一截,看得出服裝主色) + ellipse(0.5, 1.18, 0.47, 0.36, cloth), + ellipse(0.5, 1.14, 0.175, 0.22, cloth2), // 領口/胸前配色 + // 脖子 + rect(0.442, 0.64, 0.116, 0.14, skinShade), + // 後髮(長髮往下鋪到兩側) + ellipse(0.5, 0.50, 0.315, 0.395, hairDark), + ellipse(0.235, 0.78, 0.080, 0.27, hairDark), + ellipse(0.765, 0.78, 0.080, 0.27, hairDark), + // 臉 + ellipse(0.5, 0.505, 0.212, 0.248, skin), + // 瀏海:中央一大片 + 兩側鬢髮,蓋住額頭 + ellipse(0.5, 0.335, 0.238, 0.158, hair), + ellipse(0.312, 0.435, 0.072, 0.155, hair), + ellipse(0.688, 0.435, 0.072, 0.155, hair), + // 眉毛 + rect(0.362, 0.452, 0.095, 0.015, hairDark), + rect(0.543, 0.452, 0.095, 0.015, hairDark), + // 眼睛:眼白 → 虹膜 → 瞳孔 → 高光 + ellipse(0.415, 0.538, 0.060, 0.073, [252, 252, 255]), + ellipse(0.585, 0.538, 0.060, 0.073, [252, 252, 255]), + ellipse(0.415, 0.543, 0.046, 0.059, eye), + ellipse(0.585, 0.543, 0.046, 0.059, eye), + ellipse(0.415, 0.549, 0.021, 0.030, shade(eye, 0.7)), + ellipse(0.585, 0.549, 0.021, 0.030, shade(eye, 0.7)), + circle(0.399, 0.520, 0.015, [255, 255, 255]), + circle(0.569, 0.520, 0.015, [255, 255, 255]), + // 腮紅與嘴 + ellipse(0.330, 0.598, 0.048, 0.025, cloth, 0.28), + ellipse(0.670, 0.598, 0.048, 0.025, cloth, 0.28), + ellipse(0.5, 0.626, 0.026, 0.015, mouth), + // 側邊髮飾(用第二配色,讓不同角色更好分辨) + ellipse(0.762, 0.345, 0.056, 0.038, cloth2), + ellipse(0.800, 0.375, 0.030, 0.052, cloth2), + ]; +} + +/** 徽章樣式(沒有參考照片時):點陣紋 + 編號字母。 */ +function badgeShapes(spec) { + return [ + ...dots(spec).map((d) => circle(d.cx, d.cy, d.r, spec.dot, spec.dotAlpha)), + ...glyphRects(spec).map((r) => rect(r.x, r.y, r.w, r.h, spec.ink)), + ]; +} + +export function iconShapes(spec) { + return spec.style === "portrait" ? portraitShapes(spec) : badgeShapes(spec); +} + /** 回傳字母的所有方塊(單位座標)。 */ function glyphRects(spec) { const cell = GEO.glyphCell; @@ -309,13 +393,18 @@ export function renderSvg(spec, size = DEFAULT_SIZE) { ` `, ` `, " ", + ` `, + ` `, + " ", " ", ` `, - ` `, - ...dots(spec).map((d) => ` `), - " ", - ` `, - ...glyphRects(spec).map((r) => ` `), + ` `, + ...iconShapes(spec).map((sh) => + sh.type === "rect" + ? ` ` + : ` `), " ", ` = sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h; + } else { + const dx = (u - sh.cx) / sh.rx; + const dy = (v - sh.cy) / sh.ry; + hit = dx * dx + dy * dy <= 1; } + if (!hit) continue; + const a = sh.alpha; + r += (sh.fill[0] - r) * a; + g += (sh.fill[1] - g) * a; + b += (sh.fill[2] - b) * a; } // 邊框:與 SVG 的 stroke 完全相同的環帶 //(SVG 的描邊以 0.012 為中心、寬 0.012 → 涵蓋 0.006~0.018) @@ -438,15 +529,6 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { g += (g2 - g) * spec.ringAlpha; b += (g3 - b) * spec.ringAlpha; } - // 字:實心方塊 - for (const rc of rects) { - if (u >= rc.x && u < rc.x + rc.w && v >= rc.y && v < rc.y + rc.h) { - r = i1; - g = i2; - b = i3; - break; - } - } acc[o] = Math.round(r); acc[o + 1] = Math.round(g); acc[o + 2] = Math.round(b); @@ -489,10 +571,33 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { // 產生並寫檔 // --------------------------------------------------------------------------- // -export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = null, source = null } = {}) { - const spec = iconSpec(slug, { code, palette }); - const svg = renderSvg(spec, size); - const png = renderPng(spec, size); +/** + * 產生圖示。有 `photo` 且工具齊全 → 用真實照片裁臉;否則畫向量人物形象(一樣有臉)。 + * 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。 + */ +export function generateIcon(slug, { + size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, + photo = null, pick = null, face = null, +} = {}) { + const spec = iconSpec(slug, { code, palette, style }); + let svg; + let png; + let photoResult = null; + if (photo) { + const tmp = path.join(pl.personaDir(slug), ".sync", "portrait.tmp.png"); + fs.mkdirSync(path.dirname(tmp), { recursive: true }); + photoResult = renderPhotoPng(photo, tmp, size, { pick, face }); + if (photoResult.ok) { + png = fs.readFileSync(tmp); + svg = photoSvg(spec, png, size); + spec.style = "photo"; + } + fs.rmSync(tmp, { force: true }); + } + if (!png) { + svg = renderSvg(spec, size); + png = renderPng(spec, size); + } pl.writeText(iconSvgPath(slug), svg); fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); fs.writeFileSync(iconPngPath(slug), png); @@ -501,10 +606,15 @@ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = config.icon = { ...(config.icon || {}), letters: spec.letters, + style: spec.style, size, generated_at: pl.nowIso(), palette: spec.palette ? paletteToString(spec.palette) : null, source: source || spec.source || null, + crop: photoResult?.ok + ? { method: photoResult.info.method, face: photoResult.info.face, + box: photoResult.info.box, faces_found: photoResult.info.faces_found } + : null, }; pl.writeJson(pl.configPath(slug), config); return { @@ -512,8 +622,184 @@ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = svg: iconSvgPath(slug), png: iconPngPath(slug), size, + photo: photoResult, bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length }, }; } export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug)); + +// --------------------------------------------------------------------------- // +// 照片裁臉(選用的加值路徑) +// --------------------------------------------------------------------------- // +// +// 本體零依賴,但如果環境裡有 Pillow(+可選的 OpenCV 動漫臉偵測), +// 圖示就能直接用真實照片裁出的臉。缺工具時 `toolReport()` 會給出安裝指令。 + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const PORTRAIT_PY = path.join(HERE, "portrait.py"); +export const CASCADE_URL = + "https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml"; + +export function cascadePath() { + if (process.env.PERSONA_ANIME_CASCADE) return process.env.PERSONA_ANIME_CASCADE; + return path.join(os.homedir(), ".cache", "jsc-persona", "lbpcascade_animeface.xml"); +} + +/** 找一個能用的 python(優先吃 PERSONA_PYTHON,其次 venv,最後系統 python3)。 */ +export function pythonPath() { + const candidates = [ + process.env.PERSONA_PYTHON, + path.join(os.homedir(), ".cache", "jsc-persona", "venv", "bin", "python3"), + "python3", + ].filter(Boolean); + for (const bin of candidates) { + const probe = spawnSync(bin, ["-c", "import sys;print(sys.version_info[0])"], { encoding: "utf8" }); + if (probe.status === 0) return bin; + } + return null; +} + +const pyHas = (bin, mod) => + spawnSync(bin, ["-c", `import ${mod}`], { encoding: "utf8" }).status === 0; + +/** 目前有哪些工具、缺什麼、怎麼補。 */ +export function toolReport() { + const python = pythonPath(); + const pillow = Boolean(python) && pyHas(python, "PIL"); + const cv2 = Boolean(python) && pyHas(python, "cv2"); + const cascade = fs.existsSync(cascadePath()); + const venv = path.join(os.homedir(), ".cache", "jsc-persona", "venv"); + const missing = []; + if (!python) { + missing.push({ + what: "python3", + why: "解碼照片(JPEG/WebP/PNG)與裁切都靠它", + how: "sudo apt-get install -y python3 python3-venv", + }); + } + if (!pillow) { + missing.push({ + what: "Pillow", + why: "沒有它就無法把照片解碼成像素,也就無法裁臉", + how: `python3 -m venv ${venv} && ${venv}/bin/pip install -q pillow`, + }); + } + if (!cv2) { + missing.push({ + what: "OpenCV(opencv-python-headless)", + why: "自動找出臉的位置;沒有它就只能用「上方中央」的經驗法則裁切", + how: `${venv}/bin/pip install -q opencv-python-headless`, + }); + } + if (!cascade) { + missing.push({ + what: "動漫臉偵測模型 lbpcascade_animeface.xml", + why: "OpenCV 內建的模型認不出動漫臉,要這個才準", + how: + `mkdir -p ${path.dirname(cascadePath())} && curl -sL -o ${cascadePath()} ${CASCADE_URL}`, + }); + } + return { + python, + pillow, + cv2, + cascade, + ready: Boolean(python && pillow), + faceDetection: Boolean(python && pillow && cv2), + animeFaceDetection: Boolean(python && pillow && cv2 && cascade), + missing, + venv, + }; +} + +/** 缺工具時要印給使用者看的提示(一行 what/why,一行指令)。 */ +export function installHintLines(report) { + if (!report.missing.length) return []; + const lines = [" ⚠ 少了這些工具,暫時無法用真實照片裁臉(先用向量人物形象代替):"]; + for (const m of report.missing) { + lines.push(` • ${m.what} — ${m.why}`); + lines.push(` ${m.how}`); + } + lines.push(" 裝好之後重跑 `icon generate --photo <圖片> --force` 就會換成照片裁臉版。"); + return lines; +} + +/** 取得參考照片:URL 就抓下來,本機路徑就直接用。 */ +export async function fetchPhoto(source, destDir) { + if (/^https?:\/\//i.test(source)) { + const res = await fetch(source, { headers: { "User-Agent": "jsc-persona/icon" } }); + if (!res.ok) throw new Error(`下載參考照片失敗(HTTP ${res.status}):${source}`); + const buf = Buffer.from(await res.arrayBuffer()); + fs.mkdirSync(destDir, { recursive: true }); + const ext = (source.split("?")[0].match(/\.(png|jpe?g|webp|gif)$/i)?.[1] || "img").toLowerCase(); + const file = path.join(destDir, `reference.${ext}`); + fs.writeFileSync(file, buf); + return file; + } + const abs = path.resolve(source); + if (!fs.existsSync(abs)) throw new Error(`找不到參考照片:${abs}`); + return abs; +} + +/** 列出參考照片裡偵測到的所有臉(多角色的圖要先看這個再挑)。 */ +export function listFaces(imagePath) { + const report = toolReport(); + if (!report.ready) return { ok: false, report }; + const args = [PORTRAIT_PY, "--input", imagePath, "--list"]; + if (report.cascade) args.push("--cascade", cascadePath()); + const proc = spawnSync(report.python, args, { encoding: "utf8" }); + if (proc.status !== 0) return { ok: false, report, reason: String(proc.stderr).slice(0, 300) }; + try { + return { ...JSON.parse(String(proc.stdout).trim().split("\n").pop()), report }; + } catch { + return { ok: false, report, reason: "portrait.py 的輸出不是 JSON" }; + } +} + +/** 用照片裁臉產生 icon.png(成功回傳結果,工具不足回 ok:false 與缺什麼)。 */ +export function renderPhotoPng(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) { + const report = toolReport(); + if (!report.ready) return { ok: false, report }; + const args = [PORTRAIT_PY, "--input", imagePath, "--output", outPath, "--size", String(size), + "--radius", String(GEO.radius)]; + if (report.cascade) args.push("--cascade", cascadePath()); + if (face) args.push("--face", face); + else if (pick) args.push("--pick", pick); + const proc = spawnSync(report.python, args, { encoding: "utf8" }); + if (proc.status !== 0) { + return { ok: false, report, reason: (proc.stderr || proc.stdout || "").trim().slice(0, 300) }; + } + let info = null; + try { + info = JSON.parse(String(proc.stdout).trim().split("\n").pop()); + } catch { + info = null; + } + if (!info?.ok) return { ok: false, report, reason: info?.reason || "portrait.py 沒有回報成功" }; + return { ok: true, report, info }; +} + +/** 把裁好的 PNG 包成自成一體的 SVG(含圓角與瞳色外框)。 */ +export function photoSvg(spec, pngBuffer, size = DEFAULT_SIZE) { + const S = size; + const u = (v) => Math.round(v * S * 1000) / 1000; + return [ + ``, + ` ${spec.code}${spec.name ? ` ${spec.name}` : ""}`, + " ", + ` `, + " ", + ` `, + ` `, + " ", + ` `, + "", + "", + ].join("\n"); +} diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 8c7740c..46a87dc 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -51,7 +51,7 @@ function emit(payload, asJson, lines) { const FLAGS = new Set([ "json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all", "with-journal", "gzip", "record", "load", "allow-repeat", - "if-due", "no-gitea", "public", "rename", + "if-due", "no-gitea", "public", "rename", "from-source", ]); function parseArgs(argv) { @@ -1139,7 +1139,32 @@ commands.icon = async ({ flags, positional }) => { ]); return; } - if (action !== "generate") die(`未知 action:${action}(可用 generate/show)`); + if (action === "faces") { + requireMember(slug, session, Boolean(flags["as-guest"])); + const want = str(flags.photo); + if (!want) die("需要 `--photo <圖片路徑或網址>`。"); + let file; + try { + file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); + } catch (err) { + die(err.message); + } + const found = ic.listFaces(file); + if (!found.ok) { + emit(found, flags.json, [ + `\u2716 \u7121\u6cd5\u5075\u6e2c\u81c9\uff1a${found.reason || "\u5de5\u5177\u4e0d\u8db3"}`, + ...ic.installHintLines(found.report || ic.toolReport()), + ]); + return; + } + emit(found, flags.json, [ + `\u53c3\u8003\u7167\u7247 ${found.size[0]}\u00d7${found.size[1]}\uff5c\u5075\u6e2c\u65b9\u5f0f ${found.method || "\uff08\u7121\uff09"}\uff5c\u627e\u5230 ${found.faces.length} \u5f35\u81c9\uff1a`, + ...found.faces.map((f) => ` #${f.index} ${f.w}\u00d7${f.h} @(${f.x},${f.y})\u3000\u4e2d\u5fc3 ${f.center.join(",")}`), + " \u591a\u89d2\u8272\u7684\u5716\u8acb\u5148\u770b\u904e\u539f\u5716\u518d\u6311\uff1a`icon generate --photo <\u5716> --pick <\u7d22\u5f15>`\uff08\u6216 `--face x,y,w,h`\uff09\u3002", + ]); + return; + } + if (action !== "generate") die(`\u672a\u77e5 action\uff1a${action}\uff08\u53ef\u7528 generate/show/faces\uff09`); requireOwner(slug, session); if (ic.hasIcon(slug) && !flags.force) { die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`); @@ -1166,17 +1191,42 @@ commands.icon = async ({ flags, positional }) => { date: str(flags["source-date"]) || pl.nowIso().slice(0, 10), } : null; - const res = ic.generateIcon(slug, { size, palette, source }); + const style = str(flags.style) || null; + if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`); + // 參考照片:給了就試著裁臉;工具不足會退回向量人物形象並印出安裝指令 + let photo = null; + if (flags.photo || (flags["from-source"] && source?.url)) { + const want = str(flags.photo) || source.url; + try { + photo = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); + } catch (err) { + die(`${err.message}`); + } + } + const res = ic.generateIcon(slug, { + size, palette, source, style, photo, + pick: str(flags.pick) || null, + face: str(flags.face) || null, + }); const lines = [ `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, - res.spec.palette - ? ` 字母 ${res.spec.letters}|配色取自參考照片:${ic.paletteToString(res.spec.palette)}` - : ` 字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定(同一個人格永遠同一張圖)`, + res.spec.style === "photo" + ? ` 形象圖:真實照片裁臉(偵測方式 ${res.photo?.info?.method},裁切框 ${JSON.stringify(res.photo?.info?.box)})` + : res.spec.style === "portrait" + ? ` 形象圖:向量人物(有臉)|配色取自參考照片:${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}` : ""}`] : []), + // 缺工具就把安裝方式講清楚,而不是默默降級 + ...(photo && res.photo && !res.photo.ok + ? [ + ...(res.photo.reason ? [` ⚠ 照片裁臉失敗:${res.photo.reason}`] : []), + ...ic.installHintLines(res.photo.report || ic.toolReport()), + ] + : []), ]; // 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像 if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { @@ -1340,12 +1390,18 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) 圖示(建立人格並補齊資料後跑): - icon generate|show --session [--size 512 --force --no-gitea] + icon generate|show|faces --session [--size 512 --force --no-gitea] [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."] [--source-url <參考照片網址> --source-note <說明> --source-date ] + [--photo <圖片路徑或網址> | --from-source] [--style portrait|badge] + [--pick largest|leftmost|rightmost|<索引> | --face x,y,w,h] + faces 會列出參考照片裡偵測到的臉——多角色的圖務必先看過再用 --pick 指定。 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 - 沒帶 --palette → 配色由編號/名字/emoji 雜湊而來。 - 帶了 --palette → 用「你實際看過的參考照片」萃取的顏色(必須同時帶 --source-url 存證)。 + 沒帶 --palette → 徽章樣式,配色由編號/名字/emoji 雜湊而來。 + 帶了 --palette → 向量人物形象(有臉),配色取自「你實際看過的參考照片」 + (必須同時帶 --source-url 存證)。 + 帶了 --photo → 直接用那張照片裁出臉當形象圖;工具不足時會退回向量人物形象 + 並印出安裝指令(Pillow / OpenCV / 動漫臉模型)。 編號與 Gitea(存取庫名稱 = 人格編號): code show|assign|next --session [--romaji <英文名> --code --rename --force --public] diff --git a/scripts/portrait.py b/scripts/portrait.py new file mode 100644 index 0000000..1abb910 --- /dev/null +++ b/scripts/portrait.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""從參考照片裁出人物臉部,輸出成人格圖示用的正方形 PNG。 + +這支腳本是**選用的加值工具**:jsc-persona 本體只用 Node 內建模組,沒有它照樣能產生 +向量人物形象。裝了 Pillow(+可選的 OpenCV 動漫臉偵測)之後,圖示就能改用真實照片裁臉。 + +用法: + python3 portrait.py --input <圖片> --list # 列出偵測到的所有臉 + python3 portrait.py --input <圖片> --output [--size 512] + [--cascade ] [--pick largest|leftmost|rightmost|] + [--face x,y,w,h] # 直接指定裁切框 + +多角色的圖片一定要挑臉:`--list` 看有哪些,再用 `--pick` 或 `--face` 指定。 +輸出(stdout):一行 JSON。失敗時 ok=false,並附上 hint。 +""" + +import argparse +import json +import os +import sys + + +def emit(payload): + print(json.dumps(payload, ensure_ascii=False)) + sys.exit(0) + + +def fail(reason, hint=None): + emit({"ok": False, "reason": reason, "hint": hint}) + + +def detect_faces(path, cascade_path): + """回傳 (faces, method);faces 是 [(x, y, w, h), ...],偵測不到就回 ([], None)。""" + try: + import cv2 + except ImportError: + return [], None + + candidates = [] + if cascade_path and os.path.exists(cascade_path): + candidates.append((cascade_path, "anime-cascade")) + builtin = getattr(getattr(cv2, "data", None), "haarcascades", "") + if builtin: + frontal = os.path.join(builtin, "haarcascade_frontalface_default.xml") + if os.path.exists(frontal): + candidates.append((frontal, "frontal-cascade")) + + try: + image = cv2.imread(path) + if image is None: + return [], None + gray = cv2.equalizeHist(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)) + except Exception: + return [], None + + # 由嚴到寬試幾組參數:先求準,找不到才放寬 + ladder = [(1.05, 5, 40), (1.05, 3, 32), (1.02, 2, 24)] + for xml, method in candidates: + try: + clf = cv2.CascadeClassifier(xml) + if clf.empty(): + continue + except Exception: + continue + for sf, mn, ms in ladder: + try: + faces = clf.detectMultiScale(gray, scaleFactor=sf, minNeighbors=mn, minSize=(ms, ms)) + except Exception: + continue + if len(faces): + return [tuple(int(v) for v in f) for f in faces], method + return [], None + + +def choose(faces, pick): + if not faces: + return None + if pick is None or pick == "largest": + return max(faces, key=lambda f: f[2] * f[3]) + if pick == "leftmost": + return min(faces, key=lambda f: f[0]) + if pick == "rightmost": + return max(faces, key=lambda f: f[0] + f[2]) + if pick == "topmost": + return min(faces, key=lambda f: f[1]) + try: + return sorted(faces, key=lambda f: f[0])[int(pick)] + except (ValueError, IndexError): + return None + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--input", required=True) + ap.add_argument("--output") + ap.add_argument("--size", type=int, default=512) + ap.add_argument("--cascade", default=None) + ap.add_argument("--radius", type=float, default=0.22, help="圓角半徑(佔邊長比例)") + ap.add_argument("--pick", default=None, help="largest|leftmost|rightmost|topmost|<由左至右的索引>") + ap.add_argument("--face", default=None, help="直接指定臉的框 x,y,w,h") + ap.add_argument("--list", action="store_true", help="只列出偵測到的臉,不輸出圖") + args = ap.parse_args() + + try: + from PIL import Image, ImageDraw + except ImportError: + fail("缺少 Pillow", "pip install pillow") + + try: + img = Image.open(args.input).convert("RGB") + except Exception as exc: + fail(f"讀不到圖片:{exc}", "確認檔案完整;WebP 需要較新的 Pillow") + + W, H = img.size + faces, method = detect_faces(args.input, args.cascade) + + if args.list: + emit({ + "ok": True, + "size": [W, H], + "method": method, + "faces": [ + {"index": i, "x": f[0], "y": f[1], "w": f[2], "h": f[3], + "center": [f[0] + f[2] // 2, f[1] + f[3] // 2]} + for i, f in enumerate(sorted(faces, key=lambda f: f[0])) + ], + }) + + if not args.output: + fail("需要 --output(或用 --list 只看偵測結果)") + + if args.face: + try: + x, y, w, h = (int(v) for v in args.face.split(",")) + except ValueError: + fail("--face 格式要是 x,y,w,h") + method = "manual-box" + else: + chosen = choose(faces, args.pick) + if chosen: + x, y, w, h = chosen + else: + method = "heuristic-top-center" + side = min(W, H) + x, y, w, h = int(W / 2 - side * 0.25), int(min(H / 2, side * 0.30) - side * 0.25), \ + int(side * 0.5), int(side * 0.5) + + # 往外留邊,讓頭髮與肩膀進來一點,構圖才像頭像 + pad = max(w, h) * 0.55 + cx, cy = x + w / 2, y + h / 2 - h * 0.06 + side = min(max(w, h) + pad * 2, min(W, H)) + left = int(max(0, min(W - side, cx - side / 2))) + top = int(max(0, min(H - side, cy - side / 2))) + box = (left, top, int(left + side), int(top + side)) + + face = img.crop(box).resize((args.size, args.size), Image.LANCZOS).convert("RGBA") + radius = int(args.size * args.radius) + mask = Image.new("L", (args.size, args.size), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, args.size - 1, args.size - 1], radius=radius, fill=255) + face.putalpha(mask) + face.save(args.output, "PNG", optimize=True) + + emit({ + "ok": True, + "method": method, + "faces_found": len(faces), + "source_size": [W, H], + "face": [x, y, w, h], + "box": list(box), + "size": args.size, + "output": args.output, + }) + + +if __name__ == "__main__": + main() diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 32ee996..2990a46 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -541,7 +541,7 @@ 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 && + return icon.palette.startsWith(PAL) && icon.source?.url === SRC && icon.source?.note === "測試用主視覺" && icon.source?.date === "2026-07-30"; })(), JSON.stringify(pl.loadConfig("GAMMA-01").icon)); check("照片配色與雜湊配色畫出來不一樣", (() => { @@ -553,7 +553,7 @@ check("照片配色與雜湊配色畫出來不一樣", (() => { 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; + return again.palette !== null && ic.paletteToString(again.palette).startsWith(PAL); })()); check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", (() => { // 藍黑髮 + 淡粉洋裝:不收斂的話不論黑字白字都會有一端糊掉 @@ -568,6 +568,53 @@ check("一深一淺的極端配色仍保證字讀得到(會收斂色階)", ( return Math.min(ratio(spec.ink, spec.c1), ratio(spec.ink, spec.c2)) >= 3; })()); +console.log("⑯ 人物形象圖(有臉)與照片裁臉工具"); +const facePal = ic.parsePalette("hair=#1b1b22,eye=#6b4a2f,accent=#f2b6cb,secondary=#4a7bc8,light=#fbeff3"); +const faceSpec = ic.iconSpec("GAMMA-01", { palette: facePal, style: "portrait" }); +check("有調色盤時預設畫「人物形象」而不是徽章", + ic.iconSpec("GAMMA-01", { palette: facePal }).style === "portrait" && + ic.iconSpec("alpha").style !== "portrait", ic.iconSpec("alpha").style); +check("形象圖真的畫了五官(眼白/虹膜/瞳孔/嘴都在)", (() => { + const shapes = ic.iconShapes(faceSpec); + const eyeWhite = shapes.filter((sh) => sh.fill.join() === "252,252,255").length; + const iris = shapes.filter((sh) => sh.fill.join() === facePal.eye.join()).length; + const highlight = shapes.filter((sh) => sh.fill.join() === "255,255,255").length; + return shapes.length >= 20 && eyeWhite === 2 && iris === 2 && highlight === 2; +})(), `圖形數 ${ic.iconShapes(faceSpec).length}`); +check("形象圖用的是照片配色(髮色當底、瞳色當眼睛)", + faceSpec.c1.join() === facePal.hair.join() || faceSpec.c2.join() !== faceSpec.c1.join()); +check("SVG 與 PNG 出自同一份圖形清單(SVG 有對應數量的 ellipse)", (() => { + const svg = ic.renderSvg(faceSpec, 64); + const shapes = ic.iconShapes(faceSpec); + const ellipses = (svg.match(/ sh.type === "ellipse").length && + rects >= shapes.filter((sh) => sh.type === "rect").length; +})()); +check("--style 可以強制畫回徽章", + ic.iconSpec("GAMMA-01", { palette: facePal, style: "badge" }).style === "badge"); +const tools = ic.toolReport(); +check("工具偵測會回報缺什麼與怎麼裝", + typeof tools.ready === "boolean" && Array.isArray(tools.missing) && + tools.missing.every((m) => m.what && m.why && m.how)); +check("缺工具時的提示含安裝指令", (() => { + const fake = { missing: [{ what: "Pillow", why: "解碼照片", how: "pip install pillow" }] }; + const lines = ic.installHintLines(fake); + return lines.length >= 3 && lines.join("\n").includes("pip install pillow"); +})()); +check("工具齊全時提示為空", ic.installHintLines({ missing: [] }).length === 0); +check("`icon faces` 需要 --photo", + cli(["icon", "faces", "--session", S_CODE], { expectOk: false }).status !== 0); +check("形象圖同步到 Wiki 區(svg 與 png 都在)", + covered("icon.svg")[0] === "wiki" && covered("icon.png")[0] === "wiki"); +check("Wiki 有專頁保存形象圖,並寫明來源", (() => { + const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01"); + return page.includes("形象圖") && page.includes("icon.svg") && page.includes("icon.png") && + page.includes("參考來源") && page.includes("https://example.invalid/key-visual.png"); +})(), gt.wikiIconPage("GAMMA-01", "GAMMA-01").slice(0, 120)); +check("Wiki 的保留檔不會被同步流程刪掉", + ["Home.md", "Icon.md"].every((f) => gt.wikiHome && typeof gt.wikiIconPage === "function")); + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-icon/SKILL.md b/skills/persona-icon/SKILL.md index 2990713..6df60b9 100644 --- a/skills/persona-icon/SKILL.md +++ b/skills/persona-icon/SKILL.md @@ -1,12 +1,13 @@ --- name: persona-icon -description: 產生或更新人格的圖示(icon.svg + icon.png):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖後萃取髮色/瞳色/服裝色,再用這組配色繪製圖示,並記下來源網址備查;找不到參考圖時退回用編號雜湊配色。當使用者說要做人格頭像/圖示/icon、要依角色最新造型更新圖示、說現在的圖示不像、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 +description: 產生或更新人格的形象圖(icon.svg + icon.png,盡量看得到臉):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖,接著優先用臉部偵測從圖上裁出臉當頭像;缺少裁圖工具時改畫有五官的向量人物形象並提示如何安裝工具,完全沒有參考圖才退回編號徽章。來源網址與裁切框會存進 config 備查,圖示同時同步到 Gitea Wiki 與存取庫頭像。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說現在的圖示不像或看不到臉、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 --- -# 🎨 persona-icon — 依最新造型產生人格圖示 +# 🎨 persona-icon — 依最新造型產生人格形象圖(有臉) **CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `) -**時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——配色與字母綁在最終身分上。 +**時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——形象綁在最終身分上。 +**目標**:圖示要是**人物形象圖,盡量看得到臉**;退而求其次才是抽象徽章。 --- @@ -57,7 +58,38 @@ description: 產生或更新人格的圖示(icon.svg + icon.png):先上網 | `secondary` | 服裝的第二色/配件色 | | `light` | 最亮的部位(白袖、皮膚亮面) | -### 4. 繪製 +### 4. 裁出臉(有工具的話,這是首選) + +**圖示必須是人物形象,盡量看得到臉。** 環境裡如果有 Pillow(+OpenCV 動漫臉模型), +就直接從參考圖裁臉: + +```bash +# 先看看圖裡有幾張臉——多角色的主視覺一定要先看 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon faces --session --photo "<圖片網址或路徑>" + +# 挑一張(索引由左到右;也可以用 largest/leftmost/rightmost,或 --face x,y,w,h 自己框) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force \ + --photo "<圖片網址或路徑>" --pick <索引> \ + --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.." \ + --source-url "<那張圖的網址>" --source-note "<作品(年份)+造型描述>" +``` + +**多角色的圖務必自己看過再挑**:`icon faces` 只會告訴你座標,哪張臉是本人要由你判斷 +(把裁出來的圖用 Read 打開確認一次最保險)。挑錯就是別人的臉。 + +工具不齊時 CLI 會**印出安裝指令**並自動退回第 5 步的向量形象——不會靜默失敗。 +需要的工具: + +| 工具 | 做什麼 | 怎麼裝(免 sudo) | +| --- | --- | --- | +| Pillow | 解碼照片、裁切、縮放 | `python3 -m venv ~/.cache/jsc-persona/venv && ~/.cache/jsc-persona/venv/bin/pip install pillow` | +| OpenCV 4.x | 自動找臉的位置 | `~/.cache/jsc-persona/venv/bin/pip install "opencv-python-headless<5"` | +| 動漫臉模型 | OpenCV 內建模型認不出動漫臉 | `curl -sL -o ~/.cache/jsc-persona/lbpcascade_animeface.xml https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml` | + +> OpenCV **5.x 拿掉了 `CascadeClassifier`**,一定要裝 4.x。 +> venv 放在 `~/.cache/jsc-persona/venv` 會被自動偵測到;也可以用 `PERSONA_PYTHON` 指定別的。 + +### 5. 沒有工具時:向量人物形象(一樣有臉) ```bash node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ @@ -70,37 +102,40 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ - **`--palette` 一定要配 `--source-url`**:配色是從哪張圖來的必須留得下來(CLI 會強制)。 - 兩者都會寫進 `state/config.json` 的 `icon` 欄位,之後 `icon show` 查得到。 -- 產出的 `icon.svg` + `icon.png` 會自動設成 Gitea 存取庫頭像並同步到 Wiki 區。 +- 產出的 `icon.svg` + `icon.png` 會自動設成 Gitea 存取庫頭像、同步到 Wiki 區, + 並在 Wiki 產生一頁 **Icon**(同時展示 SVG 與 PNG,附上來源網址與裁切框)。 找不到可靠的參考圖 → **不要硬掰**,直接 `icon generate` 不帶 `--palette`, 用編號雜湊出的配色,並告訴使用者「沒找到最新造型的官方圖,先用預設配色」。 -### 5. 回報 +### 6. 回報 一句話說明:用了哪部作品哪一年的視覺、取到什麼配色、圖示放哪裡。 把來源網址附上,讓使用者可以自己核對。 -## 圖示長什麼樣 +## 三種樣式 -512×512 圓角徽章:**髮色 → 服裝主色**的對角漸層、**瞳色**外框、**亮色**點陣紋、 -中央是編號前兩個字母(`ASUNA-01` → `AS`)。 +| 樣式 | 什麼時候用 | 長什麼樣 | +| --- | --- | --- | +| `photo` | 有參考圖 **且**工具齊全(最佳) | 從官方視覺**裁出的臉**,圓角+瞳色外框 | +| `portrait` | 有參考圖但沒工具 | 向量人物形象:髮型、瞳色、服裝色都取自照片,**有五官** | +| `badge` | 完全沒有參考圖(原創人格) | 雙色漸層徽章 + 編號前兩個字母 | -- 兩個顏色一深一淺時(例:藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓, +- `portrait` 與 `badge` 由 Node 自己柵格化,SVG 與 PNG **共用同一份圖形清單**,兩邊必然一致; + PNG 是自寫的柵格器 + zlib 編碼,不依賴任何影像函式庫。 +- `photo` 的 SVG 會把裁好的 PNG 以 base64 內嵌,一樣自成一體(不外連)。 +- `badge` 樣式下若兩個顏色一深一淺(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓, 確保字讀得到——顏色仍然是照片來的,只是收斂色階。 -- SVG 與 PNG 是**同一張圖**;PNG 由自寫的柵格器產生,不依賴任何影像函式庫。 -## 為什麼不是把照片本身當圖示 +## 界線 -1. **技術上**:環境裡沒有任何影像解碼器(rsvg/imagemagick/Pillow 都沒有), - JPEG/WebP 讀不進來,無法把原圖轉成 PNG。 -2. **權利上**:把他人的美術作品原樣放進存取庫是散布,不是引用。 - -取配色是有依據、又不搬運原圖的做法。使用者若堅持要用原圖當頭像, -請他自己把圖片上傳到 Gitea 的存取庫設定頁。 +裁出來的是**角色臉部的縮圖**,用途是人格身分辨識(頭像)。原始美術作品的權利屬於原作者, +存取庫預設私有;不要把它當成可以散布的素材,也不要重製整張原圖。 ## 邊界 - 只能對「本 session 已載入的人格」產生圖示。 - 已有圖示時要重畫必須加 `--force`。 - 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。 +- `.sync/reference.*` 是下載回來的參考圖快取,可以安全刪除。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 From d0c42f0c150508bf1daea56de43892b8bec38fa7 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 02:57:18 +0000 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E5=BD=A2=E8=B1=A1=E5=9C=96?= =?UTF-8?q?=E6=94=B9=E7=82=BA=E3=80=8C=E4=BE=9D=E4=BA=BA=E6=A0=BC=E8=B3=87?= =?UTF-8?q?=E6=96=99=E9=87=8D=E7=B9=AA=E3=80=8D=EF=BC=8C=E4=B8=A6=E9=A9=97?= =?UTF-8?q?=E8=AD=89=20Wiki=20=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 不再直接使用網路上找到的圖片 舊版把裁下來的官方美術當圖示(photo 樣式)。現在改成: 找圖 → `icon headshot` 裁出**大頭照當底稿** → AI 用 Read 親眼看過 → 讀出髮型/瀏海/眼型/表情/髮飾/領口等特徵 → **由本工具重新繪製**。 * 底稿寫在 <人格>/.sync/headshot.png,**不是圖示、不同步、不發佈**。 * 產出的 SVG 不得有 /base64/外連(selftest 會擋)。 * 移除 photo 樣式與 photoSvg;config 裡殘留的舊樣式會被忽略而非退回徽章。 2. 重繪引擎:五官與造型可參數化 新增 --features:hairstyle(5) / length(4) / fringe(4) / eyes(4) / expression(4) / accessory(5) / side / collar(4) / ahoge。 渲染器新增 polygon 圖元(呆毛、緞帶、V 領、銳利眼角),SVG 與自寫柵格器 仍共用同一份圖形清單,兩邊必然一致。 另外調了臉部比例:眉毛用「髮色偏膚色」避免深髮角色眉毛與瀏海連成黑帶、 加了鼻子(否則嘴會被看成鼻子)、眼與嘴的縱向配置重排。 3. Wiki 形象圖必須同步(並且會被驗證) 新增 `sync verify`(不一致以非零結束)與 verifyIconInWiki(); `icon generate` 推完 Wiki 會自動回頭確認 icon.svg + icon.png 真的在遠端 且與本機一致。 修掉一個會讓驗證永遠失敗的 bug:Wiki 首頁內嵌了 `最後同步 ${now}`, 每次產生都不同 → 永遠 dirty、每次 push 都多一個 commit。改用圖示的 generated_at。porcelain 解析也從固定位移改為正規式。 已重繪兩個真實人格(皆為 portrait 樣式,並通過 Wiki 同步驗證): ASUNA-01 底稿=《Unanswered//butterfly》(2026) 主視覺左側;金蜜色極長直髮、 中分瀏海、呆毛、紅褐杏眼、沉靜神情、紅上衣 V 領 YUI-01 底稿=AniList 官方角色圖;藍黑極長直髮、齊瀏海、圓大棕眼、燦爛笑容, 髮飾與配色採現行 Unital Ring 導航妖精造型(淡粉洋裝、藍花) selftest 161 項全綠(新增第 ⑰ 節:特徵解析、換髮型/眼型/表情會畫出不同的圖、 polygon 兩邊一致、舊樣式不污染、SVG 無內嵌影像、Wiki 首頁無時間戳)。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 15 +- README.md | 43 +++-- plugin.json | 2 +- scripts/persona-gitea.mjs | 52 +++++- scripts/persona-icon.mjs | 325 ++++++++++++++++++++++++----------- scripts/persona.mjs | 131 +++++++++----- scripts/selftest.mjs | 63 +++++++ skills/persona-icon/SKILL.md | 89 ++++++---- 10 files changed, 527 insertions(+), 197 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8e3c725..53fb8aa 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.6", + "version": "0.0.7", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 12534aa..158b058 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.6", + "version": "0.0.7", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index c99307d..42e9ae4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,15 +34,18 @@ **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 -12. **圖示必須是人物形象圖(盡量有臉)**,來源是該人格「最新一次登場」的官方視覺 - (`/jsc-persona:persona-icon`):上網找圖 → **用 Read 親眼看過** → 有 Pillow+OpenCV 就 - `--photo` 裁臉,否則畫有五官的向量人物;配色一律 `--palette` 搭配 `--source-url`(CLI 強制存證)。 - **沒看過圖就不准填顏色、也不准亂挑臉**(多角色的圖先 `icon faces` 再挑,挑完打開確認)。 +12. **圖示必須是重新繪製的人物形象圖(有臉)**:上網找出該人格「最新一次登場」的官方視覺 → + `icon headshot` 裁出**大頭照當底稿** → **用 Read 親眼看過** → 讀出髮型/瀏海/眼型/表情/ + 髮飾等特徵 → `icon generate --palette ... --features ...` **重新繪製**。 + **絕對不要把找到的圖片直接當圖示**(底稿只留在 `.sync/`,不同步、不發佈; + 產出的 SVG 不得有 ``/base64/外連)。 + **沒看過圖就不准填顏色或特徵、也不准亂挑臉**(多角色先 `icon faces` 再挑,挑完打開確認)。 13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。 plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 -14. **Wiki 必須保存形象圖**:`icon.svg` 與 `icon.png` 都在 Wiki 區,另有自動產生的 **Icon** 頁 - 展示兩種格式並列出來源、樣式與裁切框。 +14. **Wiki 必須保存並同步形象圖**:`icon.svg` 與 `icon.png` 都在 Wiki 區,另有自動產生的 + **Icon** 頁展示兩種格式與來源。`icon generate` 推完會**回頭驗證**,`sync verify` 可隨時檢查。 + Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。 ## 慣例 diff --git a/README.md b/README.md index cf779e6..0931f2e 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | -| **12. 圖示是人物形象圖,取自最新登場的官方視覺** | `/jsc-persona:persona-icon` 上網找出該人格**最新一次登場**的官方視覺、**親眼看過**,有 Pillow+OpenCV 就直接**裁出臉**當形象圖;沒工具則畫**有五官的向量人物**(配色同樣取自那張圖),並印出安裝指令。來源網址與裁切框存進 `config.json` 備查 | -| **13. Wiki 保存形象圖** | `icon.svg` 與 `icon.png` 一起同步到 Wiki 區,另外自動產生一頁 **Icon** 同時展示兩種格式並列出來源、樣式與裁切框;PNG 同時設為存取庫頭像 | +| **12. 圖示是重新繪製的人物形象圖(有臉)** | 上網找出該人格**最新一次登場**的官方視覺 → 裁出**大頭照當底稿** → **親眼看過** → 讀出髮型/瀏海/眼型/表情/髮飾等特徵 → **由本工具依人格資料重新繪製**。**絕不把找到的圖片當圖示**:SVG 裡沒有 ``、沒有 base64、沒有外連,底稿只留在 `.sync/` 不同步。來源網址與重繪依據存進 `config.json` 備查 | +| **13. Wiki 必須保存並同步形象圖** | `icon.svg` 與 `icon.png` 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示兩種格式與來源;`icon generate` 推完會**回頭驗證** Wiki 真的有這兩個檔案且與本機一致,另有 `sync verify` 可隨時檢查(不一致以非零結束)| --- @@ -150,13 +150,23 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 建立人格**並補齊 IDENTITY/SOUL 之後**產生,512×512。**優先是人物形象圖(看得到臉)**, 依環境有無工具分三種樣式: +流程是 **找圖 → 裁大頭照當底稿 → 親眼看過 → 讀出特徵 → 重新繪製**: + +| 步驟 | 指令 | 產出 | +| --- | --- | --- | +| 1. 找臉 | `icon faces --photo <圖>` | 圖裡所有臉的座標(多角色務必先看) | +| 2. 裁底稿 | `icon headshot --photo <圖> --pick N` | `.sync/headshot.png`(**不是圖示、不同步**) | +| 3. 看 | Read 打開底稿 + 讀 IDENTITY/SOUL | 髮型、瀏海、眼型、表情、髮飾、領口 | +| 4. 重繪 | `icon generate --palette ... --features ...` | `icon.svg` + `icon.png` | + | 樣式 | 條件 | 長什麼樣 | | --- | --- | --- | -| **`photo`** | 有參考圖 **且** 有 Pillow+OpenCV | 從官方視覺**裁出的臉**(自動臉部偵測),圓角+瞳色外框 | -| **`portrait`** | 有參考圖但缺工具 | **有五官的向量人物**:髮型、瞳色、服裝色都取自那張圖 | +| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像,五官俱全 | | **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 | -缺工具時 CLI **會印出安裝指令**再退回 `portrait`,不會靜默降級: +**找到的圖片只能當底稿**:產出的 SVG 沒有 ``、沒有 base64、沒有外連,每個像素都是畫出來的。 + +裁底稿需要的工具缺了,CLI **會印出安裝指令**,不會靜默降級: ```bash python3 -m venv ~/.cache/jsc-persona/venv @@ -187,24 +197,23 @@ curl -sL -o ~/.cache/jsc-persona/lbpcascade_animeface.xml \ - PNG 由**自寫的柵格器**畫出(3× 超取樣 + 盒式縮減當反鋸齒),再用 `zlib` 手工組出 IHDR/IDAT/IEND 與 CRC32。這台機器沒有 rsvg/inkscape/imagemagick,也沒有影像函式庫, 而本專案禁止 npm 依賴——所以就自己畫。 -- **為什麼沒有 emoji、也不是把照片本身當圖示**:環境裡沒有任何影像解碼器 - (rsvg/imagemagick/Pillow/ffmpeg 都沒有),JPEG/WebP 讀不進來,無法把原圖轉成 PNG; - 而且把他人的美術作品原樣放進存取庫是散布、不是引用。**取配色**是有依據又不搬運原圖的做法。 - emoji 同理無法柵格化,但仍參與雜湊配色。 +- **為什麼不直接用找到的圖**:那是別人的美術作品。底稿只用來「看」,圖示由本工具重畫。 +- **為什麼沒有 emoji**:把 emoji 畫進 PNG 需要字型柵格化,環境裡連 emoji 字型都沒有; + emoji 仍參與雜湊配色。 - 圖示屬於低頻資料 → `icon.svg` 與 `icon.png` 都同步到 **Wiki 區**,並自動產生一頁 **Icon** 展示兩種格式與來源;PNG 同時設成 Gitea **存取庫頭像**。 ```bash -# 先看參考圖裡有幾張臉(多角色的主視覺務必先看) -node scripts/persona.mjs icon faces --session --photo "<圖片網址>" +node scripts/persona.mjs icon faces --session --photo "<圖片網址>" +node scripts/persona.mjs icon headshot --session --photo "<圖片網址>" --pick 1 -# 裁臉 + 取色(--palette 必須配 --source-url) node scripts/persona.mjs icon generate --session --force \ - --photo "<圖片網址>" --pick 1 \ - --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \ - --source-url "<官方視覺的網址>" --source-note "<作品(年份)+造型描述>" + --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3,skin=#f7ddc4" \ + --features "hairstyle=straight,length=very-long,fringe=parted,eyes=almond,expression=calm,collar=v,ahoge=yes" \ + --source-url "<底稿那張圖的網址>" --source-note "<作品(年份)+重繪依據>" -node scripts/persona.mjs icon show --session # 看樣式、配色、來源與裁切框 +node scripts/persona.mjs icon show --session # 樣式、配色、特徵、來源 +node scripts/persona.mjs sync verify --session --area wiki # 確認 Wiki 真的同步了 ``` ## 人格編號與 Gitea 儲存 @@ -369,7 +378,7 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 148 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、形象圖與裁臉、hooks +node scripts/selftest.mjs # 161 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、形象圖重繪與 Wiki 同步、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 diff --git a/plugin.json b/plugin.json index f4b76bc..d5c9bc2 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.6", + "version": "0.0.7", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 0f9d6aa..a87e9b6 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -462,7 +462,7 @@ export function wikiHome(slug, code) { .map((k) => `| ${k} | ${ident[k]} |`), `| 長期記憶 | ${longTerm.length} 則 |`, `| 關係人 | ${relations.nodes.length} 位 |`, - `| 最後同步 | ${pl.nowIso()} |`, + `| 圖示更新 | ${pl.loadConfig(slug).icon?.generated_at || "—"} |`, "", "## 頁面", "", @@ -610,6 +610,56 @@ export async function pullArea(slug, area, { code = null, owner = null, force = return { ok: true, area, code: theCode, changed: incoming, written }; } +/** + * 驗證某一區「本機 = 遠端」。 + * push 回報成功不等於遠端真的有東西(網路中斷、權限、非快轉都可能), + * 形象圖這種一定要出現在 Wiki 的檔案更需要一個明確的檢查點。 + */ +export async function verifyArea(slug, area, { code = null, owner = null } = {}) { + const problem = giteaProblem(); + if (problem) return { ok: false, skipped: true, area, reason: problem }; + const theCode = code || personaCode(slug); + if (!theCode) return { ok: false, skipped: true, area, reason: "沒有人格編號" }; + const theOwner = owner || (await resolveOwner()); + const { host } = giteaEnv(); + const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); + if (!git(["fetch", "--quiet", "origin"], dir).ok) { + return { ok: false, area, reason: "fetch 失敗(連不上遠端)" }; + } + const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; + const local = git(["rev-parse", "HEAD"], dir).stdout; + const remote = git(["rev-parse", `origin/${branch}`], dir).stdout; + // 再把工作副本疊上去,看看還有沒有沒推的差異 + if (area === "wiki") { + pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode)); + pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode)); + } + const files = stageArea(slug, area, dir); + const dirty = git(["status", "--porcelain"], dir) + .stdout.split("\n").map((l) => l.replace(/^.{2}\s+/, "").trim()).filter(Boolean); + git(["checkout", "--", "."], dir); + git(["clean", "-qfd"], dir); + return { + ok: Boolean(local) && local === remote && dirty.length === 0, + area, + code: theCode, + files: files.length, + local, + remote, + pending: dirty, + }; +} + +/** 形象圖(SVG + PNG)是不是真的在 Wiki 上、而且和本機一致。 */ +export async function verifyIconInWiki(slug, opts = {}) { + const res = await verifyArea(slug, "wiki", opts); + if (res.skipped || !res.code) return res; + const missing = ["icon.svg", "icon.png"].filter((f) => res.pending.includes(f)); + const dir = syncDir(slug, "wiki"); + const present = ["icon.svg", "icon.png"].filter((f) => fs.existsSync(path.join(dir, f))); + return { ...res, icon_present: present, icon_pending: missing, ok: res.ok && present.length === 2 }; +} + /** 建立 Gitea 上的存取庫與 Wiki,並把兩區都推上去。 */ export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) { const problem = giteaProblem(); diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs index 73fcd8f..8bb4c29 100644 --- a/scripts/persona-icon.mjs +++ b/scripts/persona-icon.mjs @@ -184,7 +184,9 @@ export const paletteToString = (p) => export const STYLES = ["portrait", "badge"]; -export function iconSpec(slug, { code = null, identity = null, palette = null, style = null } = {}) { +export function iconSpec(slug, { + code = null, identity = null, palette = null, style = null, features = null, +} = {}) { const ident = identity || pl.identityFields(slug); const config = pl.loadConfig(slug); const theCode = code || config.code || slug; @@ -244,9 +246,11 @@ export function iconSpec(slug, { code = null, identity = null, palette = null, s dot: dot || ink, ringAlpha, dotAlpha, - // 有調色盤(=看過參考照片)就畫人物形象;沒有的話只能畫徽章 - style: style || config.icon?.style || (pal ? "portrait" : "badge"), + // 有調色盤(=看過大頭照)就重繪人物形象;沒有的話只能畫徽章 + // 只認得 STYLES 裡的樣式:config 可能殘留舊版本寫進去的值(例如已移除的 photo) + style: [style, config.icon?.style].find((v) => STYLES.includes(v)) || (pal ? "portrait" : "badge"), palette: pal, + features: features || parseFeatures(config.icon?.features || null), source: pal ? config.icon?.source || null : null, pattern, seed: h.subarray(0, 8).toString("hex"), @@ -291,6 +295,53 @@ const tint = (c, t) => mix(c, [255, 255, 255], t); const rect = (x, y, w, h, fill, alpha = 1) => ({ type: "rect", x, y, w, h, fill, alpha }); const ellipse = (cx, cy, rx, ry, fill, alpha = 1) => ({ type: "ellipse", cx, cy, rx, ry, fill, alpha }); const circle = (cx, cy, r, fill, alpha = 1) => ellipse(cx, cy, r, r, fill, alpha); +/** 多邊形:畫髮尾、緞帶、V 領、呆毛這些橢圓做不出來的形狀。 */ +const poly = (points, fill, alpha = 1) => ({ type: "poly", points, fill, alpha }); + +// --------------------------------------------------------------------------- // +// 五官與造型的特徵:這些決定「重新繪製」出來的人長什麼樣 +// --------------------------------------------------------------------------- // + +export const FEATURE_SPEC = { + hairstyle: ["straight", "twintails", "ponytail", "bob", "braid"], + length: ["short", "medium", "long", "very-long"], + fringe: ["blunt", "parted", "swept", "curtain"], + eyes: ["round", "almond", "sharp", "droopy"], + expression: ["gentle", "bright", "calm", "neutral"], + accessory: ["none", "ribbon", "clip", "flower", "hairband"], + side: ["left", "right"], + collar: ["round", "v", "high", "sailor"], + ahoge: ["no", "yes"], +}; + +export const DEFAULT_FEATURES = { + hairstyle: "straight", + length: "long", + fringe: "parted", + eyes: "almond", + expression: "gentle", + accessory: "none", + side: "right", + collar: "round", + ahoge: "no", +}; + +/** `hairstyle=twintails,fringe=blunt,...` → 正規化過的特徵物件(不認得的值一律回退預設)。 */ +export function parseFeatures(raw) { + const out = { ...DEFAULT_FEATURES }; + if (!raw) return 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 value = chunk.slice(idx + 1).trim().toLowerCase(); + if (FEATURE_SPEC[key] && FEATURE_SPEC[key].includes(value)) out[key] = value; + } + return out; +} + +export const featuresToString = (f) => + Object.keys(DEFAULT_FEATURES).map((k) => `${k}=${f[k]}`).join(","); /** * 人物形象(有臉)。全部用橢圓與矩形拼出來,所以 SVG 與自寫柵格器畫得出一模一樣的結果。 @@ -307,42 +358,148 @@ function portraitShapes(spec) { const skinShade = shade(skin, 0.12); const mouth = shade(cloth, 0.25); - return [ - // 肩膀與衣服(露出一截,看得出服裝主色) - ellipse(0.5, 1.18, 0.47, 0.36, cloth), - ellipse(0.5, 1.14, 0.175, 0.22, cloth2), // 領口/胸前配色 - // 脖子 - rect(0.442, 0.64, 0.116, 0.14, skinShade), - // 後髮(長髮往下鋪到兩側) - ellipse(0.5, 0.50, 0.315, 0.395, hairDark), - ellipse(0.235, 0.78, 0.080, 0.27, hairDark), - ellipse(0.765, 0.78, 0.080, 0.27, hairDark), - // 臉 - ellipse(0.5, 0.505, 0.212, 0.248, skin), - // 瀏海:中央一大片 + 兩側鬢髮,蓋住額頭 - ellipse(0.5, 0.335, 0.238, 0.158, hair), - ellipse(0.312, 0.435, 0.072, 0.155, hair), - ellipse(0.688, 0.435, 0.072, 0.155, hair), - // 眉毛 - rect(0.362, 0.452, 0.095, 0.015, hairDark), - rect(0.543, 0.452, 0.095, 0.015, hairDark), - // 眼睛:眼白 → 虹膜 → 瞳孔 → 高光 - ellipse(0.415, 0.538, 0.060, 0.073, [252, 252, 255]), - ellipse(0.585, 0.538, 0.060, 0.073, [252, 252, 255]), - ellipse(0.415, 0.543, 0.046, 0.059, eye), - ellipse(0.585, 0.543, 0.046, 0.059, eye), - ellipse(0.415, 0.549, 0.021, 0.030, shade(eye, 0.7)), - ellipse(0.585, 0.549, 0.021, 0.030, shade(eye, 0.7)), - circle(0.399, 0.520, 0.015, [255, 255, 255]), - circle(0.569, 0.520, 0.015, [255, 255, 255]), - // 腮紅與嘴 - ellipse(0.330, 0.598, 0.048, 0.025, cloth, 0.28), - ellipse(0.670, 0.598, 0.048, 0.025, cloth, 0.28), - ellipse(0.5, 0.626, 0.026, 0.015, mouth), - // 側邊髮飾(用第二配色,讓不同角色更好分辨) - ellipse(0.762, 0.345, 0.056, 0.038, cloth2), - ellipse(0.800, 0.375, 0.030, 0.052, cloth2), - ]; + const f = spec.features || DEFAULT_FEATURES; + const out = []; + const mirror = f.side === "left" ? -1 : 1; + const sideX = (base) => 0.5 + (base - 0.5) * mirror; + + // ── 頭髮長度:側髮鋪到哪裡 + const bottom = { short: 0.66, medium: 0.78, long: 0.90, "very-long": 1.02 }[f.length] || 0.90; + const sideCy = (0.46 + bottom) / 2; + const sideRy = (bottom - 0.46) / 2; + + // ── 肩膀與衣服 + out.push(ellipse(0.5, 1.18, 0.47, 0.36, cloth)); + if (f.collar === "round") out.push(ellipse(0.5, 1.14, 0.175, 0.22, cloth2)); + else if (f.collar === "high") out.push(rect(0.30, 0.90, 0.40, 0.10, cloth2)); + else if (f.collar === "sailor") { + out.push(poly([[0.30, 0.92], [0.50, 1.06], [0.70, 0.92], [0.70, 1.10], [0.30, 1.10]], cloth2)); + } else if (f.collar === "v") { + out.push(poly([[0.428, 0.900], [0.572, 0.900], [0.500, 1.030]], skinShade)); + } + // 脖子 + out.push(rect(0.442, 0.64, 0.116, 0.15, skinShade)); + + // ── 後髮 + out.push(ellipse(0.5, 0.50, 0.315, 0.395, hairDark)); + if (f.hairstyle === "twintails") { + out.push(ellipse(sideX(0.185), sideCy + 0.03, 0.085, sideRy, hairDark)); + out.push(ellipse(sideX(0.815), sideCy + 0.03, 0.085, sideRy, hairDark)); + out.push(ellipse(sideX(0.255), 0.415, 0.040, 0.030, cloth2)); // 髮束 + out.push(ellipse(sideX(0.745), 0.415, 0.040, 0.030, cloth2)); + } else if (f.hairstyle === "ponytail") { + out.push(ellipse(sideX(0.815), sideCy, 0.075, sideRy * 1.05, hairDark)); + out.push(ellipse(sideX(0.735), 0.40, 0.042, 0.032, cloth2)); + out.push(ellipse(0.245, 0.62, 0.062, 0.14, hairDark)); + } else if (f.hairstyle === "bob") { + out.push(ellipse(0.215, 0.60, 0.090, 0.175, hairDark)); + out.push(ellipse(0.785, 0.60, 0.090, 0.175, hairDark)); + } else if (f.hairstyle === "braid") { + out.push(ellipse(0.235, sideCy, 0.072, sideRy, hairDark)); + out.push(ellipse(0.765, sideCy, 0.072, sideRy, hairDark)); + for (let i = 0; i < 3; i += 1) { + out.push(ellipse(sideX(0.775), 0.60 + i * 0.11, 0.055, 0.048, shade(hair, 0.18))); + } + } else { + out.push(ellipse(0.235, sideCy, 0.078, sideRy, hairDark)); + out.push(ellipse(0.765, sideCy, 0.078, sideRy, hairDark)); + } + // 呆毛 + if (f.ahoge === "yes") out.push(poly([[0.482, 0.180], [0.548, 0.070], [0.540, 0.190]], hair)); + + // ── 臉 + out.push(ellipse(0.5, 0.500, 0.226, 0.256, skin)); + + // ── 瀏海 + if (f.fringe === "blunt") { + out.push(ellipse(0.5, 0.300, 0.248, 0.150, hair)); + out.push(rect(0.252, 0.300, 0.496, 0.088, hair)); + } else if (f.fringe === "swept") { + out.push(ellipse(sideX(0.560), 0.320, 0.235, 0.160, hair)); + out.push(poly([[sideX(0.30), 0.30], [sideX(0.78), 0.30], [sideX(0.30), 0.47]], hair)); + } else if (f.fringe === "curtain") { + out.push(ellipse(0.5, 0.290, 0.235, 0.140, hair)); + out.push(ellipse(0.360, 0.400, 0.090, 0.155, hair)); + out.push(ellipse(0.640, 0.400, 0.090, 0.155, hair)); + } else { + out.push(ellipse(0.5, 0.320, 0.244, 0.145, hair)); + out.push(ellipse(0.318, 0.410, 0.068, 0.140, hair)); + out.push(ellipse(0.682, 0.410, 0.068, 0.140, hair)); + } + // 鬢髮(貼著臉頰的兩束) + out.push(ellipse(0.268, 0.545, 0.038, 0.150, hair)); + out.push(ellipse(0.732, 0.545, 0.038, 0.150, hair)); + + // ── 眉毛(表情會影響傾斜) + const browY = f.expression === "bright" ? 0.430 : 0.436; + const brow = mix(hairDark, skin, 0.28); // 深髮角色的眉毛要和瀏海分得開 + const browTilt = { gentle: 0.006, bright: 0.010, calm: 0, neutral: 0 }[f.expression] ?? 0; + out.push(poly([[0.372, browY + browTilt], [0.462, browY], [0.462, browY + 0.021], + [0.372, browY + browTilt + 0.021]], brow)); + out.push(poly([[0.538, browY], [0.628, browY + browTilt], [0.628, browY + browTilt + 0.021], + [0.538, browY + 0.021]], brow)); + + // ── 眼睛 + const eyeGeo = { + round: { rx: 0.058, ry: 0.070 }, + almond: { rx: 0.063, ry: 0.060 }, + sharp: { rx: 0.066, ry: 0.049 }, + droopy: { rx: 0.060, ry: 0.065 }, + }[f.eyes] || { rx: 0.063, ry: 0.064 }; + const eyeY = f.eyes === "droopy" ? 0.558 : 0.552; + for (const [cx, hl] of [[0.415, 0.399], [0.585, 0.569]]) { + out.push(ellipse(cx, eyeY, eyeGeo.rx, eyeGeo.ry, [252, 252, 255])); + out.push(ellipse(cx, eyeY + 0.005, eyeGeo.rx * 0.76, eyeGeo.ry * 0.80, eye)); + out.push(ellipse(cx, eyeY + 0.011, eyeGeo.rx * 0.34, eyeGeo.ry * 0.42, shade(eye, 0.7))); + out.push(circle(hl, eyeY - 0.018, 0.015, [255, 255, 255])); + // 上眼線:眼型的關鍵 + out.push(rect(cx - eyeGeo.rx, eyeY - eyeGeo.ry, eyeGeo.rx * 2, 0.014, hairDark)); + if (f.eyes === "sharp") { + out.push(poly([[cx + eyeGeo.rx * 0.6, eyeY - eyeGeo.ry], + [cx + eyeGeo.rx * 1.45, eyeY - eyeGeo.ry - 0.022], + [cx + eyeGeo.rx * 1.05, eyeY - eyeGeo.ry * 0.35]], hairDark)); + } + } + + // ── 鼻子(很小一點,但少了它嘴巴會被看成鼻子) + out.push(ellipse(0.5, 0.638, 0.010, 0.007, shade(skin, 0.22))); + + // ── 腮紅與嘴(表情) + if (f.expression !== "calm") { + out.push(ellipse(0.318, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20)); + out.push(ellipse(0.682, 0.646, 0.042, 0.019, cloth, f.expression === "bright" ? 0.28 : 0.20)); + } + if (f.expression === "bright") { + out.push(ellipse(0.5, 0.690, 0.046, 0.022, mouth)); + out.push(rect(0.454, 0.676, 0.092, 0.009, shade(mouth, 0.35))); + out.push(ellipse(0.5, 0.683, 0.034, 0.008, tint(mouth, 0.6))); + } else if (f.expression === "calm" || f.expression === "neutral") { + out.push(rect(0.480, 0.686, 0.040, 0.009, mouth)); + } else { + out.push(ellipse(0.5, 0.688, 0.024, 0.013, mouth)); + } + + // ── 髮飾 + const ax = sideX(0.775); + if (f.accessory === "ribbon") { + out.push(poly([[ax, 0.330], [ax + 0.085 * mirror, 0.288], [ax + 0.085 * mirror, 0.376]], cloth2)); + out.push(poly([[ax, 0.330], [ax - 0.075 * mirror, 0.292], [ax - 0.075 * mirror, 0.372]], cloth2)); + out.push(circle(ax, 0.332, 0.026, shade(cloth2, 0.18))); + } else if (f.accessory === "clip") { + out.push(rect(ax - 0.055, 0.318, 0.110, 0.026, cloth2)); + out.push(rect(ax - 0.055, 0.352, 0.075, 0.022, tint(cloth2, 0.25))); + } else if (f.accessory === "flower") { + for (let i = 0; i < 5; i += 1) { + const a = (i / 5) * Math.PI * 2; + out.push(circle(ax + Math.cos(a) * 0.042, 0.335 + Math.sin(a) * 0.042, 0.030, cloth2)); + } + out.push(circle(ax, 0.335, 0.024, tint(cloth2, 0.45))); + } else if (f.accessory === "hairband") { + out.push(ellipse(0.5, 0.268, 0.246, 0.052, cloth2)); + out.push(ellipse(0.5, 0.300, 0.238, 0.052, hair)); + } + + return out; } /** 徽章樣式(沒有參考照片時):點陣紋 + 編號字母。 */ @@ -399,12 +556,16 @@ export function renderSvg(spec, size = DEFAULT_SIZE) { " ", ` `, ` `, - ...iconShapes(spec).map((sh) => - sh.type === "rect" - ? ` ` - : ` `), + ...iconShapes(spec).map((sh) => { + const paint = ` fill="${hex(sh.fill)}"${sh.alpha < 1 ? ` opacity="${sh.alpha}"` : ""}/>`; + if (sh.type === "rect") { + return ` ", ` y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + /** 內縮 inset 之後的圓角矩形內外判定(用來畫出與 SVG 描邊相同的環帶)。 */ function insideInset(x, y, inset) { const span = 1 - 2 * inset; @@ -511,6 +683,8 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { let hit; if (sh.type === "rect") { hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h; + } else if (sh.type === "poly") { + hit = pointInPoly(u, v, sh.points); } else { const dx = (u - sh.cx) / sh.rx; const dy = (v - sh.cy) / sh.ry; @@ -576,28 +750,11 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { * 回傳裡的 `photo` 說明走了哪條路、缺什麼工具。 */ export function generateIcon(slug, { - size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, - photo = null, pick = null, face = null, + size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, features = null, } = {}) { - const spec = iconSpec(slug, { code, palette, style }); - let svg; - let png; - let photoResult = null; - if (photo) { - const tmp = path.join(pl.personaDir(slug), ".sync", "portrait.tmp.png"); - fs.mkdirSync(path.dirname(tmp), { recursive: true }); - photoResult = renderPhotoPng(photo, tmp, size, { pick, face }); - if (photoResult.ok) { - png = fs.readFileSync(tmp); - svg = photoSvg(spec, png, size); - spec.style = "photo"; - } - fs.rmSync(tmp, { force: true }); - } - if (!png) { - svg = renderSvg(spec, size); - png = renderPng(spec, size); - } + const spec = iconSpec(slug, { code, palette, style, features }); + const svg = renderSvg(spec, size); + const png = renderPng(spec, size); pl.writeText(iconSvgPath(slug), svg); fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); fs.writeFileSync(iconPngPath(slug), png); @@ -610,11 +767,8 @@ export function generateIcon(slug, { size, generated_at: pl.nowIso(), palette: spec.palette ? paletteToString(spec.palette) : null, + features: featuresToString(spec.features), source: source || spec.source || null, - crop: photoResult?.ok - ? { method: photoResult.info.method, face: photoResult.info.face, - box: photoResult.info.box, faces_found: photoResult.info.faces_found } - : null, }; pl.writeJson(pl.configPath(slug), config); return { @@ -622,7 +776,6 @@ export function generateIcon(slug, { svg: iconSvgPath(slug), png: iconPngPath(slug), size, - photo: photoResult, bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length }, }; } @@ -757,8 +910,11 @@ export function listFaces(imagePath) { } } -/** 用照片裁臉產生 icon.png(成功回傳結果,工具不足回 ok:false 與缺什麼)。 */ -export function renderPhotoPng(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) { +/** + * 從參考圖裁出**大頭照**。這張是給 AI 看的**參考**,不是圖示本身—— + * 圖示一律由 renderSvg/renderPng 依人格資料重新繪製。 + */ +export function cropHeadshot(imagePath, outPath, size = DEFAULT_SIZE, { pick = null, face = null } = {}) { const report = toolReport(); if (!report.ready) return { ok: false, report }; const args = [PORTRAIT_PY, "--input", imagePath, "--output", outPath, "--size", String(size), @@ -780,26 +936,3 @@ export function renderPhotoPng(imagePath, outPath, size = DEFAULT_SIZE, { pick = return { ok: true, report, info }; } -/** 把裁好的 PNG 包成自成一體的 SVG(含圓角與瞳色外框)。 */ -export function photoSvg(spec, pngBuffer, size = DEFAULT_SIZE) { - const S = size; - const u = (v) => Math.round(v * S * 1000) / 1000; - return [ - ``, - ` ${spec.code}${spec.name ? ` ${spec.name}` : ""}`, - " ", - ` `, - " ", - ` `, - ` `, - " ", - ` `, - "", - "", - ].join("\n"); -} diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 46a87dc..abe5698 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1133,12 +1133,45 @@ commands.icon = async ({ flags, positional }) => { ` 字母 ${spec.letters}|配色 ${spec.palette ? "取自參考照片" : "由編號雜湊"}` + `:${JSON.stringify(spec.c1)} → ${JSON.stringify(spec.c2)}|seed ${spec.seed}`, ...(spec.palette ? [` 調色盤:${ic.paletteToString(spec.palette)}`] : []), + ...(spec.style === "portrait" ? [` 特徵:${ic.featuresToString(spec.features)}`] : []), ...(src.url ? [` 參考來源:${src.url}${src.note ? `(${src.note})` : ""}${src.date ? `|${src.date}` : ""}`] : []), ` ${ic.iconSvgPath(slug)}`, ` ${ic.iconPngPath(slug)}`, ]); return; } + if (action === "headshot") { + // 裁出「參考用大頭照」。這張不是圖示,也不會同步出去——它是給 AI 看的底稿。 + requireOwner(slug, session); + const want = str(flags.photo); + if (!want) die("需要 `--photo <圖片路徑或網址>`(該人格最新登場的官方視覺)。"); + let file; + try { + file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); + } catch (err) { + die(err.message); + } + const out = path.join(pl.personaDir(slug), ".sync", "headshot.png"); + const cropped = ic.cropHeadshot(file, out, num(flags.size, 384), { + pick: str(flags.pick) || null, + face: str(flags.face) || null, + }); + if (!cropped.ok) { + emit(cropped, flags.json, [ + `✖ 裁不出大頭照:${cropped.reason || "工具不足"}`, + ...ic.installHintLines(cropped.report || ic.toolReport()), + ]); + process.exit(1); + } + emit({ persona: slug, file: out, ...cropped.info }, flags.json, [ + `✔ 大頭照已裁出:${out}`, + ` 偵測方式 ${cropped.info.method}|原圖 ${cropped.info.source_size.join("×")}|` + + `找到 ${cropped.info.faces_found} 張臉|裁切框 ${JSON.stringify(cropped.info.box)}`, + " ⚠ 這張是**參考底稿**,不是圖示,也不會同步到 Gitea。", + " 請用 Read 打開它,確認是本人,再依看到的髮型/眼型/配件下 `icon generate --features ...`。", + ]); + return; + } if (action === "faces") { requireMember(slug, session, Boolean(flags["as-guest"])); const want = str(flags.photo); @@ -1164,7 +1197,7 @@ commands.icon = async ({ flags, positional }) => { ]); return; } - if (action !== "generate") die(`\u672a\u77e5 action\uff1a${action}\uff08\u53ef\u7528 generate/show/faces\uff09`); + if (action !== "generate") die(`未知 action:${action}(可用 generate/show/faces/headshot)`); requireOwner(slug, session); if (ic.hasIcon(slug) && !flags.force) { die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`); @@ -1193,40 +1226,22 @@ commands.icon = async ({ flags, positional }) => { : null; const style = str(flags.style) || null; if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`); - // 參考照片:給了就試著裁臉;工具不足會退回向量人物形象並印出安裝指令 - let photo = null; - if (flags.photo || (flags["from-source"] && source?.url)) { - const want = str(flags.photo) || source.url; - try { - photo = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); - } catch (err) { - die(`${err.message}`); - } - } - const res = ic.generateIcon(slug, { - size, palette, source, style, photo, - pick: str(flags.pick) || null, - face: str(flags.face) || null, - }); + const features = flags.features ? ic.parseFeatures(str(flags.features)) : null; + const res = ic.generateIcon(slug, { size, palette, source, style, features }); const lines = [ `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, - res.spec.style === "photo" - ? ` 形象圖:真實照片裁臉(偵測方式 ${res.photo?.info?.method},裁切框 ${JSON.stringify(res.photo?.info?.box)})` - : res.spec.style === "portrait" - ? ` 形象圖:向量人物(有臉)|配色取自參考照片:${ic.paletteToString(res.spec.palette)}` - : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, + res.spec.style === "portrait" + ? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)` + : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, + ...(res.spec.style === "portrait" + ? [` 配色:${ic.paletteToString(res.spec.palette)}`, + ` 特徵:${ic.featuresToString(res.spec.features)}`] + : []), ...(source?.url || res.spec.source?.url ? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`] : []), - // 缺工具就把安裝方式講清楚,而不是默默降級 - ...(photo && res.photo && !res.photo.ok - ? [ - ...(res.photo.reason ? [` ⚠ 照片裁臉失敗:${res.photo.reason}`] : []), - ...ic.installHintLines(res.photo.report || ic.toolReport()), - ] - : []), ]; // 圖示屬於低頻的身分資料 → Wiki 區;順便設成 Gitea 存取庫的頭像 if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) { @@ -1234,8 +1249,16 @@ commands.icon = async ({ flags, positional }) => { const owner = await gt.resolveOwner(); const okAvatar = await gt.setRepoAvatar(owner, gt.personaCode(slug), fs.readFileSync(res.png)); if (okAvatar) lines.push(" 📦 已設為 Gitea 存取庫頭像。"); - const pushed = await gt.pushArea(slug, "wiki", { message: `icon: 產生人格圖示 ${res.spec.letters}` }); - if (pushed.ok && pushed.changed) lines.push(" ↑ 圖示已同步到 Wiki 區。"); + const pushed = await gt.pushArea(slug, "wiki", { message: `icon: 重繪人格形象圖 ${res.spec.code}` }); + if (pushed.ok && pushed.changed) lines.push(" ↑ 形象圖已同步到 Wiki 區。"); + // 推完一定要回頭確認 Wiki 真的有這兩個檔案,且與本機一致 + const check = await gt.verifyIconInWiki(slug); + lines.push( + check.ok + ? ` ✔ Wiki 已保存形象圖並與本機一致(${check.icon_present.join(" + ")})` + : ` ✖ Wiki 形象圖驗證未通過:${check.reason || `缺少或未同步 ${(check.icon_pending || []).join(", ") || "?"}`}` + + " → 跑 `sync push --area wiki` 再 `sync verify --area wiki`。", + ); } catch (err) { lines.push(` ⚠ 同步到 Gitea 失敗(不影響本機):${err.message.slice(0, 120)}`); } @@ -1301,6 +1324,27 @@ commands.sync = async ({ flags, positional }) => { : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`)); return; } + if (action === "verify") { + const out = []; + for (const key of areas) { + try { + out.push(await gt.verifyArea(slug, key)); + } catch (err) { + out.push({ area: key, ok: false, reason: err.message }); + } + } + const bad = out.filter((r) => !r.ok && !r.skipped); + emit({ persona: slug, results: out, ok: bad.length === 0 }, flags.json, out.map((r) => + r.skipped + ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})` + : r.ok + ? `✔ ${gt.AREAS[r.area].label}:本機與 Gitea 一致(${r.files} 個檔案)` + : `✖ ${gt.AREAS[r.area].label}:不一致` + + (r.pending?.length ? `,還沒推上去的檔案:${r.pending.slice(0, 8).join(", ")}` : "") + + (r.reason ? `(${r.reason})` : ""))); + if (bad.length) process.exit(1); + return; + } if (action === "pull") { const out = []; for (const key of areas) { @@ -1324,7 +1368,7 @@ commands.sync = async ({ flags, positional }) => { : `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`)); return; } - die(`未知 action:${action}(可用 init/push/pull/status)`); + die(`未知 action:${action}(可用 init/push/pull/status/verify)`); }; commands.gc = ({ flags }) => { @@ -1390,22 +1434,23 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) 圖示(建立人格並補齊資料後跑): - icon generate|show|faces --session [--size 512 --force --no-gitea] - [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.."] - [--source-url <參考照片網址> --source-note <說明> --source-date ] - [--photo <圖片路徑或網址> | --from-source] [--style portrait|badge] - [--pick largest|leftmost|rightmost|<索引> | --face x,y,w,h] - faces 會列出參考照片裡偵測到的臉——多角色的圖務必先看過再用 --pick 指定。 + icon faces|headshot|generate|show --session + faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看) + headshot --photo <...> [--pick <索引>|--face x,y,w,h] [--size 384] + 裁出**參考用大頭照**到 .sync/headshot.png(不是圖示、不同步) + generate [--size 512 --force --no-gitea --style portrait|badge] + [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.."] + [--features "hairstyle=..,length=..,fringe=..,eyes=..,expression=..,accessory=..,side=..,collar=..,ahoge=.."] + [--source-url <來源網址> --source-note <說明> --source-date ] + **依人格資料重新繪製**人物頭像;不會把來源圖放進圖示。 + 沒帶 --palette → 徽章樣式(配色由編號/名字/emoji 雜湊)。 + show 看目前的樣式、配色、特徵與來源 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 - 沒帶 --palette → 徽章樣式,配色由編號/名字/emoji 雜湊而來。 - 帶了 --palette → 向量人物形象(有臉),配色取自「你實際看過的參考照片」 - (必須同時帶 --source-url 存證)。 - 帶了 --photo → 直接用那張照片裁出臉當形象圖;工具不足時會退回向量人物形象 - 並印出安裝指令(Pillow / OpenCV / 動漫臉模型)。 編號與 Gitea(存取庫名稱 = 人格編號): code show|assign|next --session [--romaji <英文名> --code --rename --force --public] - sync status|init|push|pull --session [--area files|wiki|all --if-due --force --message --owner] + sync status|init|push|pull|verify --session [--area files|wiki|all --if-due --force --message --owner] + verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步) 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push 環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER), diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 2990a46..7e3390e 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -615,6 +615,69 @@ check("Wiki 有專頁保存形象圖,並寫明來源", (() => { check("Wiki 的保留檔不會被同步流程刪掉", ["Home.md", "Icon.md"].every((f) => gt.wikiHome && typeof gt.wikiIconPage === "function")); +console.log("⑰ 依人格資料重繪(不直接使用網路圖)與 Wiki 同步驗證"); +check("特徵解析:只吃認得的值,其餘回退預設", (() => { + const f = ic.parseFeatures("hairstyle=twintails,eyes=round,accessory=flower,bogus=x,expression=???"); + return f.hairstyle === "twintails" && f.eyes === "round" && f.accessory === "flower" && + f.expression === ic.DEFAULT_FEATURES.expression && !("bogus" in f); +})(), JSON.stringify(ic.parseFeatures("hairstyle=twintails,bogus=x"))); +const basePal = ic.parsePalette("hair=#d9a45b,eye=#9e5b3e,accent=#c0392b"); +const draw = (feat) => ic.renderPng(ic.iconSpec("GAMMA-01", + { palette: basePal, style: "portrait", features: ic.parseFeatures(feat) }), 32); +check("換髮型會畫出不同的圖", + Buffer.compare(draw("hairstyle=straight"), draw("hairstyle=twintails")) !== 0); +check("換眼型會畫出不同的圖", + Buffer.compare(draw("eyes=round"), draw("eyes=sharp")) !== 0); +check("換表情會畫出不同的圖", + Buffer.compare(draw("expression=calm"), draw("expression=bright")) !== 0); +check("加髮飾/呆毛會多出圖形", (() => { + const plain = ic.iconShapes(ic.iconSpec("GAMMA-01", + { palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=none,ahoge=no") })); + const fancy = ic.iconShapes(ic.iconSpec("GAMMA-01", + { palette: basePal, style: "portrait", features: ic.parseFeatures("accessory=flower,ahoge=yes") })); + return fancy.length > plain.length + 3; +})()); +check("多邊形在 SVG 與柵格器都畫得出來(呆毛用的是 polygon)", (() => { + const spec = ic.iconSpec("GAMMA-01", + { palette: basePal, style: "portrait", features: ic.parseFeatures("ahoge=yes") }); + const shapes = ic.iconShapes(spec); + const polys = shapes.filter((sh) => sh.type === "poly").length; + const svg = ic.renderSvg(spec, 64); + return polys > 0 && (svg.match(/ { + const cfg = pl.loadConfig("GAMMA-01"); + cfg.icon = { ...(cfg.icon || {}), style: "photo" }; // 舊版本寫進去、現已移除的樣式 + pl.writeJson(pl.configPath("GAMMA-01"), cfg); + return ic.iconSpec("GAMMA-01").style === "portrait"; +})(), ic.iconSpec("GAMMA-01").style); +check("`icon headshot` 需要 --photo", + cli(["icon", "headshot", "--session", S_CODE], { expectOk: false }).status !== 0); +check("重繪不會把來源圖塞進圖示(SVG 沒有內嵌影像)", (() => { + const svg = fs.readFileSync(ic.iconSvgPath("GAMMA-01"), "utf8"); + // xmlns 本來就有 http,所以只看「有沒有內嵌影像」 + return !svg.includes(" { + cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64", + "--features", "hairstyle=twintails,accessory=ribbon"]); + const feat = pl.loadConfig("GAMMA-01").icon?.features || ""; + return feat.includes("hairstyle=twintails") && feat.includes("accessory=ribbon"); +})(), pl.loadConfig("GAMMA-01").icon?.features); +check("沒設定 Gitea 時 sync verify 是「略過」不是崩潰", (() => { + const res = cli(["sync", "verify", "--session", S_CODE], { expectOk: false }); + return res.stderr.includes("尚未設定") || res.stdout.includes("略過"); +})()); +check("Wiki 形象圖頁同時列出 SVG 與 PNG", (() => { + const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01"); + return page.includes("icon.svg") && page.includes("icon.png") && page.includes("SVG") && page.includes("PNG"); +})()); +check("Wiki 首頁不含每次都變的時間戳(否則永遠驗不過)", (() => { + const a = gt.wikiHome("GAMMA-01", "GAMMA-01"); + const b = gt.wikiHome("GAMMA-01", "GAMMA-01"); + return a === b; +})()); + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-icon/SKILL.md b/skills/persona-icon/SKILL.md index 6df60b9..9e6b012 100644 --- a/skills/persona-icon/SKILL.md +++ b/skills/persona-icon/SKILL.md @@ -1,13 +1,17 @@ --- name: persona-icon -description: 產生或更新人格的形象圖(icon.svg + icon.png,盡量看得到臉):先上網找出該人格「最新一次登場」的官方視覺(新作動畫主視覺、官方角色圖),親自看過那張圖,接著優先用臉部偵測從圖上裁出臉當頭像;缺少裁圖工具時改畫有五官的向量人物形象並提示如何安裝工具,完全沒有參考圖才退回編號徽章。來源網址與裁切框會存進 config 備查,圖示同時同步到 Gitea Wiki 與存取庫頭像。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說現在的圖示不像或看不到臉、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 +description: 產生或更新人格的形象圖(icon.svg + icon.png,人物頭像、看得到臉):先上網找出該人格「最新一次登場」的官方視覺,裁出**大頭照當底稿**,親眼看過之後萃取髮色/瞳色/服裝色與髮型、眼型、表情、配件等特徵,再**由本工具依人格資料重新繪製**——絕不把找到的圖片直接當圖示。來源網址、底稿裁切框與重繪依據都會存進 config 備查,圖示同步到 Gitea Wiki(SVG + PNG)並設為存取庫頭像,且會回頭驗證同步結果。當使用者說要做人格頭像/形象圖/icon、要依角色最新造型更新圖示、說現在的圖示不像或看不到臉、或剛建立完人格要補圖示時觸發。不適用於:建立人格本身(persona-create/persona-anime,它們會叫用本技能的步驟)、同步到 Gitea(persona-sync)。 --- # 🎨 persona-icon — 依最新造型產生人格形象圖(有臉) **CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session `) **時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——形象綁在最終身分上。 -**目標**:圖示要是**人物形象圖,盡量看得到臉**;退而求其次才是抽象徽章。 +**目標**:圖示要是**人物形象圖,看得到臉**;退而求其次才是抽象徽章。 + +> **鐵則:找到的圖片只能當底稿,不能當圖示。** +> 網路上的角色圖是別人的美術作品。這裡的做法是「裁出大頭照 → 看清楚 → +> **依人格資料重新繪製**」,產出的每一個像素都是本工具畫的。 --- @@ -58,27 +62,22 @@ description: 產生或更新人格的形象圖(icon.svg + icon.png,盡量看 | `secondary` | 服裝的第二色/配件色 | | `light` | 最亮的部位(白袖、皮膚亮面) | -### 4. 裁出臉(有工具的話,這是首選) - -**圖示必須是人物形象,盡量看得到臉。** 環境裡如果有 Pillow(+OpenCV 動漫臉模型), -就直接從參考圖裁臉: +### 4. 裁出大頭照當底稿 ```bash # 先看看圖裡有幾張臉——多角色的主視覺一定要先看 node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon faces --session --photo "<圖片網址或路徑>" -# 挑一張(索引由左到右;也可以用 largest/leftmost/rightmost,或 --face x,y,w,h 自己框) -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force \ - --photo "<圖片網址或路徑>" --pick <索引> \ - --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#.." \ - --source-url "<那張圖的網址>" --source-note "<作品(年份)+造型描述>" +# 裁出底稿(索引由左到右;也可以用 largest/leftmost/rightmost,或 --face x,y,w,h 自己框) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon headshot --session --photo "<圖片網址或路徑>" --pick <索引> ``` -**多角色的圖務必自己看過再挑**:`icon faces` 只會告訴你座標,哪張臉是本人要由你判斷 -(把裁出來的圖用 Read 打開確認一次最保險)。挑錯就是別人的臉。 +底稿寫到 `<人格>/.sync/headshot.png`,**不是圖示、不會同步、不會發佈**。 -工具不齊時 CLI 會**印出安裝指令**並自動退回第 5 步的向量形象——不會靜默失敗。 -需要的工具: +**多角色的圖務必自己看過再挑**:`icon faces` 只給座標,哪張臉是本人要由你判斷。 +裁完**一定要用 Read 打開確認是本人**——挑錯就是別人的臉。 + +工具不齊時 CLI 會**印出安裝指令**——不會靜默失敗。需要的工具: | 工具 | 做什麼 | 怎麼裝(免 sudo) | | --- | --- | --- | @@ -89,17 +88,36 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session OpenCV **5.x 拿掉了 `CascadeClassifier`**,一定要裝 4.x。 > venv 放在 `~/.cache/jsc-persona/venv` 會被自動偵測到;也可以用 `PERSONA_PYTHON` 指定別的。 -### 5. 沒有工具時:向量人物形象(一樣有臉) +### 5. 看著底稿,把特徵讀出來 + +用 Read 打開 `.sync/headshot.png`,**同時**讀這個人格的 `IDENTITY.md`/`SOUL.md`/canon 記憶。 +形象要同時對得起「他長什麼樣」與「他是誰」: + +| 特徵 | 可選值 | 從哪裡判斷 | +| --- | --- | --- | +| `hairstyle` | straight / twintails / ponytail / bob / braid | 底稿的髮型 | +| `length` | short / medium / long / very-long | 底稿的髮長 | +| `fringe` | blunt / parted / swept / curtain | 底稿的瀏海 | +| `eyes` | round / almond / sharp / droopy | 底稿的眼型 | +| `expression` | gentle / bright / calm / neutral | **SOUL 的 Vibe** + 底稿的神情 | +| `accessory` | none / ribbon / clip / flower / hairband | 底稿或最新造型的髮飾 | +| `side` | left / right | 髮飾在哪一側 | +| `collar` | round / v / high / sailor | 服裝領口 | +| `ahoge` | yes / no | 有沒有呆毛 | + +### 6. 重新繪製 ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ - --session --force \ - --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3" \ - --source-url "<你剛才看的那張圖的網址>" \ - --source-note "<作品名(年份)+造型描述:髮色、瞳色、服裝>" \ +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force \ + --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3,skin=#f7ddc4" \ + --features "hairstyle=straight,length=very-long,fringe=parted,eyes=almond,expression=calm,accessory=none,collar=v,ahoge=yes" \ + --source-url "<底稿那張圖的網址>" \ + --source-note "<作品(年份)+底稿是誰+重繪依據:髮型、眼型、神情、服裝>" \ --source-date ``` +**畫完一定要用 Read 打開產出的 `icon.png` 看一眼**,不像就調特徵重畫(`--force`)。 + - **`--palette` 一定要配 `--source-url`**:配色是從哪張圖來的必須留得下來(CLI 會強制)。 - 兩者都會寫進 `state/config.json` 的 `icon` 欄位,之後 `icon show` 查得到。 - 產出的 `icon.svg` + `icon.png` 會自動設成 Gitea 存取庫頭像、同步到 Wiki 區, @@ -108,7 +126,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ 找不到可靠的參考圖 → **不要硬掰**,直接 `icon generate` 不帶 `--palette`, 用編號雜湊出的配色,並告訴使用者「沒找到最新造型的官方圖,先用預設配色」。 -### 6. 回報 +### 7. 回報 一句話說明:用了哪部作品哪一年的視覺、取到什麼配色、圖示放哪裡。 把來源網址附上,讓使用者可以自己核對。 @@ -117,25 +135,34 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \ | 樣式 | 什麼時候用 | 長什麼樣 | | --- | --- | --- | -| `photo` | 有參考圖 **且**工具齊全(最佳) | 從官方視覺**裁出的臉**,圓角+瞳色外框 | -| `portrait` | 有參考圖但沒工具 | 向量人物形象:髮型、瞳色、服裝色都取自照片,**有五官** | +| `portrait` | 有底稿(有調色盤)→ **預設** | 依特徵重繪的人物頭像:髮型、瀏海、眼型、表情、髮飾、領口全部照特徵畫 | | `badge` | 完全沒有參考圖(原創人格) | 雙色漸層徽章 + 編號前兩個字母 | -- `portrait` 與 `badge` 由 Node 自己柵格化,SVG 與 PNG **共用同一份圖形清單**,兩邊必然一致; - PNG 是自寫的柵格器 + zlib 編碼,不依賴任何影像函式庫。 -- `photo` 的 SVG 會把裁好的 PNG 以 base64 內嵌,一樣自成一體(不外連)。 +- 兩種樣式都由 Node 自己柵格化,SVG 與 PNG **共用同一份圖形清單**(橢圓/矩形/多邊形), + 兩邊必然一致;PNG 是自寫的柵格器 + zlib 編碼,不依賴任何影像函式庫。 +- SVG **不會內嵌任何影像**——沒有 ``、沒有 base64、沒有外連。 - `badge` 樣式下若兩個顏色一深一淺(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓, - 確保字讀得到——顏色仍然是照片來的,只是收斂色階。 + 確保字讀得到。 ## 界線 -裁出來的是**角色臉部的縮圖**,用途是人格身分辨識(頭像)。原始美術作品的權利屬於原作者, -存取庫預設私有;不要把它當成可以散布的素材,也不要重製整張原圖。 +- **找到的圖片只能當底稿**:裁出來的大頭照留在 `.sync/`,不進圖示、不同步、不發佈。 + 圖示的每個像素都是本工具依特徵畫出來的。 +- 底稿的來源網址會記在 `config.json`,方便日後核對,但不重製原圖。 + +## Wiki 必須同步 + +`icon generate` 推完 Wiki 會**自動回頭驗證**,確認 `icon.svg` 與 `icon.png` 真的在 Wiki 上 +而且與本機一致;不一致會直接報錯。也可以手動查: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync verify --session --area wiki +``` ## 邊界 - 只能對「本 session 已載入的人格」產生圖示。 - 已有圖示時要重畫必須加 `--force`。 - 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。 -- `.sync/reference.*` 是下載回來的參考圖快取,可以安全刪除。 +- `.sync/reference.*` 與 `.sync/headshot.png` 是底稿快取,可以安全刪除(不會同步出去)。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 From cd1f4754ab2736c060180de16599434d3e45fb0b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 03:20:29 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20Wiki=20=E5=9C=96=E7=89=87=E5=85=B6?= =?UTF-8?q?=E5=AF=A6=E6=98=AF=E7=A0=B4=E5=9C=96=EF=BC=88HTML=20img=20?= =?UTF-8?q?=E4=B8=8D=E8=A2=AB=E6=94=B9=E5=AF=AB=EF=BC=89=EF=BC=9B=E5=BD=A2?= =?UTF-8?q?=E8=B1=A1=E5=9C=96=E5=8A=A0=E9=AB=98=E8=A7=A3=E6=9E=90=E5=BA=A6?= =?UTF-8?q?=E8=88=87=E4=BA=94=E5=AE=98=E7=B4=B0=E7=AF=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Wiki 形象圖「沒有同步」的真正原因 內容其實一直是一致的(md5 相同),但**頁面上看不到圖**: Icon.md/Home.md 用了 HTML ``,而 Gitea **只改寫 Markdown 圖片語法**(`![](icon.png)` → `/wiki/raw/icon.png`)。HTML 的 src 被瀏覽器解析成 `/wiki/icon.svg` → 303 轉址 → 破圖。 用公開的臨時庫實測四種寫法後確認,全部改成 Markdown 語法。 同時發現攤平規則過頭了:原本所有子目錄檔案都被壓成 `icon-portrait.svg`。 實測 `/wiki/raw/<資料夾>/<圖>` 是取得到的,所以**只有 .md 需要攤平** (頁面必須在根層),圖片與附件保留資料夾結構。 2. icon/ 資料夾 + 高解析度 每次 `icon generate` 都會輸出並同步: icon.svg / icon.png 512,Wiki 頁面與存取庫頭像 icon/portrait.svg 向量原稿 icon/portrait-512.png 高解析度 icon/portrait-1024.png 高解析度 柵格器加上每個圖形的 bounding box 裁剪,1024×1024(3× 超取樣)約 1.8 秒; 沒有裁剪的話要跑上億次點測試。 3. 更貼近原圖、更可愛 眼睛放大約 15%,虹膜改成四層(暗環→本色→下緣提亮→瞳孔)+雙高光, 眼線壓扁成細弧、外眼角加睫毛;頭髮加高光弧、髮絲與側髮反光; 臉型再圓一點、加下巴陰影與鼻子。 眉色改成隨髮色明暗調整:深髮往膚色提亮、淺髮比頭髮更深——否則不是和瀏海 連成黑帶,就是變成兩條灰槓。 selftest 168 項全綠(新增第 ⑱ 節:icon/ 多解析度、PNG 實際尺寸、只攤平 .md、 Wiki 頁面不得用 HTML img、bounding box 裁剪效能)。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 11 +++-- README.md | 17 +++++-- plugin.json | 2 +- scripts/persona-gitea.mjs | 37 +++++++++++--- scripts/persona-icon.mjs | 96 +++++++++++++++++++++++++++--------- scripts/persona.mjs | 1 + scripts/selftest.mjs | 38 +++++++++++++- skills/persona-icon/SKILL.md | 2 + 10 files changed, 166 insertions(+), 42 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 53fb8aa..b6e732d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 158b058..13d84c1 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 42e9ae4..9dac0c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,9 +43,14 @@ 13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。 plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 -14. **Wiki 必須保存並同步形象圖**:`icon.svg` 與 `icon.png` 都在 Wiki 區,另有自動產生的 - **Icon** 頁展示兩種格式與來源。`icon generate` 推完會**回頭驗證**,`sync verify` 可隨時檢查。 - Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。 +14. **Wiki 必須保存並同步形象圖**:`icon.svg`、`icon.png` 與 `icon/`(向量原稿 + 512/1024) + 都在 Wiki 區,另有自動產生的 **Icon** 頁。`icon generate` 推完會**回頭驗證**, + `sync verify` 可隨時檢查。兩個容易踩的坑: + * Wiki 頁面**只能用 Markdown 圖片語法** `![](icon.png)`——Gitea 只改寫這種語法為 + `/wiki/raw/...`;HTML `` 不會被改寫,瀏覽器會解析成 `/wiki/icon.png` + 而變成破圖(看起來就像「沒有同步」)。 + * Wiki 產生的頁面**不得含每次都變的時間戳**,否則驗證永遠不會通過、也會每次多一個 commit。 + * 攤平只對 `.md` 做(頁面必須在根層);圖片等附件保留資料夾結構,`/wiki/raw/<資料夾>/<檔>` 讀得到。 ## 慣例 diff --git a/README.md b/README.md index 0931f2e..9edd9ae 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | | **12. 圖示是重新繪製的人物形象圖(有臉)** | 上網找出該人格**最新一次登場**的官方視覺 → 裁出**大頭照當底稿** → **親眼看過** → 讀出髮型/瀏海/眼型/表情/髮飾等特徵 → **由本工具依人格資料重新繪製**。**絕不把找到的圖片當圖示**:SVG 裡沒有 ``、沒有 base64、沒有外連,底稿只留在 `.sync/` 不同步。來源網址與重繪依據存進 `config.json` 備查 | -| **13. Wiki 必須保存並同步形象圖** | `icon.svg` 與 `icon.png` 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示兩種格式與來源;`icon generate` 推完會**回頭驗證** Wiki 真的有這兩個檔案且與本機一致,另有 `sync verify` 可隨時檢查(不一致以非零結束)| +| **13. Wiki 必須保存並同步形象圖** | `icon.svg`、`icon.png` 與 **`icon/` 資料夾(向量原稿 + 512/1024 高解析度)** 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示與來源。Wiki 頁面一律用 **Markdown 圖片語法**(Gitea 只改寫這種語法為 `/wiki/raw/...`;HTML `` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` | --- @@ -161,9 +161,20 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 | 樣式 | 條件 | 長什麼樣 | | --- | --- | --- | -| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像,五官俱全 | +| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像:漸層虹膜+雙高光+睫毛、髮絲與反光、腮紅與下巴陰影 | | **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 | +輸出(每次 `icon generate` 都會全部更新): + +``` +icon.svg / icon.png 根目錄:Wiki 頁面與存取庫頭像用(512) +icon/portrait.svg 向量原稿,可無限放大 +icon/portrait-512.png 高解析度 +icon/portrait-1024.png 高解析度 +``` + +柵格器對每個圖形先算 bounding box 再逐點測試,1024×1024(3× 超取樣)約 1.8 秒。 + **找到的圖片只能當底稿**:產出的 SVG 沒有 ``、沒有 base64、沒有外連,每個像素都是畫出來的。 裁底稿需要的工具缺了,CLI **會印出安裝指令**,不會靜默降級: @@ -378,7 +389,7 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 161 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、形象圖重繪與 Wiki 同步、hooks +node scripts/selftest.mjs # 168 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、形象圖重繪、高解析輸出與 Wiki 同步、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 diff --git a/plugin.json b/plugin.json index d5c9bc2..c176f0d 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.7", + "version": "0.0.8", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index a87e9b6..2214218 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -93,6 +93,7 @@ export const AREAS = { "USER.md", "icon.svg", "icon.png", + "icon/", "memory/INDEX.md", "memory/long-term/", "mindmap/semantic.mmd", @@ -316,6 +317,10 @@ const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relation */ export function wikiName(rel) { if (!rel.includes("/")) return rel; + // 只有 .md 需要攤平——Gitea 只把「根目錄的 .md」當成頁面。 + // 其他附件(圖片、mmd、json)放子資料夾沒問題:實測 /wiki/raw/icon/portrait.svg 取得到, + // 而且 Markdown 圖片語法會被自動改寫成那個 raw 路徑。 + if (!/\.md$/i.test(rel)) return rel; const parts = rel.split("/"); const file = parts.pop(); return `${WIKI_PREFIX[parts[0]] || parts[0]}-${file}`; @@ -401,6 +406,19 @@ export function wikiIconPage(slug, code) { const root = pl.personaDir(slug); const hasSvg = fs.existsSync(path.join(root, "icon.svg")); const hasPng = fs.existsSync(path.join(root, "icon.png")); + let renders = []; + try { + renders = fs.readdirSync(path.join(root, "icon")) + .filter((n) => /\.(svg|png)$/i.test(n)) + .sort() + .map((name) => ({ + name, + ext: name.split(".").pop(), + note: /(\d+)/.test(name) ? `${name.match(/(\d+)/)[1]}×${name.match(/(\d+)/)[1]} 高解析度` : "向量原稿", + })); + } catch { + renders = []; + } const styleLabel = { photo: "真實照片裁臉", portrait: "向量人物形象(有臉),配色取自參考照片", @@ -411,13 +429,16 @@ export function wikiIconPage(slug, code) { "", `\`${code}\` ${styleLabel}`, "", + // Gitea 只會改寫 **Markdown 圖片語法** 的路徑(→ /wiki/raw/...); + // 用 HTML 會被瀏覽器當成相對於頁面網址,變成 303 破圖。 ...(hasSvg || hasPng ? [ - "| SVG(向量,可無限放大) | PNG(點陣,Gitea 頭像用) |", - "| --- | --- |", - `| ${hasSvg ? 'icon.svg' : "(缺)"} ` + - `| ${hasPng ? 'icon.png' : "(缺)"} |`, - `| [icon.svg](icon.svg) | [icon.png](icon.png) |`, + ...(hasPng ? [`![${code} 形象圖](icon.png)`, ""] : []), + "| 格式 | 檔案 | 用途 |", + "| --- | --- | --- |", + ...(hasSvg ? [`| SVG | [icon.svg](icon.svg) | 向量,可無限放大 |`] : []), + ...(hasPng ? [`| PNG | [icon.png](icon.png) | 點陣,存取庫頭像 |`] : []), + ...renders.map((r) => `| ${r.ext.toUpperCase()} | [icon/${r.name}](icon/${r.name}) | ${r.note} |`), "", ] : ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]), @@ -446,11 +467,11 @@ export function wikiHome(slug, code) { const ident = pl.identityFields(slug); const longTerm = pl.longTermEntries(slug); const relations = pl.loadRelations(slug); - const hasIcon = fs.existsSync(path.join(pl.personaDir(slug), "icon.svg")); + const hasIcon = fs.existsSync(path.join(pl.personaDir(slug), "icon.png")); const lines = [ `# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``, "", - ...(hasIcon ? [`${code}`, ""] : []), + ...(hasIcon ? [`![${code}](icon.png)`, ""] : []), "> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;", "> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。", "", @@ -489,7 +510,7 @@ export function wikiHome(slug, code) { "", "- `Mindmap-semantic.mmd` — 心智圖(Mermaid)", "- `Relations-graph.mmd` — 人際關係圖(Mermaid)/ `Relations-graph.json` — 原始資料", - "- `_paths.json` — 攤平前的原始路徑對照(同步用,勿手改)", + "- `icon/` — 高解析度形象圖(多尺寸) - `_paths.json` — 攤平前的路徑對照(勿手改)", "", "> Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以子目錄的檔案在這裡是攤平的檔名。", "", diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs index 8bb4c29..2c69302 100644 --- a/scripts/persona-icon.mjs +++ b/scripts/persona-icon.mjs @@ -26,6 +26,8 @@ import * as pl from "./persona-lib.mjs"; export const ICON_SVG = "icon.svg"; export const ICON_PNG = "icon.png"; export const DEFAULT_SIZE = 512; +/** icon/ 資料夾裡要輸出的解析度(512 給頭像、1024 給高解析用途)。 */ +export const RENDER_SIZES = [512, 1024]; export const iconSvgPath = (slug) => path.join(pl.personaDir(slug), ICON_SVG); export const iconPngPath = (slug) => path.join(pl.personaDir(slug), ICON_PNG); @@ -404,11 +406,14 @@ function portraitShapes(spec) { out.push(ellipse(0.235, sideCy, 0.078, sideRy, hairDark)); out.push(ellipse(0.765, sideCy, 0.078, sideRy, hairDark)); } + // 側髮的反光,讓長髮有圓柱感 + out.push(ellipse(0.221, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32)); + out.push(ellipse(0.779, sideCy - sideRy * 0.38, 0.022, sideRy * 0.36, tint(hairDark, 0.20), 0.32)); // 呆毛 if (f.ahoge === "yes") out.push(poly([[0.482, 0.180], [0.548, 0.070], [0.540, 0.190]], hair)); // ── 臉 - out.push(ellipse(0.5, 0.500, 0.226, 0.256, skin)); + out.push(ellipse(0.5, 0.500, 0.234, 0.252, skin)); // ── 瀏海 if (f.fringe === "blunt") { @@ -426,41 +431,63 @@ function portraitShapes(spec) { out.push(ellipse(0.318, 0.410, 0.068, 0.140, hair)); out.push(ellipse(0.682, 0.410, 0.068, 0.140, hair)); } + // 瀏海高光:一道亮弧 + 幾綹髮絲,頭髮才不會是一塊死色 + out.push(ellipse(0.5, 0.230, 0.126, 0.022, tint(hair, 0.40), + luminance(hair) < 0.12 ? 0.13 : 0.24)); + for (const [sx, sy, sw] of [[0.386, 0.330, 0.013], [0.596, 0.330, 0.013]]) { + out.push(poly([[sx, sy - 0.075], [sx + sw, sy - 0.075], [sx + sw * 0.4, sy + 0.055]], + shade(hair, 0.16), 0.35)); + } + // 鬢髮(貼著臉頰的兩束) - out.push(ellipse(0.268, 0.545, 0.038, 0.150, hair)); - out.push(ellipse(0.732, 0.545, 0.038, 0.150, hair)); + out.push(ellipse(0.250, 0.560, 0.032, 0.185, hair)); + out.push(ellipse(0.750, 0.560, 0.032, 0.185, hair)); // ── 眉毛(表情會影響傾斜) - const browY = f.expression === "bright" ? 0.430 : 0.436; - const brow = mix(hairDark, skin, 0.28); // 深髮角色的眉毛要和瀏海分得開 + const browY = f.expression === "bright" ? 0.408 : 0.414; + // 眉毛要「看得到但不搶戲」:深髮角色往膚色提亮才不會和瀏海連成一片, + // 淺髮角色則要比頭髮更深,否則會變成兩條灰槓。 + const brow = luminance(hair) < 0.12 ? mix(hairDark, skin, 0.16) : shade(hair, 0.48); const browTilt = { gentle: 0.006, bright: 0.010, calm: 0, neutral: 0 }[f.expression] ?? 0; - out.push(poly([[0.372, browY + browTilt], [0.462, browY], [0.462, browY + 0.021], - [0.372, browY + browTilt + 0.021]], brow)); - out.push(poly([[0.538, browY], [0.628, browY + browTilt], [0.628, browY + browTilt + 0.021], - [0.538, browY + 0.021]], brow)); + out.push(poly([[0.378, browY + browTilt], [0.458, browY], [0.458, browY + 0.014], + [0.376, browY + browTilt + 0.014]], brow)); + out.push(poly([[0.542, browY], [0.622, browY + browTilt], [0.622, browY + browTilt + 0.014], + [0.540, browY + 0.014]], brow)); // ── 眼睛 const eyeGeo = { - round: { rx: 0.058, ry: 0.070 }, - almond: { rx: 0.063, ry: 0.060 }, - sharp: { rx: 0.066, ry: 0.049 }, - droopy: { rx: 0.060, ry: 0.065 }, + round: { rx: 0.070, ry: 0.084 }, + almond: { rx: 0.074, ry: 0.073 }, + sharp: { rx: 0.076, ry: 0.060 }, + droopy: { rx: 0.072, ry: 0.078 }, }[f.eyes] || { rx: 0.063, ry: 0.064 }; const eyeY = f.eyes === "droopy" ? 0.558 : 0.552; - for (const [cx, hl] of [[0.415, 0.399], [0.585, 0.569]]) { + const lash = shade(hairDark, 0.35); + for (const [cx, hl, dir] of [[0.404, 0.385, -1], [0.596, 0.577, 1]]) { + // 眼白 → 虹膜(上深下淺的漸層感)→ 瞳孔 → 高光 ×2 → 眼線與睫毛 out.push(ellipse(cx, eyeY, eyeGeo.rx, eyeGeo.ry, [252, 252, 255])); - out.push(ellipse(cx, eyeY + 0.005, eyeGeo.rx * 0.76, eyeGeo.ry * 0.80, eye)); - out.push(ellipse(cx, eyeY + 0.011, eyeGeo.rx * 0.34, eyeGeo.ry * 0.42, shade(eye, 0.7))); - out.push(circle(hl, eyeY - 0.018, 0.015, [255, 255, 255])); - // 上眼線:眼型的關鍵 - out.push(rect(cx - eyeGeo.rx, eyeY - eyeGeo.ry, eyeGeo.rx * 2, 0.014, hairDark)); - if (f.eyes === "sharp") { - out.push(poly([[cx + eyeGeo.rx * 0.6, eyeY - eyeGeo.ry], - [cx + eyeGeo.rx * 1.45, eyeY - eyeGeo.ry - 0.022], - [cx + eyeGeo.rx * 1.05, eyeY - eyeGeo.ry * 0.35]], hairDark)); + out.push(ellipse(cx, eyeY + 0.004, eyeGeo.rx * 0.80, eyeGeo.ry * 0.84, shade(eye, 0.30))); + out.push(ellipse(cx, eyeY + 0.012, eyeGeo.rx * 0.76, eyeGeo.ry * 0.72, eye)); + out.push(ellipse(cx, eyeY + 0.024, eyeGeo.rx * 0.62, eyeGeo.ry * 0.42, tint(eye, 0.28))); + out.push(ellipse(cx, eyeY + 0.010, eyeGeo.rx * 0.32, eyeGeo.ry * 0.44, shade(eye, 0.78))); + out.push(ellipse(hl, eyeY - 0.019, 0.019, 0.016, [255, 255, 255])); + out.push(circle(cx + dir * eyeGeo.rx * 0.38, eyeY + eyeGeo.ry * 0.42, 0.010, [255, 255, 255], 0.85)); + // 上眼線 + 外眼角睫毛 + out.push(ellipse(cx, eyeY - eyeGeo.ry * 0.94, eyeGeo.rx * 0.98, eyeGeo.ry * 0.13, lash)); + out.push(poly([ + [cx + dir * eyeGeo.rx * 0.80, eyeY - eyeGeo.ry * 1.00], + [cx + dir * eyeGeo.rx * 1.22, eyeY - eyeGeo.ry * (f.eyes === "sharp" ? 1.24 : 1.08)], + [cx + dir * eyeGeo.rx * 1.02, eyeY - eyeGeo.ry * 0.66], + ], lash)); + if (f.eyes === "droopy") { + out.push(ellipse(cx + dir * eyeGeo.rx * 0.7, eyeY + eyeGeo.ry * 0.85, eyeGeo.rx * 0.5, + eyeGeo.ry * 0.16, lash, 0.75)); } } + // ── 下巴/脖子的陰影,臉才立體 + out.push(ellipse(0.5, 0.706, 0.104, 0.032, shade(skin, 0.14), 0.32)); + // ── 鼻子(很小一點,但少了它嘴巴會被看成鼻子) out.push(ellipse(0.5, 0.638, 0.010, 0.007, shade(skin, 0.22))); @@ -664,7 +691,16 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { const SS = Math.max(1, Math.min(4, supersample)); const big = size * SS; const acc = Buffer.alloc(big * big * 4); - const shapes = iconShapes(spec); + // 先算每個圖形的外接矩形:不加這個,1024×3 超取樣要跑上億次點測試 + const shapes = iconShapes(spec).map((sh) => { + if (sh.type === "rect") return { ...sh, x0: sh.x, x1: sh.x + sh.w, y0: sh.y, y1: sh.y + sh.h }; + if (sh.type === "poly") { + const xs = sh.points.map((q) => q[0]); + const ys = sh.points.map((q) => q[1]); + return { ...sh, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) }; + } + return { ...sh, x0: sh.cx - sh.rx, x1: sh.cx + sh.rx, y0: sh.cy - sh.ry, y1: sh.cy + sh.ry }; + }); const [g1, g2, g3] = spec.ring; for (let py = 0; py < big; py += 1) { @@ -680,6 +716,7 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { let b = spec.c1[2] + (spec.c2[2] - spec.c1[2]) * t; // 圖形清單:由後往前疊,與 SVG 的繪製順序相同 for (const sh of shapes) { + if (u < sh.x0 || u > sh.x1 || v < sh.y0 || v > sh.y1) continue; let hit; if (sh.type === "rect") { hit = u >= sh.x && u < sh.x + sh.w && v >= sh.y && v < sh.y + sh.h; @@ -758,6 +795,16 @@ export function generateIcon(slug, { pl.writeText(iconSvgPath(slug), svg); fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); fs.writeFileSync(iconPngPath(slug), png); + // icon/ 資料夾:向量原稿 + 多個解析度,Gitea 上點得開也拿得走 + const dir = path.join(pl.personaDir(slug), "icon"); + fs.mkdirSync(dir, { recursive: true }); + const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(svg, "utf8") }]; + pl.writeText(path.join(dir, "portrait.svg"), svg); + for (const px of RENDER_SIZES) { + const buf = px === size ? png : renderPng(spec, px); + fs.writeFileSync(path.join(dir, `portrait-${px}.png`), buf); + renders.push({ name: `portrait-${px}.png`, bytes: buf.length }); + } // 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」 const config = pl.loadConfig(slug); config.icon = { @@ -776,6 +823,7 @@ export function generateIcon(slug, { svg: iconSvgPath(slug), png: iconPngPath(slug), size, + renders, bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length }, }; } diff --git a/scripts/persona.mjs b/scripts/persona.mjs index abe5698..244c242 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1232,6 +1232,7 @@ commands.icon = async ({ flags, positional }) => { `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, + ` icon/:${res.renders.map((r) => `${r.name} ${(r.bytes / 1024).toFixed(0)}KB`).join("、")}`, res.spec.style === "portrait" ? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)` : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 7e3390e..7ccacc1 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -579,7 +579,8 @@ check("形象圖真的畫了五官(眼白/虹膜/瞳孔/嘴都在)", ( const eyeWhite = shapes.filter((sh) => sh.fill.join() === "252,252,255").length; const iris = shapes.filter((sh) => sh.fill.join() === facePal.eye.join()).length; const highlight = shapes.filter((sh) => sh.fill.join() === "255,255,255").length; - return shapes.length >= 20 && eyeWhite === 2 && iris === 2 && highlight === 2; + // 兩隻眼睛:各有眼白、虹膜、至少一個高光;整體圖形數要夠(含頭髮/五官/衣服) + return shapes.length >= 30 && eyeWhite === 2 && iris >= 2 && highlight >= 2; })(), `圖形數 ${ic.iconShapes(faceSpec).length}`); check("形象圖用的是照片配色(髮色當底、瞳色當眼睛)", faceSpec.c1.join() === facePal.hair.join() || faceSpec.c2.join() !== faceSpec.c1.join()); @@ -678,6 +679,41 @@ check("Wiki 首頁不含每次都變的時間戳(否則永遠驗不過)", (( return a === b; })()); +console.log("⑱ 高解析度輸出與 Wiki 圖片實際可讀"); +check("icon/ 資料夾輸出向量原稿與多個解析度", (() => { + cli(["icon", "generate", "--session", S_CODE, "--force", "--size", "64", + "--palette", "hair=#d9a45b,accent=#c0392b", "--source-url", "https://example.invalid/x.png"]); + const dir = path.join(pl.personaDir("GAMMA-01"), "icon"); + const names = fs.readdirSync(dir).sort(); + return names.includes("portrait.svg") && ic.RENDER_SIZES.every((px) => names.includes(`portrait-${px}.png`)); +})(), (() => { try { return fs.readdirSync(path.join(pl.personaDir("GAMMA-01"), "icon")).join(","); } catch { return "(無)"; } })()); +check("高解析度 PNG 的實際尺寸正確", (() => { + const buf = fs.readFileSync(path.join(pl.personaDir("GAMMA-01"), "icon", "portrait-1024.png")); + return buf.readUInt32BE(16) === 1024 && buf.readUInt32BE(20) === 1024; +})()); +check("icon/ 屬於 Wiki 區(會被同步)", covered("icon/portrait-1024.png")[0] === "wiki"); +check("Wiki 只攤平 .md,圖片保留資料夾結構", (() => { + // Gitea 只把「根目錄的 .md」當頁面,但 /wiki/raw/<資料夾>/<圖> 取得到(實測) + return gt.wikiName("memory/long-term/x.md") === "Memory-x.md" && + gt.wikiName("icon/portrait-1024.png") === "icon/portrait-1024.png" && + gt.wikiName("relations/graph.json") === "relations/graph.json"; +})(), `${gt.wikiName("icon/portrait-1024.png")} / ${gt.wikiName("memory/long-term/x.md")}`); +check("Wiki 頁面用 Markdown 圖片語法(HTML 不會被改寫路徑)", (() => { + const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01"); + const home = gt.wikiHome("GAMMA-01", "GAMMA-01"); + return page.includes("![") && !page.includes(" l.includes("img") || l.includes("!["))); +check("Icon 頁會列出 icon/ 裡的每個檔案", (() => { + const page = gt.wikiIconPage("GAMMA-01", "GAMMA-01"); + return ic.RENDER_SIZES.every((px) => page.includes(`icon/portrait-${px}.png`)); +})()); +check("柵格器有做 bounding box 裁剪(1024 才跑得動)", (() => { + const spec = ic.iconSpec("GAMMA-01"); + const t0 = Date.now(); + ic.renderPng(spec, 512); + return Date.now() - t0 < 4000; // 沒有裁剪的話會慢好幾倍 +})()); + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-icon/SKILL.md b/skills/persona-icon/SKILL.md index 9e6b012..eccef59 100644 --- a/skills/persona-icon/SKILL.md +++ b/skills/persona-icon/SKILL.md @@ -140,6 +140,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session `、沒有 base64、沒有外連。 - `badge` 樣式下若兩個顏色一深一淺(藍黑髮 + 淡粉洋裝),程式會把較亮的一端往較暗的壓, 確保字讀得到。 From 929ccb3c05bda8032b68a10d3ea80449fbc22e6b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 03:43:45 +0000 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=E5=BE=9E=E6=89=BE=E5=9C=96?= =?UTF-8?q?=E9=96=8B=E5=A7=8B=E5=84=AA=E5=8C=96=E2=80=94=E2=80=94=E9=AB=98?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=BA=A6=E5=AE=98=E6=96=B9=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E7=A8=BF=20=E2=86=92=20=E5=8E=BB=E8=83=8C=20=E2=86=92=20?= =?UTF-8?q?=E5=90=88=E6=88=90=E7=82=BA=E5=BD=A2=E8=B1=A1=E5=9C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 找圖(icon search) 從 Fandom API 撈角色頁的所有圖片,依「解析度 + 是不是官方設定稿」排序。 官方設定稿(Full Body / Character Design / Avatar)是最好的來源: * 773×1056 起跳,遠勝角色資料庫的 230px 縮圖 * **多半本來就是透明底 PNG**,去背幾乎免費、邊緣完美 新增 icon measure:回報解析度、臉佔比、背景是透明/單色/有場景、去背難度。 2. 去背(icon cutout)→ icon/portrait-cutout.png 三條路徑自動選: source-alpha 原圖已是透明底(官方設定稿常見)→ 完美 plain-background 純白/單色底,色距去背 + 最大連通區 + 補洞 → 很好 grabcut 有場景時用臉的位置當前景種子 → 普通 實測記錄:把臉從 2026 主視覺裁下來再 GrabCut,結衣的黑髮會被整片當成背景切掉; 換成官方設定稿之後這問題直接消失——所以「找對圖」比「去背演算法」更關鍵。 3. 合成(icon generate --from-cutout) 自動裁成頭肩構圖再疊到角色配色的漸層底上。官方設定稿常是正反兩面並排, 不裁會變成兩個人,所以 compose 預設 --crop head(--zoom 可調鬆緊)。 產出 icon.svg(內嵌同一張 PNG,自成一體不外連)、icon.png, 以及 icon/portrait.svg 與 512/1024 兩個解析度。 向量重繪(--features)保留為「找不到可用官方圖」時的退路。 已更新兩個真實人格(皆為 cutout 樣式,Wiki 同步已驗證): ASUNA-01 Asuna's SAO Avatar Full Body(773×1056,透明底)— 對應 2026 《Unanswered//butterfly》重述的早期艾恩葛朗特 YUI-01 Yui's ALO Pixie Form Full Body(773×1056,透明底)— 現行 Unital Ring 章的導航妖精形態 順手修掉兩個 bug: * 合成路徑下 svg 為 null,回傳時 Buffer.byteLength(null) 直接崩潰。 * verifyArea 解析 git status --porcelain 時,因為 git() 會 trim 輸出, 開頭空白已消失(" M x" → "M x"),正規式對不上,檔名前面多一個 M。 selftest 175 項全綠(新增第 ⑲ 節:找圖評分、去背三路徑、compose 預設裁頭肩、 去背圖在 icon/ 會同步、SVG 不外連、沒有去背圖時 --from-cutout 會擋下)。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- AGENTS.md | 12 +- README.md | 29 ++- plugin.json | 2 +- scripts/persona-gitea.mjs | 3 +- scripts/persona-icon.mjs | 137 ++++++++++++-- scripts/persona.mjs | 119 +++++++++++-- scripts/portrait.py | 337 +++++++++++++++++++++++++++-------- scripts/selftest.mjs | 33 ++++ skills/persona-icon/SKILL.md | 154 +++++++--------- 11 files changed, 613 insertions(+), 217 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b6e732d..1541059 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.8", + "version": "0.0.9", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 13d84c1..6cdfeb2 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.8", + "version": "0.0.9", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/AGENTS.md b/AGENTS.md index 9dac0c3..a0a709d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,12 +34,12 @@ **同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST`/`GITEA_TOKEN` 就純本機運作。 11. **人格圖示在資料補齊之後才產生**:SVG 與 PNG 是同一張圖(共用單位座標與點陣字), PNG 由 `scripts/persona-icon.mjs` 自己柵格化+zlib 編碼,**不得引入任何影像函式庫**。 -12. **圖示必須是重新繪製的人物形象圖(有臉)**:上網找出該人格「最新一次登場」的官方視覺 → - `icon headshot` 裁出**大頭照當底稿** → **用 Read 親眼看過** → 讀出髮型/瀏海/眼型/表情/ - 髮飾等特徵 → `icon generate --palette ... --features ...` **重新繪製**。 - **絕對不要把找到的圖片直接當圖示**(底稿只留在 `.sync/`,不同步、不發佈; - 產出的 SVG 不得有 ``/base64/外連)。 - **沒看過圖就不准填顏色或特徵、也不准亂挑臉**(多角色先 `icon faces` 再挑,挑完打開確認)。 +12. **形象圖優先用「高解析度官方圖去背」**:`icon search` → `icon measure` → `icon cutout` + → `icon generate --from-cutout`。找圖時**優先官方設定稿**(Full Body/Character Design/ + Avatar):解析度高,而且多半是透明底或白底,去背幾乎免費。挑的那張要**對得上該人格 + 「最新一次登場」的形態**(同一個角色有很多套造型)。 + 每一步都要**用 Read 打開確認**:去背有沒有殘留、構圖對不對。 + 找不到可用官方圖才退回 `--features` 的向量重繪。 13. **選用工具缺了要「提示安裝」,不准靜默降級**:`toolReport()` 會列出缺什麼、為什麼要、 怎麼裝(venv 免 sudo)。注意 **OpenCV 5 拿掉了 `CascadeClassifier`,必須裝 4.x**。 plugin 本體仍然零依賴:沒有這些工具照樣能產生形象圖。 diff --git a/README.md b/README.md index 9edd9ae..ef7a318 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有 | **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 | | **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01`、`YUI-01`、`ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 | | **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 | -| **12. 圖示是重新繪製的人物形象圖(有臉)** | 上網找出該人格**最新一次登場**的官方視覺 → 裁出**大頭照當底稿** → **親眼看過** → 讀出髮型/瀏海/眼型/表情/髮飾等特徵 → **由本工具依人格資料重新繪製**。**絕不把找到的圖片當圖示**:SVG 裡沒有 ``、沒有 base64、沒有外連,底稿只留在 `.sync/` 不同步。來源網址與重繪依據存進 `config.json` 備查 | +| **12. 形象圖來自高解析度官方圖,去背後合成** | `icon search` 從 Fandom 撈官方圖並依「解析度+是否官方設定稿」排序(設定稿多為透明/白底、773×1056 起跳)→ `icon measure` 確認臉夠大、背景好去 → `icon cutout` 去背成透明 PNG(原生 alpha /單色底/GrabCut 三條路徑)→ `icon generate --from-cutout` 裁頭肩、合成到角色配色的漸層底。找不到官方圖才退回依人格資料重繪的向量形象 | | **13. Wiki 必須保存並同步形象圖** | `icon.svg`、`icon.png` 與 **`icon/` 資料夾(向量原稿 + 512/1024 高解析度)** 都同步到 Wiki 區,另有自動產生的 **Icon** 頁展示與來源。Wiki 頁面一律用 **Markdown 圖片語法**(Gitea 只改寫這種語法為 `/wiki/raw/...`;HTML `` 會變成破圖),圖片保留資料夾結構、只有 `.md` 需要攤平。`icon generate` 推完會回頭驗證,另有 `sync verify` | --- @@ -150,29 +150,24 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段 建立人格**並補齊 IDENTITY/SOUL 之後**產生,512×512。**優先是人物形象圖(看得到臉)**, 依環境有無工具分三種樣式: -流程是 **找圖 → 裁大頭照當底稿 → 親眼看過 → 讀出特徵 → 重新繪製**: +流程:**找官方圖 → 量測 → 去背 → 裁頭肩 → 合成** | 步驟 | 指令 | 產出 | | --- | --- | --- | -| 1. 找臉 | `icon faces --photo <圖>` | 圖裡所有臉的座標(多角色務必先看) | -| 2. 裁底稿 | `icon headshot --photo <圖> --pick N` | `.sync/headshot.png`(**不是圖示、不同步**) | -| 3. 看 | Read 打開底稿 + 讀 IDENTITY/SOUL | 髮型、瀏海、眼型、表情、髮飾、領口 | -| 4. 重繪 | `icon generate --palette ... --features ...` | `icon.svg` + `icon.png` | +| 1. 找圖 | `icon search --wiki --page <角色>` | 候選清單,📐 = 官方設定稿(優先) | +| 2. 量測 | `icon measure --photo <網址>` | 解析度、臉多大、背景透明/單色/有場景 | +| 3. 去背 | `icon cutout --photo <網址>` | `icon/portrait-cutout.png`(透明 PNG) | +| 4. 合成 | `icon generate --from-cutout --palette ...` | `icon.svg` + `icon.png` + `icon/` 多解析度 | + +去背三條路徑自動選:**原生 alpha**(官方設定稿常見,完美)→ **單色底移除**(很好)→ +**GrabCut**(有場景時,邊緣普通;黑髮角色容易被誤切,這時該換設定稿)。 | 樣式 | 條件 | 長什麼樣 | | --- | --- | --- | -| **`portrait`** | 有底稿(給了調色盤)→ 預設 | 依特徵重繪的人物頭像:漸層虹膜+雙高光+睫毛、髮絲與反光、腮紅與下巴陰影 | +| **`cutout`** | 有去背圖 → 最佳 | 官方原圖裁頭肩,疊在角色配色的漸層底上 | +| **`portrait`** | 沒有可用官方圖 | 依特徵重繪的人物頭像,五官俱全 | | **`badge`** | 完全沒有參考圖 | 雙色漸層 + 編號前兩個字母 | -輸出(每次 `icon generate` 都會全部更新): - -``` -icon.svg / icon.png 根目錄:Wiki 頁面與存取庫頭像用(512) -icon/portrait.svg 向量原稿,可無限放大 -icon/portrait-512.png 高解析度 -icon/portrait-1024.png 高解析度 -``` - 柵格器對每個圖形先算 bounding box 再逐點測試,1024×1024(3× 超取樣)約 1.8 秒。 **找到的圖片只能當底稿**:產出的 SVG 沒有 ``、沒有 base64、沒有外連,每個像素都是畫出來的。 @@ -389,7 +384,7 @@ node scripts/persona.mjs said check --session --text "<話>" # 這句是 node scripts/persona.mjs room script --session --room # 乾淨對話稿(劇場模式用) node scripts/persona.mjs export --session --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs sync status --session # Gitea 同步狀態 -node scripts/selftest.mjs # 168 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、形象圖重繪、高解析輸出與 Wiki 同步、hooks +node scripts/selftest.mjs # 175 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、hooks ``` 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、 diff --git a/plugin.json b/plugin.json index c176f0d..08c1a6f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.8", + "version": "0.0.9", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" } diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index 2214218..cb1b2da 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -657,7 +657,8 @@ export async function verifyArea(slug, area, { code = null, owner = null } = {}) } const files = stageArea(slug, area, dir); const dirty = git(["status", "--porcelain"], dir) - .stdout.split("\n").map((l) => l.replace(/^.{2}\s+/, "").trim()).filter(Boolean); + // git() 會把輸出 trim 掉,所以 porcelain 開頭那個空白可能已經不見了(" M x" → "M x") + .stdout.split("\n").map((l) => l.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim()).filter(Boolean); git(["checkout", "--", "."], dir); git(["clean", "-qfd"], dir); return { diff --git a/scripts/persona-icon.mjs b/scripts/persona-icon.mjs index 2c69302..35e5079 100644 --- a/scripts/persona-icon.mjs +++ b/scripts/persona-icon.mjs @@ -788,21 +788,47 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) { */ export function generateIcon(slug, { size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, features = null, + cutout = null, pick = null, face = null, zoom = 2.15, } = {}) { const spec = iconSpec(slug, { code, palette, style, features }); - const svg = renderSvg(spec, size); - const png = renderPng(spec, size); - pl.writeText(iconSvgPath(slug), svg); - fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); - fs.writeFileSync(iconPngPath(slug), png); - // icon/ 資料夾:向量原稿 + 多個解析度,Gitea 上點得開也拿得走 + // 有去背圖就用它合成(官方原圖,解析度高);沒有就回到向量重繪 + let compose = null; + if (cutout && fs.existsSync(cutout)) { + const bg = spec.palette + ? `${hex(tint(spec.palette.light || spec.c1, 0.35))},${hex(spec.palette.accent || spec.c2)}` + : `${hex(spec.c1)},${hex(spec.c2)}`; + compose = composeIcon(cutout, iconPngPath(slug), { size, bg, pick, face, zoom }); + if (compose.ok) spec.style = "cutout"; + } + const svg = compose?.ok ? null : renderSvg(spec, size); + const png = compose?.ok ? fs.readFileSync(iconPngPath(slug)) : renderPng(spec, size); const dir = path.join(pl.personaDir(slug), "icon"); fs.mkdirSync(dir, { recursive: true }); - const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(svg, "utf8") }]; - pl.writeText(path.join(dir, "portrait.svg"), svg); + fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true }); + if (!compose?.ok) fs.writeFileSync(iconPngPath(slug), png); + // SVG:向量樣式直接輸出;去背合成則包一層自成一體的 SVG(base64 內嵌,不外連) + const finalSvg = svg || wrapPngSvg(spec, png, size); + pl.writeText(iconSvgPath(slug), finalSvg); + // icon/ 資料夾:原稿 + 多個解析度,Gitea 上點得開也拿得走 + const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(finalSvg, "utf8") }]; + pl.writeText(path.join(dir, "portrait.svg"), finalSvg); for (const px of RENDER_SIZES) { - const buf = px === size ? png : renderPng(spec, px); - fs.writeFileSync(path.join(dir, `portrait-${px}.png`), buf); + let buf; + if (compose?.ok) { + const target = path.join(dir, `portrait-${px}.png`); + const res = composeIcon(cutout, target, { + size: px, + bg: spec.palette + ? `${hex(tint(spec.palette.light || spec.c1, 0.35))},${hex(spec.palette.accent || spec.c2)}` + : `${hex(spec.c1)},${hex(spec.c2)}`, + pick, face, zoom, + }); + buf = res.ok ? fs.readFileSync(target) : png; + if (!res.ok) fs.writeFileSync(target, buf); + } else { + buf = px === size ? png : renderPng(spec, px); + fs.writeFileSync(path.join(dir, `portrait-${px}.png`), buf); + } renders.push({ name: `portrait-${px}.png`, bytes: buf.length }); } // 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」 @@ -816,6 +842,7 @@ export function generateIcon(slug, { palette: spec.palette ? paletteToString(spec.palette) : null, features: featuresToString(spec.features), source: source || spec.source || null, + cutout: compose?.ok ? { file: CUTOUT_PNG, compose: compose.person || null } : null, }; pl.writeJson(pl.configPath(slug), config); return { @@ -824,10 +851,25 @@ export function generateIcon(slug, { png: iconPngPath(slug), size, renders, - bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length }, + bytes: { svg: Buffer.byteLength(finalSvg, "utf8"), png: png.length }, }; } +/** 去背合成的圖示:SVG 用 base64 內嵌同一張 PNG,兩種格式看到的是同一張圖,且不外連。 */ +export function wrapPngSvg(spec, pngBuffer, size = DEFAULT_SIZE) { + const S = size; + return [ + ``, + ` ${spec.code}${spec.name ? ` ${spec.name}` : ""}`, + ` `, + "", + "", + ].join("\n"); +} + export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug)); // --------------------------------------------------------------------------- // @@ -943,14 +985,19 @@ export async function fetchPhoto(source, destDir) { return abs; } -/** 列出參考照片裡偵測到的所有臉(多角色的圖要先看這個再挑)。 */ -export function listFaces(imagePath) { +export const CUTOUT_PNG = "icon/portrait-cutout.png"; +export const cutoutPath = (slug) => path.join(pl.personaDir(slug), "icon", "portrait-cutout.png"); + +/** 呼叫 portrait.py 的共用包裝。 */ +function runPortrait(args) { const report = toolReport(); if (!report.ready) return { ok: false, report }; - const args = [PORTRAIT_PY, "--input", imagePath, "--list"]; - if (report.cascade) args.push("--cascade", cascadePath()); - const proc = spawnSync(report.python, args, { encoding: "utf8" }); - if (proc.status !== 0) return { ok: false, report, reason: String(proc.stderr).slice(0, 300) }; + const full = [PORTRAIT_PY, ...args]; + if (report.cascade && !args.includes("--cascade")) full.push("--cascade", cascadePath()); + const proc = spawnSync(report.python, full, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); + if (proc.status !== 0) { + return { ok: false, report, reason: String(proc.stderr || proc.stdout).trim().slice(0, 300) }; + } try { return { ...JSON.parse(String(proc.stdout).trim().split("\n").pop()), report }; } catch { @@ -958,6 +1005,62 @@ export function listFaces(imagePath) { } } +/** 列出參考圖裡偵測到的所有臉(多角色的圖要先看這個再挑)。 */ +export const listFaces = (imagePath) => runPortrait(["--mode", "faces", "--input", imagePath]); + +/** 量測一張候選圖:解析度、臉多大、背景好不好去。 */ +export const measureImage = (imagePath) => runPortrait(["--mode", "measure", "--input", imagePath]); + +/** 去背,輸出透明 PNG。優先沿用原圖既有的 alpha(官方人設圖多半就是透明底)。 */ +export const cutoutImage = (imagePath, outPath, { pick = null, face = null } = {}) => + runPortrait([ + "--mode", "cutout", "--input", imagePath, "--output", outPath, + ...(face ? ["--face", face] : pick ? ["--pick", pick] : []), + ]); + +/** 把去背圖裁成頭肩、疊到圓角漸層底上,產出最終圖示。 */ +export const composeIcon = (cutout, outPath, { size = DEFAULT_SIZE, bg = null, pick = null, + face = null, zoom = 2.15, crop = "head" } = {}) => + runPortrait([ + "--mode", "compose", "--input", cutout, "--output", outPath, + "--size", String(size), "--radius", String(GEO.radius), "--zoom", String(zoom), "--crop", crop, + ...(bg ? ["--bg", bg] : []), + ...(face ? ["--face", face] : pick ? ["--pick", pick] : []), + ]); + +/** + * 從 Fandom wiki 找出這個角色的高解析度官方圖。 + * 官方人設圖(Full Body / Character Design)通常是透明底或白底,去背幾乎免費, + * 而且解析度遠高於角色資料庫的縮圖——這是「找圖」這一步最該優先的來源。 + */ +export async function wikiImageCandidates(wiki, page, { limit = 60 } = {}) { + const url = `https://${wiki}.fandom.com/api.php?action=query&generator=images` + + `&titles=${encodeURIComponent(page)}&gimlimit=${limit}&prop=imageinfo&iiprop=url|size&format=json`; + const res = await fetch(url, { headers: { "User-Agent": "jsc-persona/icon" } }); + if (!res.ok) throw new Error(`Fandom API 失敗(HTTP ${res.status})`); + const json = await res.json(); + const pages = json?.query?.pages || {}; + const rows = Object.values(pages) + .map((entry) => ({ title: entry.title, info: entry.imageinfo?.[0] })) + .filter((r) => r.info?.width) + .map((r) => { + const px = r.info.width * r.info.height; + // 「官方設定稿」的關鍵字:這類圖解析度高、背景乾淨,最適合當形象圖 + const settei = /full.?body|character.?design|concept|profile|settei|avatar/i.test(r.title); + return { + title: r.title, + url: String(r.info.url).split("/revision")[0], + width: r.info.width, + height: r.info.height, + pixels: px, + official_sheet: settei, + score: Math.round(Math.log2(px) * 10) / 10 + (settei ? 8 : 0), + }; + }) + .sort((a, b) => b.score - a.score); + return rows; +} + /** * 從參考圖裁出**大頭照**。這張是給 AI 看的**參考**,不是圖示本身—— * 圖示一律由 renderSvg/renderPng 依人格資料重新繪製。 diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 244c242..675a97b 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1140,6 +1140,87 @@ commands.icon = async ({ flags, positional }) => { ]); return; } + if (action === "search") { + // 找圖第一步:從 Fandom wiki 撈這個角色的官方圖,依「解析度 + 是不是官方設定稿」排序 + requireMember(slug, session, Boolean(flags["as-guest"])); + const wiki = str(flags.wiki); + const page = str(flags.page); + if (!wiki || !page) die("需要 `--wiki ` 與 `--page <角色頁名>`(例:--wiki swordartonline --page Yui)。"); + let rows; + try { + rows = await ic.wikiImageCandidates(wiki, page, { limit: num(flags.limit, 60) }); + } catch (err) { + die(err.message); + } + const top = rows.slice(0, num(flags.top, 12)); + emit({ wiki, page, candidates: top }, flags.json, [ + `\`${page}\` 在 ${wiki}.fandom.com 的圖片候選(依解析度與是否官方設定稿排序):`, + ...top.map((r, i) => + ` #${i} ${String(r.width).padStart(5)}×${String(r.height).padEnd(5)}` + + `${r.official_sheet ? " 📐官方設定稿" : " "} ${r.title}\n ${r.url}`), + " 官方設定稿(Full Body/Character Design)通常是透明底或白底,去背幾乎免費,優先選它。", + " 選好之後:`icon measure --photo <網址>` 看臉夠不夠大,再 `icon cutout --photo <網址>`。", + ]); + return; + } + if (action === "measure") { + requireMember(slug, session, Boolean(flags["as-guest"])); + const want = str(flags.photo); + if (!want) die("需要 `--photo <圖片路徑或網址>`。"); + let file; + try { + file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); + } catch (err) { + die(err.message); + } + const res = ic.measureImage(file); + if (!res.ok) { + emit(res, flags.json, [`✖ 量測失敗:${res.reason || "工具不足"}`, + ...ic.installHintLines(res.report || ic.toolReport())]); + process.exit(1); + } + emit(res, flags.json, [ + `解析度 ${res.size.join("×")}(${(res.pixels / 1e6).toFixed(2)} MP)|找到 ${res.faces_found} 張臉` + + `|臉佔長邊 ${(res.face_ratio * 100).toFixed(0)}%`, + `背景:${res.transparent ? "透明底(最佳)" : res.background.plain ? "單色底(好去背)" : "有場景(要靠 GrabCut,可能不乾淨)"}` + + `|去背難度:${res.cutout_easy ? "容易" : "偏難"}`, + res.cutout_easy + ? " → 這張可以用。`icon cutout --photo <同一張>`" + : " → 建議換一張官方設定稿(`icon search` 裡標 📐 的),去背會乾淨很多。", + ]); + return; + } + if (action === "cutout") { + requireOwner(slug, session); + const want = str(flags.photo); + if (!want) die("需要 `--photo <圖片路徑或網址>`。"); + let file; + try { + file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync")); + } catch (err) { + die(err.message); + } + const out = ic.cutoutPath(slug); + fs.mkdirSync(path.dirname(out), { recursive: true }); + const res = ic.cutoutImage(file, out, { + pick: str(flags.pick) || null, + face: str(flags.face) || null, + }); + if (!res.ok) { + emit(res, flags.json, [`✖ 去背失敗:${res.reason || "工具不足"}`, + ...ic.installHintLines(res.report || ic.toolReport())]); + process.exit(1); + } + const method = { "source-alpha": "原圖本來就是透明底", "plain-background": "單色底去除", + grabcut: "GrabCut(有場景,邊緣可能不完美)" }[res.method] || res.method; + emit({ persona: slug, ...res }, flags.json, [ + `✔ 去背完成:${out}`, + ` 方式:${method}|原圖 ${res.source_size.join("×")} → 去背後 ${res.output_size.join("×")}` + + `|不透明佔比 ${(res.opaque_ratio * 100).toFixed(0)}%`, + " 請用 Read 打開確認邊緣乾不乾淨,再 `icon generate --from-cutout --force`。", + ]); + return; + } if (action === "headshot") { // 裁出「參考用大頭照」。這張不是圖示,也不會同步出去——它是給 AI 看的底稿。 requireOwner(slug, session); @@ -1197,7 +1278,7 @@ commands.icon = async ({ flags, positional }) => { ]); return; } - if (action !== "generate") die(`未知 action:${action}(可用 generate/show/faces/headshot)`); + if (action !== "generate") die(`未知 action:${action}(可用 search/measure/faces/headshot/cutout/generate/show)`); requireOwner(slug, session); if (ic.hasIcon(slug) && !flags.force) { die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`); @@ -1227,19 +1308,33 @@ commands.icon = async ({ flags, positional }) => { const style = str(flags.style) || null; if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`); const features = flags.features ? ic.parseFeatures(str(flags.features)) : null; - const res = ic.generateIcon(slug, { size, palette, source, style, features }); + // --from-cutout:用去背好的官方原圖合成(解析度高、忠於原作) + const cutout = flags["from-cutout"] && fs.existsSync(ic.cutoutPath(slug)) ? ic.cutoutPath(slug) : null; + if (flags["from-cutout"] && !cutout) { + die(`還沒有去背圖。先跑 \`icon search\` 找官方設定稿 → \`icon cutout --photo <網址>\`。`); + } + const res = ic.generateIcon(slug, { + size, palette, source, style, features, cutout, + pick: str(flags.pick) || null, + face: str(flags.face) || null, + zoom: num(flags.zoom, 2.15), + }); const lines = [ `✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`, ` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`, ` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`, ` icon/:${res.renders.map((r) => `${r.name} ${(r.bytes / 1024).toFixed(0)}KB`).join("、")}`, - res.spec.style === "portrait" - ? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)` - : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, + res.spec.style === "cutout" + ? ` 形象圖:官方原圖去背後合成(頭肩構圖,解析度取自原圖)` + : res.spec.style === "portrait" + ? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)` + : ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`, ...(res.spec.style === "portrait" ? [` 配色:${ic.paletteToString(res.spec.palette)}`, ` 特徵:${ic.featuresToString(res.spec.features)}`] - : []), + : res.spec.style === "cutout" && res.spec.palette + ? [` 底色:取自 ${ic.paletteToString(res.spec.palette)}`] + : []), ...(source?.url || res.spec.source?.url ? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`] : []), @@ -1435,16 +1530,20 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force) 圖示(建立人格並補齊資料後跑): - icon faces|headshot|generate|show --session - faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看) + icon search|measure|faces|headshot|cutout|generate|show --session + search --wiki --page <角色頁> 找官方圖,依解析度/是否設定稿排序 + measure --photo <網址或路徑> 解析度、臉多大、背景好不好去 + faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看) headshot --photo <...> [--pick <索引>|--face x,y,w,h] [--size 384] 裁出**參考用大頭照**到 .sync/headshot.png(不是圖示、不同步) + cutout --photo <...> [--pick <索引>] 去背 → icon/portrait-cutout.png generate [--size 512 --force --no-gitea --style portrait|badge] [--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.."] [--features "hairstyle=..,length=..,fringe=..,eyes=..,expression=..,accessory=..,side=..,collar=..,ahoge=.."] [--source-url <來源網址> --source-note <說明> --source-date ] - **依人格資料重新繪製**人物頭像;不會把來源圖放進圖示。 - 沒帶 --palette → 徽章樣式(配色由編號/名字/emoji 雜湊)。 + [--from-cutout] [--zoom 2.15] [--pick <索引>] + 帶 --from-cutout → 用去背好的官方原圖合成(頭肩構圖,最忠於原作)。 + 否則依人格資料重新繪製;沒帶 --palette → 徽章樣式。 show 看目前的樣式、配色、特徵與來源 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 diff --git a/scripts/portrait.py b/scripts/portrait.py index 1abb910..7379a93 100644 --- a/scripts/portrait.py +++ b/scripts/portrait.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 -"""從參考照片裁出人物臉部,輸出成人格圖示用的正方形 PNG。 +"""人格形象圖的影像處理:量測、找臉、裁大頭照、去背、合成成圖示。 -這支腳本是**選用的加值工具**:jsc-persona 本體只用 Node 內建模組,沒有它照樣能產生 -向量人物形象。裝了 Pillow(+可選的 OpenCV 動漫臉偵測)之後,圖示就能改用真實照片裁臉。 +這支腳本是**選用的加值工具**:jsc-persona 本體只用 Node 內建模組,沒有它也能畫出 +向量人物形象;裝了 Pillow(+可選的 OpenCV 臉部偵測)之後,就能改用官方圖去背當形象圖。 -用法: - python3 portrait.py --input <圖片> --list # 列出偵測到的所有臉 - python3 portrait.py --input <圖片> --output [--size 512] - [--cascade ] [--pick largest|leftmost|rightmost|] - [--face x,y,w,h] # 直接指定裁切框 +模式: + --mode measure 量測:尺寸、臉的位置、背景是不是單色(決定去背好不好做) + --mode faces 列出偵測到的所有臉 + --mode headshot 裁出大頭照(給人看的底稿) + --mode cutout 去背,輸出透明 PNG + --mode compose 把去背圖合成到圓角漸層底上,產出最終圖示 -多角色的圖片一定要挑臉:`--list` 看有哪些,再用 `--pick` 或 `--face` 指定。 -輸出(stdout):一行 JSON。失敗時 ok=false,並附上 hint。 +輸出(stdout):一行 JSON。失敗時 ok=false 並附上 hint。 """ import argparse @@ -29,13 +29,19 @@ def fail(reason, hint=None): emit({"ok": False, "reason": reason, "hint": hint}) -def detect_faces(path, cascade_path): - """回傳 (faces, method);faces 是 [(x, y, w, h), ...],偵測不到就回 ([], None)。""" +def load_cv2(): try: import cv2 + return cv2 except ImportError: - return [], None + return None + +def detect_faces(path, cascade_path): + """回傳 (faces, method)。faces = [(x,y,w,h), ...]。""" + cv2 = load_cv2() + if cv2 is None: + return [], None candidates = [] if cascade_path and os.path.exists(cascade_path): candidates.append((cascade_path, "anime-cascade")) @@ -44,7 +50,6 @@ def detect_faces(path, cascade_path): frontal = os.path.join(builtin, "haarcascade_frontalface_default.xml") if os.path.exists(frontal): candidates.append((frontal, "frontal-cascade")) - try: image = cv2.imread(path) if image is None: @@ -52,9 +57,6 @@ def detect_faces(path, cascade_path): gray = cv2.equalizeHist(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)) except Exception: return [], None - - # 由嚴到寬試幾組參數:先求準,找不到才放寬 - ladder = [(1.05, 5, 40), (1.05, 3, 32), (1.02, 2, 24)] for xml, method in candidates: try: clf = cv2.CascadeClassifier(xml) @@ -62,7 +64,7 @@ def detect_faces(path, cascade_path): continue except Exception: continue - for sf, mn, ms in ladder: + for sf, mn, ms in [(1.05, 5, 40), (1.05, 3, 32), (1.02, 2, 24)]: try: faces = clf.detectMultiScale(gray, scaleFactor=sf, minNeighbors=mn, minSize=(ms, ms)) except Exception: @@ -89,87 +91,278 @@ def choose(faces, pick): return None +def background_report(img): + """看四個角與外框一圈:底色一不一致、是不是淺色。單色底=去背可以做得很乾淨。""" + w, h = img.size + px = img.convert("RGB").load() + samples = [] + step = max(1, min(w, h) // 60) + for x in range(0, w, step): + samples.append(px[x, 0]) + samples.append(px[x, h - 1]) + for y in range(0, h, step): + samples.append(px[0, y]) + samples.append(px[w - 1, y]) + avg = tuple(sum(c[i] for c in samples) / len(samples) for i in range(3)) + var = sum(max(abs(c[i] - avg[i]) for i in range(3)) for c in samples) / len(samples) + return { + "color": [round(v) for v in avg], + "spread": round(var, 1), # 越小越單色 + "plain": bool(var < 18), # 單色底 + "light": bool(sum(avg) / 3 > 200), + } + + +def alpha_from_plain_bg(img, bg_color, tol=34, feather=1.2): + """單色底去背:離底色越近越透明,再對邊緣做一點羽化。""" + from PIL import Image, ImageFilter + import math + rgb = img.convert("RGB") + w, h = rgb.size + px = rgb.load() + mask = Image.new("L", (w, h), 255) + mp = mask.load() + br, bg_, bb = bg_color + hard = tol * tol + soft = (tol * 2.1) ** 2 + for y in range(h): + for x in range(w): + r, g, b = px[x, y] + d = (r - br) ** 2 + (g - bg_) ** 2 + (b - bb) ** 2 + if d <= hard: + mp[x, y] = 0 + elif d < soft: + mp[x, y] = int(255 * (math.sqrt(d) - tol) / (tol * 1.1)) + # 只留最大的一塊,避免把角色身上和底色相近的區塊也挖掉 + cv2 = load_cv2() + if cv2 is not None: + import numpy as np + m = np.array(mask) + binary = (m > 96).astype("uint8") + binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) + n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8) + if n > 1: + largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) + keep = (labels == largest) + # 洞(例如手臂圍出的空隙)補回來 + filled = cv2.morphologyEx(keep.astype("uint8"), cv2.MORPH_CLOSE, np.ones((15, 15), np.uint8)) + m = np.where(filled > 0, m, 0) + mask = Image.fromarray(m) + return mask.filter(ImageFilter.GaussianBlur(feather)) + + +def alpha_from_grabcut(path, face, iters=8): + """有背景的圖:用臉的位置當前景種子跑 GrabCut。""" + cv2 = load_cv2() + if cv2 is None: + return None + import numpy as np + from PIL import Image + img = cv2.imread(path) + if img is None: + return None + h, w = img.shape[:2] + mask = np.full((h, w), cv2.GC_PR_BGD, np.uint8) + mask[int(h * 0.03):int(h * 0.99), int(w * 0.05):int(w * 0.95)] = cv2.GC_PR_FGD + if face is not None: + fx, fy, fw, fh = face + cx, cy = fx + fw // 2, fy + fh // 2 + cv2.ellipse(mask, (cx, cy), (int(fw * 0.42), int(fh * 0.48)), 0, 0, 360, cv2.GC_FGD, -1) + cv2.ellipse(mask, (cx, int(cy - fh * 0.28)), (int(fw * 0.80), int(fh * 0.70)), 0, 0, 360, + cv2.GC_FGD, -1) + cv2.rectangle(mask, (int(cx - fw * 0.85), int(cy + fh * 0.8)), (int(cx + fw * 0.85), h - 1), + cv2.GC_FGD, -1) + b = max(2, int(min(w, h) * 0.015)) + mask[:b, :] = cv2.GC_BGD + mask[-b:, :] = cv2.GC_BGD + mask[:, :b] = cv2.GC_BGD + mask[:, -b:] = cv2.GC_BGD + bgd, fgd = np.zeros((1, 65), np.float64), np.zeros((1, 65), np.float64) + try: + cv2.grabCut(img, mask, None, bgd, fgd, iters, cv2.GC_INIT_WITH_MASK) + except Exception: + return None + m = np.where((mask == cv2.GC_FGD) | (mask == cv2.GC_PR_FGD), 255, 0).astype("uint8") + n, labels, stats, _ = cv2.connectedComponentsWithStats((m > 0).astype("uint8"), 8) + if n > 1: + largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) + m = np.where(labels == largest, 255, 0).astype("uint8") + m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8)) + m = cv2.GaussianBlur(m, (5, 5), 0) + return Image.fromarray(m) + + +def head_box(img_size, face, zoom): + """由臉的框推出「頭肩構圖」的正方形裁切框。""" + W, H = img_size + if face is None: + side = min(W, H) + cx, cy = W / 2, min(H / 2, side * 0.42) + else: + x, y, w, h = face + cx, cy = x + w / 2, y + h / 2 - h * 0.06 + side = min(max(w, h) * zoom, min(W, H)) + left = int(max(0, min(W - side, cx - side / 2))) + top = int(max(0, min(H - side, cy - side / 2))) + return (left, top, int(left + side), int(top + side)) + + def main(): ap = argparse.ArgumentParser() + ap.add_argument("--mode", default="headshot", + choices=["measure", "faces", "headshot", "cutout", "compose"]) ap.add_argument("--input", required=True) ap.add_argument("--output") ap.add_argument("--size", type=int, default=512) ap.add_argument("--cascade", default=None) - ap.add_argument("--radius", type=float, default=0.22, help="圓角半徑(佔邊長比例)") - ap.add_argument("--pick", default=None, help="largest|leftmost|rightmost|topmost|<由左至右的索引>") + ap.add_argument("--radius", type=float, default=0.22) + ap.add_argument("--pick", default=None) ap.add_argument("--face", default=None, help="直接指定臉的框 x,y,w,h") - ap.add_argument("--list", action="store_true", help="只列出偵測到的臉,不輸出圖") + ap.add_argument("--zoom", type=float, default=2.1, help="裁切框相對臉的倍率") + ap.add_argument("--bg", default=None, help="compose 的底色漸層,例如 #d9a45b,#c0392b") + ap.add_argument("--crop", default="head", choices=["head", "full"], + help="compose 時裁頭肩(預設)還是用整張") args = ap.parse_args() try: - from PIL import Image, ImageDraw + from PIL import Image except ImportError: fail("缺少 Pillow", "pip install pillow") try: - img = Image.open(args.input).convert("RGB") + img = Image.open(args.input) + img.load() except Exception as exc: fail(f"讀不到圖片:{exc}", "確認檔案完整;WebP 需要較新的 Pillow") W, H = img.size faces, method = detect_faces(args.input, args.cascade) + face = None + if args.face: + try: + face = tuple(int(v) for v in args.face.split(",")) + method = "manual-box" + except ValueError: + fail("--face 格式要是 x,y,w,h") + else: + face = choose(faces, args.pick) - if args.list: + if args.mode == "measure": + bg = background_report(img) + big = face[2] if face else 0 + has_alpha = False + if img.mode in ("RGBA", "LA", "PA"): + lo, hi = img.convert("RGBA").getchannel("A").getextrema() + has_alpha = lo < 16 and hi > 200 emit({ - "ok": True, - "size": [W, H], - "method": method, - "faces": [ - {"index": i, "x": f[0], "y": f[1], "w": f[2], "h": f[3], - "center": [f[0] + f[2] // 2, f[1] + f[3] // 2]} - for i, f in enumerate(sorted(faces, key=lambda f: f[0])) - ], + "ok": True, "size": [W, H], "pixels": W * H, "faces_found": len(faces), + "face": list(face) if face else None, "face_ratio": round(big / max(W, H), 3) if face else 0, + "method": method, "background": bg, "transparent": has_alpha, + "cutout_easy": bool(has_alpha or bg["plain"]), + }) + + if args.mode == "faces": + emit({ + "ok": True, "size": [W, H], "method": method, + "faces": [{"index": i, "x": f[0], "y": f[1], "w": f[2], "h": f[3], + "center": [f[0] + f[2] // 2, f[1] + f[3] // 2]} + for i, f in enumerate(sorted(faces, key=lambda f: f[0]))], }) if not args.output: - fail("需要 --output(或用 --list 只看偵測結果)") + fail("這個模式需要 --output") - if args.face: - try: - x, y, w, h = (int(v) for v in args.face.split(",")) - except ValueError: - fail("--face 格式要是 x,y,w,h") - method = "manual-box" - else: - chosen = choose(faces, args.pick) - if chosen: - x, y, w, h = chosen + if args.mode == "headshot": + from PIL import ImageDraw + box = head_box((W, H), face, args.zoom) + out = img.convert("RGB").crop(box).resize((args.size, args.size), Image.LANCZOS).convert("RGBA") + radius = int(args.size * args.radius) + mask = Image.new("L", (args.size, args.size), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, args.size - 1, args.size - 1], radius=radius, fill=255) + out.putalpha(mask) + out.save(args.output, "PNG", optimize=True) + emit({"ok": True, "mode": "headshot", "method": method, "faces_found": len(faces), + "source_size": [W, H], "face": list(face) if face else None, "box": list(box), + "size": args.size, "output": args.output}) + + if args.mode == "cutout": + bg = background_report(img) + # 最好的情況:官方人設圖多半本來就是透明底 PNG,直接沿用既有 alpha + existing = None + if img.mode in ("RGBA", "LA", "PA"): + a = img.convert("RGBA").getchannel("A") + lo, hi = a.getextrema() + if lo < 16 and hi > 200: + existing = a + if existing is not None: + alpha = existing + used = "source-alpha" + elif bg["plain"]: + alpha = alpha_from_plain_bg(img, bg["color"]) + used = "plain-background" else: - method = "heuristic-top-center" - side = min(W, H) - x, y, w, h = int(W / 2 - side * 0.25), int(min(H / 2, side * 0.30) - side * 0.25), \ - int(side * 0.5), int(side * 0.5) + alpha = alpha_from_grabcut(args.input, face) + used = "grabcut" + if alpha is None: + fail("這張圖的背景不是單色,而 OpenCV 不可用,無法去背", + "換一張官方人設圖(通常是白底),或安裝 opencv-python-headless<5") + rgba = img.convert("RGBA") + rgba.putalpha(alpha) + # 裁到實際內容的範圍,邊界不留大片透明 + bbox = rgba.getbbox() + if bbox: + rgba = rgba.crop(bbox) + rgba.save(args.output, "PNG", optimize=True) + hist = rgba.getchannel("A").histogram() + opaque = sum(hist[129:]) + emit({"ok": True, "mode": "cutout", "method": used, "background": bg, + "source_size": [W, H], "output_size": list(rgba.size), + "opaque_ratio": round(opaque / (rgba.size[0] * rgba.size[1]), 3), + "output": args.output}) - # 往外留邊,讓頭髮與肩膀進來一點,構圖才像頭像 - pad = max(w, h) * 0.55 - cx, cy = x + w / 2, y + h / 2 - h * 0.06 - side = min(max(w, h) + pad * 2, min(W, H)) - left = int(max(0, min(W - side, cx - side / 2))) - top = int(max(0, min(H - side, cy - side / 2))) - box = (left, top, int(left + side), int(top + side)) - - face = img.crop(box).resize((args.size, args.size), Image.LANCZOS).convert("RGBA") - radius = int(args.size * args.radius) - mask = Image.new("L", (args.size, args.size), 0) - ImageDraw.Draw(mask).rounded_rectangle([0, 0, args.size - 1, args.size - 1], radius=radius, fill=255) - face.putalpha(mask) - face.save(args.output, "PNG", optimize=True) - - emit({ - "ok": True, - "method": method, - "faces_found": len(faces), - "source_size": [W, H], - "face": [x, y, w, h], - "box": list(box), - "size": args.size, - "output": args.output, - }) + if args.mode == "compose": + from PIL import ImageDraw + cut = img.convert("RGBA") + S = args.size + colors = [(90, 110, 150), (40, 50, 80)] + if args.bg: + parts = [p.strip().lstrip("#") for p in args.bg.split(",")] + try: + colors = [tuple(int(p[i:i + 2], 16) for i in (0, 2, 4)) for p in parts[:2]] + except ValueError: + pass + if len(colors) == 1: + colors *= 2 + canvas = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + draw = ImageDraw.Draw(canvas) + for i in range(S): + t = i / max(1, S - 1) + c = tuple(int(colors[0][k] + (colors[1][k] - colors[0][k]) * t) for k in range(3)) + draw.line([(0, i), (S, i)], fill=c + (255,)) + # 官方人設圖常是「正面+背面」兩張並排的設定稿,整張塞進去會變成兩個人。 + # 所以預設先裁到頭肩構圖(用臉的位置),要整張再指定 --crop full。 + if args.crop == "head": + cfaces, _ = detect_faces(args.input, args.cascade) + cface = choose(cfaces, args.pick) + if args.face: + try: + cface = tuple(int(v) for v in args.face.split(",")) + except ValueError: + pass + if cface is not None: + cut = cut.crop(head_box(cut.size, cface, args.zoom)) + cw, ch = cut.size + scale = (S * 0.98) / max(cw, ch) + nw, nh = max(1, int(cw * scale)), max(1, int(ch * scale)) + person = cut.resize((nw, nh), Image.LANCZOS) + canvas.alpha_composite(person, (int((S - nw) / 2), int((S - nh) / 2))) + radius = int(S * args.radius) + mask = Image.new("L", (S, S), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, S - 1, S - 1], radius=radius, fill=255) + out = Image.new("RGBA", (S, S), (0, 0, 0, 0)) + out.paste(canvas, (0, 0), mask) + out.save(args.output, "PNG", optimize=True) + emit({"ok": True, "mode": "compose", "size": S, "person": [nw, nh], "output": args.output}) if __name__ == "__main__": diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index 7ccacc1..ad17cbc 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -714,6 +714,39 @@ check("柵格器有做 bounding box 裁剪(1024 才跑得動)", (() => { return Date.now() - t0 < 4000; // 沒有裁剪的話會慢好幾倍 })()); +console.log("⑲ 找圖 → 去背 → 合成"); +check("找圖:官方設定稿加權高於一般截圖", (() => { + // wikiImageCandidates 的評分:解析度取 log2,命中 Full Body/Character Design 再加 8 + const big = { title: "File:Scene.png", width: 1920, height: 1080 }; + const sheet = { title: "File:Yui's ALO Pixie Form Full Body.png", width: 773, height: 1056 }; + const score = (r) => Math.round(Math.log2(r.width * r.height) * 10) / 10 + + (/full.?body|character.?design|concept|profile|settei|avatar/i.test(r.title) ? 8 : 0); + return score(sheet) > score(big); +})()); +check("cutout 的三條路徑都有實作", (() => { + const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8"); + return py.includes("source-alpha") && py.includes("plain-background") && py.includes("grabcut"); +})()); +check("portrait.py 有 measure/faces/headshot/cutout/compose 五個模式", (() => { + const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8"); + return ["measure", "faces", "headshot", "cutout", "compose"].every((m) => py.includes(`"${m}"`)); +})()); +check("compose 預設裁頭肩(官方設定稿常是正反兩面,整張會變兩個人)", (() => { + const py = fs.readFileSync(path.join(HERE, "portrait.py"), "utf8"); + return py.includes('args.crop == "head"') && py.includes("head_box"); +})()); +check("去背圖路徑在 icon/(Wiki 區涵蓋)", + ic.CUTOUT_PNG === "icon/portrait-cutout.png" && covered("icon/portrait-cutout.png")[0] === "wiki"); +check("cutout 樣式的 SVG 內嵌同一張 PNG(自成一體、不外連)", (() => { + const spec = ic.iconSpec("GAMMA-01", { palette: ic.parsePalette("hair=#aaa,accent=#333") }); + const svg = ic.wrapPngSvg(spec, Buffer.from([0x89, 0x50, 0x4e, 0x47]), 64); + return svg.includes("data:image/png;base64,") && !svg.includes("http://example") && + svg.includes(" **鐵則:找到的圖片只能當底稿,不能當圖示。** -> 網路上的角色圖是別人的美術作品。這裡的做法是「裁出大頭照 → 看清楚 → -> **依人格資料重新繪製**」,產出的每一個像素都是本工具畫的。 +**流程**:找高解析度官方圖 → 量測 → **去背** → 裁頭肩 → 合成 → 更新頭像與 Wiki。 + +> 找不到可用的官方圖時(沒有設定稿、只有場景截圖且去背不乾淨), +> 才退回「依人格資料重新繪製」的向量形象(`--style portrait`)。 --- @@ -24,107 +25,78 @@ description: 產生或更新人格的形象圖(icon.svg + icon.png,人物頭 ### 1. 查出「最新一次登場」 -用 WebSearch 找這個角色最近的官方動向(新作、新章節、新造型),至少確認: +用 WebSearch 確認這個角色最近的官方動向(新作、新章節、新造型)與**對應的形態名稱** +(例:亞絲娜 → 2026《Unanswered//butterfly》重述早期艾恩葛朗特 → 對應 **SAO Avatar**; +結衣 → 現行 Unital Ring 章 → 對應 **ALO Pixie Form**)。 -- 最新的作品/章節名稱與年份 -- 那個版本的造型敘述(髮色、瞳色、服裝主色) - -搜尋語彙範例:`<角色> <作品> 2026 新作 主視覺`、` 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. 裁出大頭照當底稿 +### 2. 找高解析度官方圖 ```bash -# 先看看圖裡有幾張臉——多角色的主視覺一定要先看 -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon faces --session --photo "<圖片網址或路徑>" - -# 裁出底稿(索引由左到右;也可以用 largest/leftmost/rightmost,或 --face x,y,w,h 自己框) -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon headshot --session --photo "<圖片網址或路徑>" --pick <索引> +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon search --session --wiki --page <角色頁名> ``` -底稿寫到 `<人格>/.sync/headshot.png`,**不是圖示、不會同步、不會發佈**。 +結果依「解析度 + 是不是官方設定稿」排序,標 📐 的是設定稿(`Full Body`/`Character Design`/ +`Avatar`)。**優先選設定稿**: -**多角色的圖務必自己看過再挑**:`icon faces` 只給座標,哪張臉是本人要由你判斷。 -裁完**一定要用 Read 打開確認是本人**——挑錯就是別人的臉。 +- 解析度高(常見 773×1056、1280×880),遠勝角色資料庫的 230px 縮圖 +- **多半是透明底或純白底 → 去背幾乎免費、邊緣乾淨** +- 是官方繪製的正式立繪,不是動畫截圖 -工具不齊時 CLI 會**印出安裝指令**——不會靜默失敗。需要的工具: +挑「與第 1 步的形態相符」的那一張——同一個角色會有很多套造型。 -| 工具 | 做什麼 | 怎麼裝(免 sudo) | -| --- | --- | --- | -| Pillow | 解碼照片、裁切、縮放 | `python3 -m venv ~/.cache/jsc-persona/venv && ~/.cache/jsc-persona/venv/bin/pip install pillow` | -| OpenCV 4.x | 自動找臉的位置 | `~/.cache/jsc-persona/venv/bin/pip install "opencv-python-headless<5"` | -| 動漫臉模型 | OpenCV 內建模型認不出動漫臉 | `curl -sL -o ~/.cache/jsc-persona/lbpcascade_animeface.xml https://raw.githubusercontent.com/nagadomi/lbpcascade_animeface/master/lbpcascade_animeface.xml` | - -> OpenCV **5.x 拿掉了 `CascadeClassifier`**,一定要裝 4.x。 -> venv 放在 `~/.cache/jsc-persona/venv` 會被自動偵測到;也可以用 `PERSONA_PYTHON` 指定別的。 - -### 5. 看著底稿,把特徵讀出來 - -用 Read 打開 `.sync/headshot.png`,**同時**讀這個人格的 `IDENTITY.md`/`SOUL.md`/canon 記憶。 -形象要同時對得起「他長什麼樣」與「他是誰」: - -| 特徵 | 可選值 | 從哪裡判斷 | -| --- | --- | --- | -| `hairstyle` | straight / twintails / ponytail / bob / braid | 底稿的髮型 | -| `length` | short / medium / long / very-long | 底稿的髮長 | -| `fringe` | blunt / parted / swept / curtain | 底稿的瀏海 | -| `eyes` | round / almond / sharp / droopy | 底稿的眼型 | -| `expression` | gentle / bright / calm / neutral | **SOUL 的 Vibe** + 底稿的神情 | -| `accessory` | none / ribbon / clip / flower / hairband | 底稿或最新造型的髮飾 | -| `side` | left / right | 髮飾在哪一側 | -| `collar` | round / v / high / sailor | 服裝領口 | -| `ahoge` | yes / no | 有沒有呆毛 | - -### 6. 重新繪製 +### 3. 量測 ```bash -node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force \ - --palette "hair=#d9a45b,eye=#9e5b3e,accent=#c0392b,secondary=#e77a8e,light=#f2ebe3,skin=#f7ddc4" \ - --features "hairstyle=straight,length=very-long,fringe=parted,eyes=almond,expression=calm,accessory=none,collar=v,ahoge=yes" \ - --source-url "<底稿那張圖的網址>" \ - --source-note "<作品(年份)+底稿是誰+重繪依據:髮型、眼型、神情、服裝>" \ - --source-date +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon measure --session --photo "<圖片網址>" ``` -**畫完一定要用 Read 打開產出的 `icon.png` 看一眼**,不像就調特徵重畫(`--force`)。 +會回報解析度、找到幾張臉、臉佔長邊多少、背景是**透明/單色/有場景**、去背難不難。 +`去背難度:偏難` 就回第 2 步換一張設定稿,不要硬做。 -- **`--palette` 一定要配 `--source-url`**:配色是從哪張圖來的必須留得下來(CLI 會強制)。 -- 兩者都會寫進 `state/config.json` 的 `icon` 欄位,之後 `icon show` 查得到。 -- 產出的 `icon.svg` + `icon.png` 會自動設成 Gitea 存取庫頭像、同步到 Wiki 區, - 並在 Wiki 產生一頁 **Icon**(同時展示 SVG 與 PNG,附上來源網址與裁切框)。 +### 4. 去背 -找不到可靠的參考圖 → **不要硬掰**,直接 `icon generate` 不帶 `--palette`, -用編號雜湊出的配色,並告訴使用者「沒找到最新造型的官方圖,先用預設配色」。 +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon cutout --session --photo "<圖片網址>" [--pick <索引>] +``` + +三條路徑,自動選最好的那條: + +| 方式 | 什麼時候 | 品質 | +| --- | --- | --- | +| `source-alpha` | 原圖本來就是透明底(官方設定稿常見) | 完美 | +| `plain-background` | 純白/單色底 | 很好 | +| `grabcut` | 有場景,用臉的位置當前景種子 | 普通,邊緣可能不乾淨 | + +輸出到 `icon/portrait-cutout.png`。**用 Read 打開確認**:邊緣有沒有殘留、頭髮有沒有被切掉。 +黑髮角色在有場景的圖上特別容易被 GrabCut 誤切——真的不行就換設定稿。 + +### 5. 合成圖示 + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force --from-cutout [--zoom 1.85] [--pick <索引>] \ + --palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.." \ + --source-url "<那張圖的網址>" --source-note "<作品/形態+為什麼選這張>" +``` + +- 自動裁成**頭肩構圖**(官方設定稿常是正反兩面並排,不裁會變成兩個人;`--zoom` 調鬆緊)。 +- 底色漸層取自 `--palette`(`light` → `accent`),所以要先看過圖再填顏色。 +- 產出 `icon.svg`(內嵌同一張 PNG,自成一體不外連)、`icon.png`,以及 + `icon/portrait.svg`、`icon/portrait-512.png`、`icon/portrait-1024.png`。 +- **畫完用 Read 打開看一眼**,構圖不好就調 `--zoom` 或 `--face` 重來。 + +### 6. 沒有官方圖時:向量重繪(有臉) + +裁出大頭照當底稿、親眼看過、讀出特徵,再重繪: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon headshot --session --photo "<圖片網址>" --pick <索引> # 底稿,不同步 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session --force --palette "..." --features "hairstyle=..,eyes=..,expression=.." +``` + +特徵可選值:`hairstyle` straight/twintails/ponytail/bob/braid、`length` short/medium/long/very-long、 +`fringe` blunt/parted/swept/curtain、`eyes` round/almond/sharp/droopy、 +`expression` gentle/bright/calm/neutral、`accessory` none/ribbon/clip/flower/hairband、 +`side` left/right、`collar` round/v/high/sailor、`ahoge` yes/no。 ### 7. 回報 From 042c0e7d4ca8de819d1620ea08a87a319536c070 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 30 Jul 2026 03:46:09 +0000 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20=E7=89=88=E6=9C=AC=E8=99=9F?= =?UTF-8?q?=E6=94=B9=E7=82=BA=200.0.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依使用者指示,把三份 manifest 的 version 從 0.0.9 改回 0.0.3 (PR #3 的全部變更視為同一個版本)。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1541059..d68932e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.9", + "version": "0.0.3", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 6cdfeb2..c5c6c4e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.9", + "version": "0.0.3", "description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index 08c1a6f..b78d576 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-persona", - "version": "0.0.9", + "version": "0.0.3", "description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "skills": "./skills/" }