Files
HomeAgent/internal/plugins/webui/gzip.go
JianFeeeee ef08ebb7d3 chore(mascot): 立绘 v2 —— 真 alpha 素材 + 头部裁切头像 + 素材判据
老大给的 4 张新立绘里有 3 张是「棋盘格假透明」JPEG(像素是灰白格子,
不是 alpha)。直接进仓库会在深色面板上露出格子,所以先抠成真 alpha 再落盘。

## 为什么主图与头像要分两个文件

现役 mascot.webp 唯一的消费点是**聊天消息的 30px 圆形头像**
(dashboard.js 4 处 aiAvatar + GUI app.js 2 处),概览页并没有放立绘。
主图是坐姿全身,按现有 CSS(object-fit:cover + scale(1.5))裁成圆后
只剩裙子、看不到脸 —— 只换图不做头像版,等于把头像换成一张看不懂的图。

- assets/branding/mascot-xiaozhai.webp        1024² 主立绘(官方展示)
- assets/branding/mascot-xiaozhai-avatar.webp  512² 头部方裁(运行期源)
- internal/plugins/webui/mascot.webp           256² 头像版(原 133KB→24KB)
- cmd/gui/renderer/mascot.webp                 128² 头像版(原 133KB→9KB)

参考稿另存三张(不参与运行):sheet(设定总图)/ portrait(半身)/
turnaround(三视图)。

## 抠图怎么做的(可复现,不是手搓)

背景是 20.45px 周期的棋盘格(237/254 两色)。判据取「中性灰 且 灰度≥228」
的像素做候选,再从图像边界做连通域传播 —— 这样白裙子/白围裙因为不中性
(带蓝调)不会被误判,避免了当年 site/README 记录的 flood-fill 渗色问题。
边界再内缩 1px 抹白边,0.7px 高斯羽化去锯齿。抠完透明占比 55.8%,
深/浅两种背景上目检均无残留格子与白边。

## CSS:scale(1.5) → 1.25

原来的 1.5 是为「全身图裁圆」补的放大。换成头部方裁后,1.5 会切掉两侧
发绳与呆毛 —— 按真实 30px 渲染放大对照看过 1.0/1.15/1.25/1.4 四档。

## 新增判据(含反向验证)

internal/plugins/webui/mascot_asset_test.go 直接读 WebP 容器头,
断言 VP8X 的 Alpha 标志位与 ALPH chunk **同时存在**,外加正方形/≥128px/
≤128KB。理由:这类退化在肉眼 review 里抓不住(缩到 30px 就是一团浅色),
而用户每天都在看;且 site/README.md 记着当年正因为「白底图没法抠」
干脆不放立绘,这个坑值得一个自动化判据。

反向验证(已实跑,非声称):把素材换成 HEAD 旧图,判据报红并准确指出
「Alpha 标志位未置位 / 缺 ALPH chunk / 体积超限」;换回新图转绿。
另有一条独立用例构造「带 VP8X 但不置 Alpha 位」的最小容器,
确认解析器不会对退化素材误判为绿。

## 顺带修的两处

1. assets/docs/{zh,en}/PLUGIN_DEV.md 的 mascot 引用路径是坏的:
   `../../assets/branding/...` 从 assets/docs/zh/ 出发解析成
   `assets/assets/branding/...`,文件不存在(旧版一直是坏的)。
   已改为 `../../branding/...` 并逐条验证路径可解析。
   同时指向新的头像版(那处是 20px 圆形内联小图标)。
2. gzip.go / gzip_test.go 注释里的「mascot.webp(133KB)」改为不带死数字
   的说法 —— 体积会随素材变,注释写死数字只会变成误导。

## 验证

go build ./...            → 0
go vet ./internal/plugins/webui/ ./cmd/gui/ → 干净
gofmt -l                  → 空
go test ./internal/plugins/webui/ -count=1 → ok(15.1s,含新增 2 个用例)

## 没做的事

- 没碰 main、没开 PR、没部署、没重启服务。
- **概览页仍未放立绘**。site/README 里「素材不可用」这条已不成立(素材有
  alpha 了),但版面是否该放图属于设计取舍,不在本次范围 —— 文档已相应改写,
  不再用「素材不行」当理由。
- 抠图对发丝边缘是「够用」不是「完美」:0.7px 羽化在 1024 下看不出来,
  但若将来要印物料级大图,需要重出带原生 alpha 的源图。
2026-10-18 07:38:10 +08:00

