Files
HomeAgent/internal/plugins/webui/handler_upload.go
HomeAgent Agent 642e1c39b1 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)。
2026-09-14 07:03:47 +08:00

336 lines
12 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 (
"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/38MB 原图变成 ~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"})
}