mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
老大给的 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 的源图。
303 lines
9.7 KiB
Go
303 lines
9.7 KiB
Go
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)
|
||
})
|
||
}
|