303 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"compress/gzip"
"io"
"net/http"
"strings"
"sync"
)
// gzip 中间件:给可压缩的响应加 Content-Encoding: gzip。
//
// ★ 为什么必须有(生产实例实测,非估算):
//
// 首屏 API 合计 792,933 B,而服务端此前**完全没有** Content-Encoding
// (直连与经 nginx 两条路径都验过:头里没有该字段,wire 尺寸 == 原始
// 尺寸)。实测同一份数据 gzip -9 后:
//
// /api/v1/chat/history?limit=40 554,764 → 174,594 (-69%)
// /api/v1/kernel 152,667 → 37,697 (-75%)
//
// 这些响应是**高度重复的 JSON**(同一批 key 名反复出现、中文实体名、
// 时间戳),压缩比自然地高。经公网入口(frp + 移动网络)时,793KB 的
// 首屏与 53MB/h 的空闲轮询都是实打实的流量钱。
//
// 放在哪一层:
//
// 包在 logged **外面**(链:proxyDispatch → gzip → logged → mux)。
// 理由:proxyDispatch 命中时直接 return,响应来自上游(上游自己的
// Content-Encoding 由 httputil 处理),我们不该插手;而门户自身的
// 全部响应(含 requireAPI 的 401/503、requireWeb 的 302 跳转、
// HTML/CSS/JS、全部 JSON API)都该压。
//
// ★ 三个必须显式处理的坑:
//
// 1. **SSE / 流式不能压。** text/event-stream 一旦进了 gzip 缓冲,
// flush 语义就废了(表现为「前端收不到流式,要等缓冲攒够」)。
// 2. **必须透传 http.Flusher。** handler 里有 `w.(http.Flusher)`
// 的类型断言(handleChatEvents / streamOpenAI)。包装 ResponseWriter
// 会让断言失败 ⇒ flusher 为 nil ⇒ 代码走降级分支,SSE 直接坏掉。
// 这不是「顺手加一下」能过的改动。
// 3. **HEAD / 204 / 304 没有 body**,压缩它们只会浪费 CPU 和加坏头。
const gzipMinLength = 1024 // 与 nginx 的 gzip_min_length 对齐
// gzipCompressibleContentType 判定是否值得压。
//
// 压「已经压缩过」的类型是纯浪费:webp/png/jpeg/gzip/zip 再压一遍
// 几乎不缩小,却要付 CPU + 掉帧。webui 自带 mascot.webp(约 25KB)就是这类
// ——注意体积大小与本判定无关:值得压的是「文本」,不是「大文件」。
//
// ★ text/event-stream 明确**不**列(虽然它在通用规则里可压):
// 压它会毁掉 flush 语义。宁可漏压也不要压坏流。
func gzipCompressibleContentType(ct string) bool {
if ct == "" {
return false
}
// 取分号前的主类型(content-type 可能带 charset)
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
ct = strings.TrimSpace(strings.ToLower(ct))
switch ct {
case "application/json", "application/javascript", "text/javascript",
"text/html", "text/css", "text/plain",
"application/xml", "text/xml", "image/svg+xml":
return true
}
return false
}
// acceptsGzip 判断客户端是否要 gzip。
func acceptsGzip(r *http.Request) bool {
for _, v := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
if i := strings.IndexByte(v, ';'); i >= 0 {
v = v[:i]
}
if strings.EqualFold(strings.TrimSpace(v), "gzip") {
return true
}
}
return false
}
// gzipWriter 池:gzip.NewWriter 每次都要分配窗口/哈希状态,
// 而 webui 的 API 响应极频繁,不复用会让 GC 压力反噬我们要省的目的。
var gzipPool = sync.Pool{
New: func() any { return gzip.NewWriter(io.Discard) },
}
// gzip 响应的三个状态。写成枚举而不是几个 bool —— 上一版用
// passthrough/decided/buffering/allowBuf 四个 bool 交叉表示,
// 出现了「小响应内容被写成空」和「已压缩类型仍被压」两个 bug,
// 根因就是「到底该不该压」在 Write / WriteHeader / 收尾三处各判一次、
// 判据还不一致。**单一判据在单一处求值**是这里的硬要求。
type gzipMode int
const (
// gzipUndecided:还没看过 Content-Type,不知道该不该压。
gzipUndecided gzipMode = iota
// gzipPassThrough:不该压(或不能压),原样透传。
gzipPassThrough
// gzipBuffering:可压且已决定压,但还没写够阈值,先攒着。
gzipBuffering
// gzipStreaming:正在边收边压(已越过阈值)。
gzipStreaming
)
// gzipResponseWriter 包装 ResponseWriter,边写边压。
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
mode gzipMode
wroteHeader bool
status int
buf []byte
}
func (g *gzipResponseWriter) WriteHeader(code int) {
if g.wroteHeader {
return
}
g.status = code
// 无 body 的状态码不压,也不加 Content-Encoding。
if code == http.StatusNoContent || code == http.StatusNotModified {
g.commit(gzipPassThrough)
return
}
// 内容类型不可压(如 image/webp):透传,头照常发。
if !gzipCompressibleContentType(g.Header().Get("Content-Type")) {
g.commit(gzipPassThrough)
return
}
// 可压,但**先不发头**:Content-Length 一旦发出就不能改,
// 得先知道最终写多少字节才能决定压不压(小响应压了反而变大)。
// 真正的 commit 发生在首次 Write 越过阈值、或 handler 返回时。
}
func (g *gzipResponseWriter) Write(p []byte) (int, error) {
switch g.mode {
case gzipPassThrough:
g.commit(gzipPassThrough)
return g.ResponseWriter.Write(p)
case gzipStreaming:
// 已开压:直接喂进 gzip 流。注意此时若下游还没 WriteHeader 过,
// startCompress 已经替我们发过了(见 commit)。
if g.gz == nil {
return g.ResponseWriter.Write(p)
}
return g.gz.Write(p)
case gzipBuffering:
g.buf = append(g.buf, p...)
if len(g.buf) >= gzipMinLength {
g.startCompress()
g.writeBufToStream()
}
return len(p), nil
default: // gzipUndecided
// WriteHeader 没被显式调用(handler 直接 Write)也走这里。
if !gzipCompressibleContentType(g.Header().Get("Content-Type")) {
g.commit(gzipPassThrough)
return g.ResponseWriter.Write(p)
}
g.buf = append(g.buf, p...)
if len(g.buf) >= gzipMinLength {
g.startCompress()
g.writeBufToStream()
} else {
g.mode = gzipBuffering
}
return len(p), nil
}
}
// writeBufToStream 把缓冲内容送进 gzip 流。写失败(客户端已断开)在
// 响应收尾阶段无法处置,与 close/Flush 中的处理一致地忽略。
func (g *gzipResponseWriter) writeBufToStream() {
if g.gz != nil && len(g.buf) > 0 {
_, _ = g.gz.Write(g.buf)
}
g.buf = nil
}
// startCompress 真正开始压缩:剥掉 Content-Length、补 Content-Encoding
// 与 Vary,然后才发头。
func (g *gzipResponseWriter) startCompress() {
h := g.Header()
h.Del("Content-Length") // 压缩后长度未知,留着就是错的
h.Set("Content-Encoding", "gzip")
// Vary:同一 URL 会因 Accept-Encoding 不同而返回不同编码。中间缓存
// (nginx/CDN/浏览器)必须据此区分,否则会把 gzip 版发给不支持
// 压缩的客户端。
h.Add("Vary", "Accept-Encoding")
if g.gz == nil {
g.gz = gzipPool.Get().(*gzip.Writer)
g.gz.Reset(g.ResponseWriter)
}
g.commit(gzipStreaming)
}
// commit 定模式并发头(幂等)。
func (g *gzipResponseWriter) commit(mode gzipMode) {
if g.wroteHeader {
g.mode = mode
return
}
g.mode = mode
g.wroteHeader = true
if g.status == 0 {
g.status = http.StatusOK
}
g.ResponseWriter.WriteHeader(g.status)
}
// Flush 透传:SSE 依赖它逐帧下发。
//
// ★ 存在即必须正确:handler 里是 `w.(http.Flusher)` 断言,
// 拿不到就等于没有 Flush,SSE 会卡到缓冲满。
//
// 若此刻仍在缓冲(可压但没写够阈值),必须先把已攒的内容发出去,
// 否则 Flush 形同虚设、且数据永远滞留缓冲。
func (g *gzipResponseWriter) Flush() {
switch g.mode {
case gzipUndecided:
// 没写过任何东西就 Flush(少见):先把头发出去。
g.commit(gzipPassThrough)
case gzipBuffering:
// 有内容但没到阈值:SSE 场景不该走到这;真走到了就直接定夺——
// 有内容就压(已经攒了半天,收益远大于 23 字节的头开销)。
if len(g.buf) > 0 {
g.startCompress()
g.writeBufToStream()
} else {
g.commit(gzipPassThrough)
}
case gzipStreaming:
if g.gz != nil {
_ = g.gz.Flush()
}
}
if f, ok := g.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// finish 在 handler 返回后收尾:把「攒着没发」的内容定夺掉。
func (g *gzipResponseWriter) finish() {
switch g.mode {
case gzipBuffering:
if len(g.buf) >= gzipMinLength {
// 攒够阈值:压。
g.startCompress()
g.writeBufToStream()
} else {
// 小于阈值(典型:/api/v1/status 197B):**原样发出**。
// 这一支是「小响应不压」判据的落地点 —— 压它反而更大。
g.commit(gzipPassThrough)
if len(g.buf) > 0 {
_, _ = g.ResponseWriter.Write(g.buf)
}
g.buf = nil
}
case gzipUndecided:
// handler 没写过 body(如只 WriteHeader)但我们压住了头:
// 按「无 body」处理,原样发头。
g.commit(gzipPassThrough)
}
}
// close 关闭 gzip 流并归还池。
func (g *gzipResponseWriter) close() {
if g.gz != nil {
_ = g.gz.Close()
g.gz.Reset(io.Discard)
gzipPool.Put(g.gz)
g.gz = nil
}
}
// gzipMW 是压缩中间件。
func gzipMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// HEAD 没有 body;不协商编码。
if r.Method == http.MethodHead || !acceptsGzip(r) {
next.ServeHTTP(w, r)
return
}
// SSE 直接透传:压缩会毁掉 flush 语义(见文件头注释)。
if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
next.ServeHTTP(w, r)
return
}
gw := &gzipResponseWriter{ResponseWriter: w}
defer func() {
gw.finish()
gw.close()
}()
next.ServeHTTP(gw, r)
})
}