refactor(webui): handler.go 2993 行按资源拆成 11 个同包文件

拆法:按「资源面」搬家,每个顶层声明(func/type/var/const)整体搬到目标文件,
声明体一字未改,各文件按实际用到的包重新生成 import。文件头加一行说明本文件负责哪一面。

  handler.go              骨架:嵌入前端资源、Handler/构造、路由表、鉴权会话日志中间件、静态页
  handler_chat.go         对话面:消息模型与内存历史、SSE 事件订阅、对话/历史接口
  handler_upload.go       上传面:handleChatFile / handleUploads / 中断对话
  handler_memory.go       记忆面:图/文档/文本记忆、知识库、LLM 源、变更追踪
  handler_agents.go       内核与代理面:状态、kernel、人格、代理/快照/回滚
  handler_settings.go     设置与插件面:配置读写、插件列表详情(含 pluginmgr 反代)
  handler_terminal.go     终端面:终端会话、终端接口、命令历史
  handler_sse.go          SSE 环形缓冲(断线重连补发)
  handler_openai.go       OpenAI 兼容面:/v1/chat/completions
  handler_device.go       设备网关反代(HTTP + WS 升级透传)
  handler_files.go        /files/ 与 /uploads/ 下载

零漂移校验:拿重构前的 handler.go 与新 11 个文件逐行比对(忽略空行、package/import 头),
**丢失行 0**;新增行恰好是 11 个文件头注释(14 行)。

顺带修掉 import 里两处假使用:handler_openai 的 sdk 只作为 Handler 字段名出现(h.sdk.),
handler_settings 的 fmt 只出现在注释里 —— 都从 import 里去掉。

验证:go build ./... / go vet / webui+config+sdk 测试全绿;
起真实实例(沿用已有 data 目录)后 /status /settings /chat/history /plugins /terminals
/kernel /memory /config /login 全部 200,设置在注入 5MB 历史的情况下仍是 33,921 字节。

最大文件从 2993 → 706 行(handler_chat.go)。
This commit is contained in:
HomeAgent Agent
2026-09-14 07:03:47 +08:00
parent ab738fe81a
commit 4a00922ec3
12 changed files with 2623 additions and 2485 deletions

View File

@ -0,0 +1,84 @@
package webui
import (
"os"
"strings"
"net/http"
"path/filepath"
)
// 附件面:/files/ 中转移目录与 /uploads/ 用户上传目录的下载。
// handleFiles 服务 /files/<name>:仅限 webui_files 中转目录内的文件,
// 防路径穿越(name 必须是纯文件名),Content-Type 按扩展名白名单映射。
func (h *Handler) handleFiles(w http.ResponseWriter, r *http.Request) {
if webFilesDir == "" {
http.NotFound(w, r)
return
}
name := strings.TrimPrefix(r.URL.Path, "/files/")
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
http.NotFound(w, r)
return
}
fp := filepath.Join(webFilesDir, name)
f, err := os.Open(fp)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() {
http.NotFound(w, r)
return
}
ct := contentTypeByExt(strings.ToLower(filepath.Ext(name)))
w.Header().Set("Content-Type", ct)
// 图片内联展示;其他类型 attachment 下载。X-Content-Type-Options 防 MIME sniff。
if strings.HasPrefix(ct, "image/") || strings.HasPrefix(ct, "video/") || strings.HasPrefix(ct, "audio/") {
w.Header().Set("Content-Disposition", "inline; filename="+name)
} else {
w.Header().Set("Content-Disposition", "attachment; filename="+name)
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, max-age=3600")
http.ServeContent(w, r, name, st.ModTime(), f)
}
func contentTypeByExt(ext string) string {
switch ext {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".bmp":
return "image/bmp"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/wav"
case ".ogg":
return "audio/ogg"
case ".pdf":
return "application/pdf"
case ".zip":
return "application/zip"
case ".json":
return "application/json"
case ".txt", ".log", ".md":
return "text/plain; charset=utf-8"
default:
// 未知类型强制二进制流 + nosniff,绝不内联执行
return "application/octet-stream"
}
}