mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 02:18:06 +00:00
拆法:按「资源面」搬家,每个顶层声明(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)。
336 lines
12 KiB
Go
336 lines
12 KiB
Go
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"})
|
||
}
|