mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 背景 仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑), gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。 但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源 不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。 ## 关键区分(这是本次改动安全的前提) 两个 module 是**不同**的,影响面差一个数量级: | module | 谁依赖 | 改它的后果 | |---|---|---| | `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 | | `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 | ★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0), 只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串** (`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。 ⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。 SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配, 否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。 ## 改动 - `go.mod` / `go.sum`:两个 module 路径 + require/replace - 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径 - SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板 - 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等) ## 明确**保留** gitcode 的地方(镜像机制,不能动) - `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job) - `gitcode.com/api/v5/...`(release 附件上传) - git remote `gitcode`(镜像拉取/推送) - `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维) ## 验证 - `go build ./...` / `go vet ./...` 绿 - `go test ./... -count=1` → 48 包 ok / 0 FAIL - hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`) - 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0 - 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好 ## 顺带修正 `hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」—— module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**: 离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
344 lines
12 KiB
Go
344 lines
12 KiB
Go
package webui
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
sdk "github.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 注入用户中断或「停止」。
|
||
//
|
||
// 两种语义分开(用户明确的设计):
|
||
// - **停止**(message 为空,即停止按钮):①立即结束当前 LLM 推理(不重试);
|
||
// ②对停止那一刻已排队的 x 条消息,后续依次在 pre-action 阶段短路。
|
||
// 只传 stop=true,不入中断队列——旧实现把空停止当普通中断入队,取消后
|
||
// 还会以空内容重跑一轮,表现为“停了又活”。
|
||
// - **中断/补充指令**(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 流式段会被立刻取消。
|
||
payload := map[string]interface{}{
|
||
"content": body.Message,
|
||
"priority": sdk.PriorityL4,
|
||
}
|
||
if strings.TrimSpace(body.Message) == "" {
|
||
// 空消息 = 停止。必须带 stop 标记,否则空内容会被 interceptLoop
|
||
// 当成空操作丢掉(旧行为:接口回 200,实际什么都没发生)。
|
||
payload["stop"] = true
|
||
}
|
||
h.sdk.InjectInterrupt(source, "webui", "text", payload)
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"})
|
||
}
|