diff --git a/assets/branding/mascot-xiaozhai-avatar.webp b/assets/branding/mascot-xiaozhai-avatar.webp
new file mode 100644
index 00000000..7914f9df
Binary files /dev/null and b/assets/branding/mascot-xiaozhai-avatar.webp differ
diff --git a/assets/branding/mascot-xiaozhai-guide.md b/assets/branding/mascot-xiaozhai-guide.md
index 51997114..ba1752bf 100644
--- a/assets/branding/mascot-xiaozhai-guide.md
+++ b/assets/branding/mascot-xiaozhai-guide.md
@@ -258,6 +258,33 @@ dynamic flowing hair and fabric,
---
+## 素材清单(assets/branding/)
+
+| 文件 | 尺寸 | 用途 | alpha |
+|------|------|------|-------|
+| `mascot-xiaozhai.webp` | 1024×1024 | **主立绘**(坐姿全身,官方展示用) | ✅ 真 alpha |
+| `mascot-xiaozhai-avatar.webp` | 512×512 | **头像**(头部方裁,脸居中) | ✅ 真 alpha |
+| `mascot-xiaozhai-sheet.webp` | 1024×1536 | 设定总图(配色/通道线/道具标注) | — |
+| `mascot-xiaozhai-portrait.webp` | 1024×1536 | 半身胸像(三只工具特写清晰) | — |
+| `mascot-xiaozhai-turnaround.webp` | 1200×800 | 三视图(正/侧/背) | — |
+
+**为什么主图与头像分两个文件**:主图是全身坐姿,直接塞进 30px 圆形头像里
+(`object-fit: cover` 会把画面中心裁成正方形)只会剩裙子、看不到脸。
+头像版是**头部方裁**,圆形裁切后脸正好居中。
+
+运行期副本(由上面两张生成,勿单独手改):
+
+| 路径 | 尺寸 | 说明 |
+|------|------|------|
+| `internal/plugins/webui/mascot.webp` | 256×256 | WebUI 对外 `/mascot.webp`(嵌入二进制) |
+| `cmd/gui/renderer/mascot.webp` | 128×128 | GUI 聊天头像 + agent 光标(以 data URL 注入) |
+
+这两个文件的 alpha 由 `internal/plugins/webui/mascot_asset_test.go` 判定:
+直接读 WebP 容器头,要求 VP8X Alpha 标志位与 `ALPH` chunk 同时存在。
+换素材时若塞进一张「白底图」或「棋盘格图」,判据会红。
+
+---
+
## 设计注意事项
1. **三色丝带是核心识别元素**,在任何立绘/插画中必须保留
diff --git a/assets/branding/mascot-xiaozhai-portrait.webp b/assets/branding/mascot-xiaozhai-portrait.webp
new file mode 100644
index 00000000..ff397204
Binary files /dev/null and b/assets/branding/mascot-xiaozhai-portrait.webp differ
diff --git a/assets/branding/mascot-xiaozhai-sheet.webp b/assets/branding/mascot-xiaozhai-sheet.webp
new file mode 100644
index 00000000..0ae12b9c
Binary files /dev/null and b/assets/branding/mascot-xiaozhai-sheet.webp differ
diff --git a/assets/branding/mascot-xiaozhai-turnaround.webp b/assets/branding/mascot-xiaozhai-turnaround.webp
new file mode 100644
index 00000000..4de33544
Binary files /dev/null and b/assets/branding/mascot-xiaozhai-turnaround.webp differ
diff --git a/assets/branding/mascot-xiaozhai.webp b/assets/branding/mascot-xiaozhai.webp
index 9c006401..9c384f40 100644
Binary files a/assets/branding/mascot-xiaozhai.webp and b/assets/branding/mascot-xiaozhai.webp differ
diff --git a/assets/docs/en/PLUGIN_DEV.md b/assets/docs/en/PLUGIN_DEV.md
index d6b9146f..0b01c684 100644
--- a/assets/docs/en/PLUGIN_DEV.md
+++ b/assets/docs/en/PLUGIN_DEV.md
@@ -2,7 +2,7 @@
# HomeAgent Plugin Development Guide
-
:
+
:
## Overview
@@ -35,7 +35,7 @@ type Plugin interface {
---
-
:
+
:
## 1. Quick Start: Using the hmapdev Toolchain
@@ -243,7 +243,7 @@ curl -X POST http://127.0.0.1:8080/api/v1/plugins \
---
-
:
+
:
## 2. Go Plugin Development in Detail
@@ -538,7 +538,7 @@ outputCh := s.OutputChan()
---
-
:
+
:
## 3. Lua Plugin Development in Detail
@@ -680,7 +680,7 @@ Each `attachments` item: `{ digest=, mime=, name=, data= }`; with `data`
---
-
:
+
:
## 4. Built-in Plugins
@@ -745,7 +745,7 @@ import (
---
-
:
+
:
## 5. Best Practices
@@ -759,7 +759,7 @@ import (
---
-
:
+
:
## 6. Plugin Management
@@ -801,7 +801,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
---
-
:
+
:
## 7. Example Plugin Reference
diff --git a/assets/docs/zh/PLUGIN_DEV.md b/assets/docs/zh/PLUGIN_DEV.md
index c5ef5898..f032857f 100644
--- a/assets/docs/zh/PLUGIN_DEV.md
+++ b/assets/docs/zh/PLUGIN_DEV.md
@@ -2,7 +2,7 @@
# HomeAgent 插件开发指南
-
:
+
:
## 概述
@@ -36,7 +36,7 @@ type Plugin interface {
---
-
:
+
:
## 一、快速开始:使用 hmapdev 工具链
@@ -241,7 +241,7 @@ curl -X POST http://127.0.0.1:8080/api/v1/plugins \
---
-
:
+
:
## 二、Go 插件开发详解
@@ -531,7 +531,7 @@ outputCh := s.OutputChan()
---
-
:
+
:
## 三、Lua 插件开发详解
@@ -673,7 +673,7 @@ end)
---
-
:
+
:
## 四、内置插件
@@ -738,7 +738,7 @@ import (
---
-
:
+
:
## 五、最佳实践
@@ -752,7 +752,7 @@ import (
---
-
:
+
:
## 六、插件管理
@@ -792,7 +792,7 @@ pmgr.ReloadPlugins() // 重载所有插件
---
-
:
+
:
## 七、示例插件参考
diff --git a/cmd/gui/renderer/mascot.webp b/cmd/gui/renderer/mascot.webp
index 9c006401..bf3aaf61 100644
Binary files a/cmd/gui/renderer/mascot.webp and b/cmd/gui/renderer/mascot.webp differ
diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css
index f3a7ff28..2b7b49ac 100644
--- a/internal/plugins/webui/dashboard.css
+++ b/internal/plugins/webui/dashboard.css
@@ -1417,8 +1417,11 @@
object-fit: cover;
display: block;
}
+ /* mascot.webp 是**头部方裁**(脸居中),cover 已能把脸填满圆形。
+ 原先 scale(1.5) 是给「全身立绘裁圆」补的——现在图本身换了,
+ 再放大 1.5 会切掉两侧发绳与呆毛,所以降到 1.25 只做轻微推近。 */
.msg-assistant .msg-avatar img {
- transform: scale(1.5);
+ transform: scale(1.25);
}
.msg-avatar svg {
display: block;
diff --git a/internal/plugins/webui/gzip.go b/internal/plugins/webui/gzip.go
index ac896222..db2b34cb 100644
--- a/internal/plugins/webui/gzip.go
+++ b/internal/plugins/webui/gzip.go
@@ -45,7 +45,8 @@ const gzipMinLength = 1024 // 与 nginx 的 gzip_min_length 对齐
// gzipCompressibleContentType 判定是否值得压。
//
// 压「已经压缩过」的类型是纯浪费:webp/png/jpeg/gzip/zip 再压一遍
-// 几乎不缩小,却要付 CPU + 掉帧。webui 自带 mascot.webp(133KB)就是这类。
+// 几乎不缩小,却要付 CPU + 掉帧。webui 自带 mascot.webp(约 25KB)就是这类
+// ——注意体积大小与本判定无关:值得压的是「文本」,不是「大文件」。
//
// ★ text/event-stream 明确**不**列(虽然它在通用规则里可压):
// 压它会毁掉 flush 语义。宁可漏压也不要压坏流。
diff --git a/internal/plugins/webui/gzip_test.go b/internal/plugins/webui/gzip_test.go
index 9c7f1070..8eb5b311 100644
--- a/internal/plugins/webui/gzip_test.go
+++ b/internal/plugins/webui/gzip_test.go
@@ -112,7 +112,7 @@ func TestGzipSkipsSmallResponses(t *testing.T) {
// TestGzipSkipsAlreadyCompressedTypes 钉住「已压缩类型不压」。
//
-// mascot.webp(133KB)在内。webp/png/jpeg/gzip 再压一遍几乎不缩小,
+// mascot.webp 在内。webp/png/jpeg/gzip 再压一遍几乎不缩小,
// 纯浪费 CPU——而 CPU 正是压缩要省的东西。
func TestGzipSkipsAlreadyCompressedTypes(t *testing.T) {
for _, ct := range []string{"image/webp", "image/png", "application/zip", "application/gzip"} {
diff --git a/internal/plugins/webui/mascot.webp b/internal/plugins/webui/mascot.webp
index 9c006401..45036e08 100644
Binary files a/internal/plugins/webui/mascot.webp and b/internal/plugins/webui/mascot.webp differ
diff --git a/internal/plugins/webui/mascot_asset_test.go b/internal/plugins/webui/mascot_asset_test.go
new file mode 100644
index 00000000..acfbec15
--- /dev/null
+++ b/internal/plugins/webui/mascot_asset_test.go
@@ -0,0 +1,167 @@
+package webui
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "testing"
+)
+
+// 这个小测试钉住一件很具体、也很容易被无声破坏的事:
+// **看板娘素材必须自带真 alpha,而不是「白底图」或「棋盘格假透明」。**
+//
+// 为什么值得一个判据:
+//
+// 历史上 mascot.webp 是不透明 WebP(白底 + 蓝紫渐变外框)。放在深色面板的
+// 30px 圆形头像里,就是一个白方块;site/README.md 里记着因为「没法抠图」
+// 最终干脆不放立绘。后来换素材时又出现过「棋盘格 JPEG」—— 那种图**看起来**
+// 是透明的,实际像素是灰白格子,直接塞进仓库依然会露出格子。
+//
+// 这类退化在肉眼 review 里几乎抓不住(缩到 30px 就是一团浅色),但用户每天
+// 都在看。所以用容器格式本身来判定:判据看的是 VP8X 的 Alpha 标志位与
+// ALPH chunk 是否**一致存在**,而不是「渲染出来像不像透明」。
+func TestMascotAssetHasRealAlpha(t *testing.T) {
+ data, err := dashboardFS.ReadFile("mascot.webp")
+ if err != nil {
+ t.Fatalf("读 mascot.webp: %v", err)
+ }
+
+ info, err := parseWebPInfo(data)
+ if err != nil {
+ t.Fatalf("mascot.webp 不是合法 WebP: %v", err)
+ }
+
+ if !info.hasAlphaFlag {
+ t.Errorf("mascot.webp 的 VP8X Alpha 标志位未置位(flags=0x%02x)——"+
+ "说明这张图没有真 alpha 通道,深色面板下会露出白底/棋盘格", info.vp8xFlags)
+ }
+ if !info.hasALPHChunk {
+ t.Errorf("mascot.webp 缺 ALPH chunk —— alpha 标志位与实际数据不一致")
+ }
+
+ // 尺寸:30px 圆形头像在 2x 屏上需要 60px;留够余量但别把大图塞进二进制。
+ if info.width != info.height {
+ t.Errorf("mascot.webp 应为正方形(圆形头像裁剪依赖它),实际 %dx%d", info.width, info.height)
+ }
+ if info.width < 128 {
+ t.Errorf("mascot.webp 只有 %dpx,2x/3x 屏上会糊(要求 >= 128)", info.width)
+ }
+ if len(data) > 128<<10 {
+ t.Errorf("mascot.webp 体积 %.1fKB 超过 128KB —— 它进二进制、每次请求都发,"+
+ "头像用途不需要这个量级", float64(len(data))/1024)
+ }
+}
+
+// 反向验证:把真实素材「退化」成无 alpha 的容器,确认判据会报红。
+//
+// 这条来自实测教训:形状类判据如果只喂真实数据,它可能对任何输入都绿。
+// 这里不依赖外部工具,直接构造一个带 VP8X 但不置 Alpha 位的最小容器。
+func TestMascotAssetHasRealAlpha_RejectsOpaqueContainer(t *testing.T) {
+ var chunks bytes.Buffer
+ payload := make([]byte, 10) // flags=0x00:Alpha 位为 0
+ payload[4], payload[5] = 0x7f, 0x00 // 宽 = 0x7f+1 = 128
+ payload[7], payload[8] = 0x7f, 0x00 // 高 = 0x7f+1 = 128
+ writeChunk(&chunks, "VP8X", payload)
+
+ info, err := parseWebPInfo(buildRIFF(chunks.Bytes()))
+ if err != nil {
+ t.Fatalf("构造的容器应能被解析: %v", err)
+ }
+ if info.hasAlphaFlag {
+ t.Fatal("容器本来就声明不透明,解析器却报了 Alpha —— 判据会对退化素材失效")
+ }
+ if info.hasALPHChunk {
+ t.Fatal("构造的容器里没有 ALPH chunk,解析器却说有")
+ }
+}
+
+type webpInfo struct {
+ width, height int
+ vp8xFlags byte
+ hasAlphaFlag bool
+ hasALPHChunk bool
+}
+
+var errNotWebP = errors.New("不是 RIFF/WEBP 容器")
+
+// parseWebPInfo 只解析容器头,不解码像素 —— 判据要看的是「格式声明」,
+// 不是「看起来透明」。纯标准库即可,不给依赖加负担。
+func parseWebPInfo(data []byte) (*webpInfo, error) {
+ if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
+ return nil, errNotWebP
+ }
+ info := &webpInfo{}
+ // 遍历 RIFF chunk:4 字节 ID + 4 字节 LE 长度 + 负载(按 2 字节对齐)。
+ for off := 12; off+8 <= len(data); {
+ id := string(data[off : off+4])
+ size := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
+ payload := off + 8
+ if payload+size > len(data) {
+ break
+ }
+ switch id {
+ case "VP8X":
+ if size >= 10 {
+ info.vp8xFlags = data[payload]
+ info.hasAlphaFlag = info.vp8xFlags&0x10 != 0 // bit 4 = Alpha
+ // 画布尺寸存的是「实际值 - 1」,各 3 字节 LE。
+ info.width = int(readUint24LE(data[payload+4:])) + 1
+ info.height = int(readUint24LE(data[payload+7:])) + 1
+ }
+ case "ALPH":
+ info.hasALPHChunk = true
+ case "VP8 ":
+ // 无损/有损的「简单格式」(无 VP8X):宽高在帧头里。
+ // 这种文件必然没有 alpha —— 正是判据要拒的那种。
+ if size >= 10 && data[payload+3] == 0x9d && data[payload+4] == 0x01 && data[payload+5] == 0x2a {
+ info.width = int(binary.LittleEndian.Uint16(data[payload+6:]) & 0x3FFF)
+ info.height = int(binary.LittleEndian.Uint16(data[payload+8:]) & 0x3FFF)
+ }
+ case "VP8L":
+ if size >= 5 {
+ // 无损格式:signature 0x2f + 14bit 宽 + 14bit 高 + alpha_is_used 位
+ b := data[payload+1 : payload+5]
+ info.width = int(binary.LittleEndian.Uint16(b)&0x3FFF) + 1
+ info.height = int(binary.LittleEndian.Uint16(b[2:])&0x3FFF) + 1
+ if data[payload+4]&0x10 != 0 {
+ info.hasAlphaFlag = true
+ }
+ }
+ }
+ adv := payload + size
+ if size%2 == 1 {
+ adv++ // RIFF 要求 chunk 负载补齐到偶数
+ }
+ off = adv
+ }
+ if info.width == 0 || info.height == 0 {
+ return nil, errNotWebP
+ }
+ return info, nil
+}
+
+func readUint24LE(b []byte) int {
+ return int(b[0]) | int(b[1])<<8 | int(b[2])<<16
+}
+
+func writeChunk(buf *bytes.Buffer, id string, payload []byte) {
+ buf.WriteString(id)
+ var sz [4]byte
+ binary.LittleEndian.PutUint32(sz[:], uint32(len(payload)))
+ buf.Write(sz[:])
+ buf.Write(payload)
+ if len(payload)%2 == 1 {
+ buf.WriteByte(0)
+ }
+}
+
+func buildRIFF(chunks []byte) []byte {
+ var out bytes.Buffer
+ out.WriteString("RIFF")
+ var sz [4]byte
+ binary.LittleEndian.PutUint32(sz[:], uint32(4+len(chunks)))
+ out.Write(sz[:])
+ out.WriteString("WEBP")
+ out.Write(chunks)
+ return out.Bytes()
+}
diff --git a/site/README.md b/site/README.md
index e57382ed..91ee23a5 100644
--- a/site/README.md
+++ b/site/README.md
@@ -84,18 +84,24 @@ server {
所有 listener 走 `requestAnimationFrame` 节流;标签页隐藏时暂停粒子。
-## 为什么最终没有图
+## 看板娘素材:从「没法用」到「能用了」
-试过放看板娘立绘,失败,最后决定不放。过程留在这里,免得下次重复踩:
+> 结论先写在这:**素材侧的问题已经解决**(现役 `mascot-xiaozhai.webp` 自带真 alpha,
+> 1024×1024)。下面保留当年的失败记录,因为「为什么当时不行」这件事本身
+> 仍是有用约束——它解释了为什么不能拿一张白底图硬塞进深色版面。
-原图 `mascot-xiaozhai.webp` 是**不透明** WebP(无 alpha),白底 + 蓝紫渐变外框。
+### 当年为什么不行
+
+试过放看板娘立绘,失败,最后决定不放。
+
+原图(旧版)是**不透明** WebP(无 alpha),白底 + 蓝紫渐变外框。
直接用 = 深色页面上一个刺眼的白色方块;抠图又不可行:
```bash
python3 -c "
from PIL import Image
im = Image.open('assets/branding/mascot-xiaozhai.webp')
-print(im.mode) # RGB —— 没有 alpha 通道
+print(im.mode) # 旧版是 RGB —— 没有 alpha 通道
"
```
@@ -104,8 +110,28 @@ print(im.mode) # RGB —— 没有 alpha 通道
「圆角卡面 + 发光边框 + 底部渐隐」,视觉上能接受,但本质上是在方形图上盖装饰,
而非让图融入页面。
-结论:**这类素材要么重新出一张带透明通道的图,要么就不放**。
-现在 Hero 是单栏文字,版面反而更稳。
+### 现在为什么能了
+
+新版素材走的是另一条路:出图时背景就是**棋盘格提示**,再按「背格连通域 +
+中性灰判据」抠成真 alpha,落盘为带 `ALPH` chunk 的 WebP。
+
+```bash
+python3 -c "
+from PIL import Image
+im = Image.open('assets/branding/mascot-xiaozhai.webp')
+print(im.mode) # RGBA
+"
+```
+
+同一件事在代码里也有判据钉住:`internal/plugins/webui/mascot_asset_test.go`
+直接读 WebP 容器头,断言 VP8X 的 Alpha 标志位与 `ALPH` chunk 同时存在——
+防止日后有人把一张「看起来透明」的棋盘格 JPEG 换进来。
+
+### 但版面仍未放图
+
+素材不再是阻碍,**但这不等于 Hero 就该放图**。现版面是单栏文字,加图会改变
+信息密度与首屏节奏,属于设计取舍而非素材问题——所以这一步没有动。
+要放的话,素材已经就绪。
## 插件卡片