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 c4998bb102
commit 642e1c39b1
12 changed files with 2623 additions and 2485 deletions

View File

@ -0,0 +1,335 @@
package webui
import (
"context"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
"time"
"encoding/base64"
"encoding/json"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"net/http"
"path/filepath"
)
// 上传面:用户上传文件(handleChatFile)与下载(handleUploads)、中断对话。
// parseIntDefault 解析十进制整数,失败/空串返回 def。
func parseIntDefault(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
// maxInlineMediaBytes 是上传媒体内联进 LLM 请求的字节上限。
//
// base64 会胀大 4/3,8MB 原图变成 ~11MB 文本;再加上网关的请求体上限与
// 模型的图像 token 预算,超过这个量级多半会被上游 413 拒掉。
// 超限时退回按路径处理(模型可用 describe_image 主动看)而不是报错。
const maxInlineMediaBytes = 8 << 20
// handleChatFile 处理用户经 webui 上传文件并附带消息注入 agent。
// 设计对齐 qq 插件收文件模式:文件落盘到固定目录(<data>/uploads),
// 注入文本带「文件名 + 保存路径」,agent 用 files_read 等工具按路径消费。
// 表单字段:file(必填,multipart 文件)、message(可选附言)、device_id/device_name。
func (h *Handler) handleChatFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if uploadsDir == "" {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "uploads dir not initialized"})
return
}
if err := r.ParseMultipartForm(64 << 20); err != nil { // 单文件上限 64MB
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart: " + err.Error()})
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"})
return
}
defer file.Close()
message := r.FormValue("message")
deviceID := r.FormValue("device_id")
deviceName := r.FormValue("device_name")
clientMsgID := r.FormValue("client_msg_id")
if h.sdk == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
return
}
// 落盘:保留原文件名;重名加毫秒后缀防覆盖。文件名消毒防路径穿越。
base := filepath.Base(hdr.Filename)
if base == "" || base == "." || strings.Contains(base, "..") {
base = "upload.bin"
}
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "create uploads dir"})
return
}
savePath := filepath.Join(uploadsDir, base)
if _, err := os.Stat(savePath); err == nil {
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext)
savePath = filepath.Join(uploadsDir, fmt.Sprintf("%s_%d%s", stem, time.Now().UnixMilli(), ext))
}
out, err := os.Create(savePath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "save file"})
return
}
sz, err := io.Copy(out, file)
out.Close()
if err != nil {
os.Remove(savePath)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write file"})
return
}
// 下载 URL(前端附件卡片用):/uploads/ 与 /files/ 同一鉴权模型,路由在 RegisterRoutes 挂载
dlURL := "/uploads/" + filepath.Base(savePath)
attType := "file"
ct := hdr.Header.Get("Content-Type")
if ct == "" {
// 部分客户端(curl -F、某些移动端)不带 Content-Type,退回按扩展名判定。
// 判错的后果不只是卡片样式:图片被当普通文件就走不进视觉链路,模型看不到图。
ct = contentTypeByExt(strings.ToLower(filepath.Ext(savePath)))
}
switch {
case strings.HasPrefix(ct, "image/"):
attType = "image"
case strings.HasPrefix(ct, "audio/"):
attType = "audio"
}
// 图片/音频直接进多模态链路:读回字节拼 data URL,随本轮 message 发给模型。
//
// 此前只注入一句「文件已保存到 <路径>」,指望模型自己调 files_read——
// 但 files_read 返回的是文本,图片的字节对模型永远不可见,除非它想到再调
// describe_image。走 InjectInputMedia 后与用户在 qq 发图走同一条统一输入主干:
// 自动落进 CAS、挂上媒体记忆引用,且模型「本轮」就看得到图。
var mediaBlocks []sdk.ContentBlock
if attType == "image" || attType == "audio" {
if sz > maxInlineMediaBytes {
log.Printf("[webui] %s %s 有 %s,超过 %s 内联上限,退回按路径处理",
attType, base, formatBytesGo(sz), formatBytesGo(maxInlineMediaBytes))
} else if raw, err := os.ReadFile(savePath); err != nil {
log.Printf("[webui] 读回上传的%s失败,退回按路径处理: %v", attType, err)
} else {
dataURL := "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(raw)
if attType == "image" {
mediaBlocks = []sdk.ContentBlock{{
Type: "image_url",
ImageURL: &sdk.ImageURL{URL: dataURL, Detail: "auto"},
}}
} else {
mediaBlocks = []sdk.ContentBlock{{
Type: "audio_url",
AudioURL: &sdk.AudioURL{URL: dataURL},
}}
}
}
}
// 注入 agent:文件元信息走 interrupt 通道(内核以 system 角色注入 LLM,
// 不写入用户对话履历、不产生独立用户气泡——对齐 terminal_watch/timer 的
// 工具提醒模式)。用户的附言若有则作为正常消息先行注入。
// qq 插件同款文本格式:[xx发送了文件] + 路径,agent 用 files_read 消费。
humanSize := formatBytesGo(sz)
source := "webui"
if deviceID != "" {
source = "webui/" + deviceID
}
typeLabel := map[string]string{"image": "图片", "audio": "音频", "file": "文件"}[attType]
if typeLabel == "" {
typeLabel = "文件"
}
fileNote := fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n文件已保存到: %s\n可用 files_read 等工具读取此路径处理。",
typeLabel, base, humanSize, savePath)
// 媒体已随本轮发给模型时不再叫它去读文件:那只会读到一堆二进制字节。
if len(mediaBlocks) > 0 {
fileNote = fmt.Sprintf("[用户通过 webui 发送了%s: %s (%s)]\n原文件保存在: %s",
typeLabel, base, humanSize, savePath)
}
if message != "" {
text := message
go func() {
// 附言作为用户消息(带附件卡片)注入;文件说明紧随其后以 interrupt 补充
payload2 := map[string]interface{}{"content": text}
if deviceID != "" {
payload2["device_id"] = deviceID
payload2["device_name"] = deviceName
}
if clientMsgID != "" {
payload2["client_msg_id"] = clientMsgID + "-note"
}
payload2["upload_url"] = dlURL
payload2["upload_type"] = attType
payload2["upload_size"] = sz
payload2["upload_name"] = base
// 媒体跟附言同一条注入:拆开会让模型先看到「帮我看看这张图」而图在下一轮才到。
if len(mediaBlocks) > 0 {
payload2["media_blocks"] = mediaBlocks
}
h.sdk.InjectInput(source, "webui", "text", payload2)
}()
time.Sleep(100 * time.Millisecond) // 保证附言先入队
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{"content": fileNote, "no_memory": true})
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "accepted",
"file": map[string]interface{}{"url": dlURL, "name": base, "size": sz, "path": savePath, "type": attType},
})
return
}
// 无附言:仅文件说明,直接同步注入并等待回复(与普通聊天体验一致)
payload := map[string]interface{}{
"content": fileNote,
"upload_url": dlURL,
"upload_type": attType,
"upload_size": sz,
"upload_name": base,
}
if len(mediaBlocks) > 0 {
payload["media_blocks"] = mediaBlocks
}
if deviceID != "" {
payload["device_id"] = deviceID
payload["device_name"] = deviceName
}
if clientMsgID != "" {
payload["client_msg_id"] = clientMsgID
}
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second)
defer cancel()
respCh := make(chan *agentIO.OutputEvent, 1)
go func() {
respCh <- h.sdk.InjectInputSync(source, "webui", "text", payload)
}()
var resp *agentIO.OutputEvent
select {
case resp = <-respCh:
case <-ctx.Done():
writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout"})
return
}
if resp == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
return
}
content, _ := resp.Payload["content"].(string)
reasoning, _ := resp.Payload["reasoning_content"].(string)
result := map[string]interface{}{
"response": content,
"file": map[string]interface{}{"url": dlURL, "name": base, "size": sz, "path": savePath, "type": attType},
}
if reasoning != "" {
result["reasoning_content"] = reasoning
}
writeJSON(w, http.StatusOK, result)
}
// formatBytesGo 服务端字节人性化显示。
func formatBytesGo(n int64) string {
if n <= 0 {
return "0 B"
}
units := []string{"B", "KB", "MB", "GB"}
i := 0
f := float64(n)
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i == 0 {
return fmt.Sprintf("%d %s", n, units[i])
}
return fmt.Sprintf("%.1f %s", f, units[i])
}
// handleUploads 服务 /uploads/<name>:用户上传文件的下载(与 /files/ 同一安全模型)。
func (h *Handler) handleUploads(w http.ResponseWriter, r *http.Request) {
if uploadsDir == "" {
http.NotFound(w, r)
return
}
name := strings.TrimPrefix(r.URL.Path, "/uploads/")
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
http.NotFound(w, r)
return
}
fp := filepath.Join(uploadsDir, 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)
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)
}
// handleChatInterrupt 注入用户中断:取消正在进行的 LLM 生成并/或发送打断消息。
// 核心拦截语义(interceptLoop):
// - 有 LLM 在跑:cancelLLM 取消当前请求 + 中断入队,process() 以
// [中断消息] 重启轮次,模型看到被打断的上下文和用户新输入;
// - 无 LLM 在跑:作为普通输入处理(等同发了一条消息)。
//
// message 可选:空则纯取消(仍会注入空内容中断触发取消)。
func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.sdk == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"})
return
}
var body struct {
Message string `json:"message"`
DeviceID string `json:"device_id"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body) // body 可选
}
source := "webui"
if body.DeviceID != "" {
source = "webui/" + body.DeviceID
}
// PriorityL4:终止按钮必须能立即打断当前任务(内核级插件才有的能力)。
// agent 正卡在工具执行里时按不下手——那是临界区,由内核在安全点生效;
// 但 LLM 流式段会被立刻取消。
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{
"content": body.Message,
"priority": sdk.PriorityL4,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"})
}