fix(webui): 知识库不再把二进制当文本存 + 接上分类/媒体/删除 + 实时计数

后端(handler_memory.go 重写 handleKnowledge):
- ★ multipart 分支原来无条件 file.Read → string(buf[:n]) → 当 Markdown 存进
  content.md:传一张 PNG 得到的是一份乱码文本知识,还在 .index.json 占一份
  preview,且**没有任何迹象**表明出了问题。
  现在按 http.DetectContentType 探测的**真实类型**分流(不信客户端声明的
  Content-Type——谎报 text/plain 的 PNG 在测试里是真实场景):
  媒体入 CAS 按 digest 挂条目 / 文本校验 UTF-8 后存正文 / 都不是则 400 明确
  拒绝并回传 rejected 清单。
- 状态码语义修正:此前 POST/GET 一律 500、DELETE 一律 404,把「名称非法」
  这类调用方能自己纠正的错报成服务器故障。现按 errors.Is 分流
  400/404/503。
- 搜索支持 category 与 limit;返回 knowledgeView(不泄露服务端绝对路径,
  不回传几百 KB 的 Dense 浮点数组)。
- 列表端点补 dense 状态(前端据此提示多模态是否就绪)。
- 媒体存储未接线时上传图片返回 503,而非退化成把二进制当文本存。

前端(dashboard.js):
- 搜索结果从 <pre>{JSON}</pre> 改为结构化渲染(名称/体积/预览/媒体标记
  + 每条删除按钮)。此前前端根本没有删除入口。
- 新增分类输入框(走 category 参数)、多文件上传。
- 计数改实时:原先读 state.kernel 快照,知识条目经工具/上传增删后不会变
  (实测创建完仍显示 "-")。切到知识面板时拉 /knowledge 的真实 names.length。
- 顶部输入框变多文件;显示多模态就绪状态(ready/total)。
This commit is contained in:
JianFeeeee
2026-09-26 11:37:13 +08:00
parent 9e282ad774
commit d3a796fde2
4 changed files with 952 additions and 79 deletions

View File

@ -1,12 +1,19 @@
package webui
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"unicode/utf8"
"encoding/json"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"net/http"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 记忆面:图记忆 / 文档记忆 / 文本记忆 / 知识库 / LLM 源 / 变更追踪。
@ -119,79 +126,156 @@ func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": data})
}
// knowledgeWriteReq 是知识写入请求体(JSON 分支)。
type knowledgeWriteReq struct {
Name string `json:"name"`
Content string `json:"content"`
Media []sdk.KnowledgeMediaRef `json:"media,omitempty"`
}
// knowledgeView 是返回给前端的知识条目视图。
//
// 为何不让前端直接吃 *knowledge.Knowledge:那个结构里有 Path(服务端绝对
// 路径,不该外泄)、Dense(几百 KB 浮点数组)。前端只需要 name/category/
// tags/size/updated_at/media 摘要。
type knowledgeView struct {
Name string `json:"name"`
Category string `json:"category,omitempty"`
Preview string `json:"preview"`
Size int `json:"size"`
Tags []string `json:"tags,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
Media []knowledgeMediaView `json:"media,omitempty"`
}
type knowledgeMediaView struct {
Digest string `json:"digest"`
MIME string `json:"mime"`
Kind string `json:"kind,omitempty"`
}
// knowledgeStatusFor 把内核的 ErrInvalidName / ErrNotFound 映射到正确状态码。
//
// 此前一律 500:把「名称非法」「不存在」这种**调用方能自己纠正**的错报成
// 服务器故障,前端无从区分该改请求还是该报服务器挂了。
func knowledgeStatusFor(err error) (int, string) {
switch {
case errors.Is(err, knowledge.ErrInvalidName):
return http.StatusBadRequest, err.Error()
case errors.Is(err, knowledge.ErrNotFound):
return http.StatusNotFound, err.Error()
case errors.Is(err, sdk.ErrMediaUnavailable):
return http.StatusServiceUnavailable, err.Error()
default:
return http.StatusInternalServerError, err.Error()
}
}
func toKnowledgeView(k *pubsdk.Knowledge, name string) knowledgeView {
v := knowledgeView{Name: name}
if k.Content == "" {
return v
}
// 预览按 rune 截断,避免把多字节字符切成乱码
r := []rune(k.Content)
if len(r) > 200 {
v.Preview = string(r[:200]) + "..."
} else {
v.Preview = k.Content
}
v.Size = len(k.Content)
return v
}
func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) {
if h.knowledge == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"})
return
}
switch r.Method {
case http.MethodGet:
if h.knowledge == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"})
return
}
query := r.URL.Query().Get("q")
if query != "" {
results, err := h.knowledge.Search(query, 10)
q := r.URL.Query()
// category 为空 = 全库;非空则限定在该分类子树内(前缀匹配)
category := q.Get("category")
if query := strings.TrimSpace(q.Get("q")); query != "" {
limit := 10
if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 && v <= 100 {
limit = v
}
var results []*pubsdk.Knowledge
var err error
if scoped, ok := h.knowledge.(interface {
SearchIn(query, category string, topK int) ([]*pubsdk.Knowledge, error)
}); ok {
results, err = scoped.SearchIn(query, category, limit)
} else {
results, err = h.knowledge.Search(query, limit)
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
code, msg := knowledgeStatusFor(err)
writeJSON(w, code, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
views := make([]knowledgeView, 0, len(results))
for _, k := range results {
views = append(views, toKnowledgeView(k, k.Name))
}
writeJSON(w, http.StatusOK, map[string]interface{}{"results": views, "category": category})
return
}
categories, err := h.knowledge.List()
names, err := h.knowledge.List()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
code, msg := knowledgeStatusFor(err)
writeJSON(w, code, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"categories": categories,
"stats": h.knowledge.Stats(),
})
if names == nil {
names = []string{}
}
stats := h.knowledge.Stats()
payload := map[string]interface{}{"names": names, "stats": stats}
if ds, ok := h.knowledge.(interface {
DenseStats() map[string]interface{}
}); ok {
payload["dense"] = ds.DenseStats()
}
writeJSON(w, http.StatusOK, payload)
case http.MethodPost:
if h.knowledge == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"})
return
}
// multipart 分支:**先看它到底是什么**。
//
// 旧实现无论传什么都把字节 utf-8 强转后当 Markdown 存进 content.md:
// 上传一张 PNG 得到的是一份乱码文本知识,还会在 .index.json 里占一份
// preview,且没有任何迹象表明出了问题。现在改为:
// - 文本类(text/* 或 JSON 字节)→ 走原路径存正文
// - 媒体类(image/audio/video)→ 入 media CAS,按 digest 挂到条目上
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(10 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
name := r.FormValue("name")
file, _, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file required"})
return
}
defer file.Close()
buf := make([]byte, 10<<20)
n, _ := file.Read(buf)
content := string(buf[:n])
if err := h.knowledge.Add(name, content); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": name})
h.handleKnowledgeUpload(w, r)
return
}
var req struct {
Name string `json:"name"`
Content string `json:"content"`
}
var req knowledgeWriteReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
if req.Name == "" || req.Content == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and content required"})
if strings.TrimSpace(req.Name) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
if err := h.knowledge.Add(req.Name, req.Content); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
if strings.TrimSpace(req.Content) == "" && len(req.Media) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "content or media is required"})
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "name": req.Name})
if err := h.knowledge.AddWithMedia(req.Name, req.Content, req.Media); err != nil {
code, msg := knowledgeStatusFor(err)
writeJSON(w, code, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"status": "created", "name": req.Name, "media": len(req.Media),
})
case http.MethodDelete:
name := r.URL.Query().Get("name")
@ -200,16 +284,160 @@ func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.knowledge.Remove(name); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
// 之前所有失败一律 404,包括名称非法(400 的事)与真实 IO 错误
// (500 的事)。"啥都没删"和"服务器坏了"被混为一谈。
code, msg := knowledgeStatusFor(err)
writeJSON(w, code, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "deleted", "name": name})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// maxKnowledgeUploadBytes 是知识库单次上传的体积上限。
// 与 media CAS 的定位一致:知识条目的媒体是引用,不该拖着一堆原始字节。
const maxKnowledgeUploadBytes = 32 << 20
// handleKnowledgeUpload 处理 multipart 上传,按实际类型分流:媒体入 CAS,
// 文本存正文。绝不把二进制当文本存。
func (h *Handler) handleKnowledgeUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(maxKnowledgeUploadBytes); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart: " + err.Error()})
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
files := r.MultipartForm.File["file"]
// 也接受通用字段名,避免前端只有 file 字段名不匹配时静默走成"无媒体"
if len(files) == 0 {
files = r.MultipartForm.File["media"]
}
if len(files) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"})
return
}
if len(files) > 16 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "最多 16 个文件"})
return
}
var (
media []sdk.KnowledgeMediaRef
texts []string
rejected []string
)
for _, fh := range files {
f, err := fh.Open()
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "open " + fh.Filename + ": " + err.Error()})
return
}
data, err := io.ReadAll(io.LimitReader(f, maxKnowledgeUploadBytes+1))
f.Close()
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read " + fh.Filename + ": " + err.Error()})
return
}
if len(data) == 0 {
rejected = append(rejected, fh.Filename+": 空文件")
continue
}
if len(data) > maxKnowledgeUploadBytes {
writeJSON(w, http.StatusRequestEntityTooLarge,
map[string]string{"error": fmt.Sprintf("%s 超过 %d 上限", fh.Filename, maxKnowledgeUploadBytes)})
return
}
mime := fh.Header.Get("Content-Type")
if mime == "" {
mime = contentTypeByExt(strings.ToLower(filepath.Ext(fh.Filename)))
}
// 按**探测到的真实类型**判定,而不是信客户端给的 Content-Type:
// 声明 text/plain 的 PNG 曾是真实场景,光看头会把二进制当文本存。
detected := http.DetectContentType(data)
if isMediaMIME(mime) || isMediaMIME(detected) {
useMIME := detected
if detected == "application/octet-stream" {
useMIME = mime
}
if h.mediaStore == nil {
writeJSON(w, http.StatusServiceUnavailable,
map[string]string{"error": "媒体存储未初始化,无法保存图片/音视频"})
return
}
digest, err := h.mediaStore.Put(data, useMIME, "webui_knowledge")
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store media: " + err.Error()})
return
}
media = append(media, sdk.KnowledgeMediaRef{Digest: digest, MIME: useMIME, Kind: mediaKindOf(useMIME)})
continue
}
// 文本类:确认是合法 UTF-8 才当正文,否则拒绝并说明原因
if !utf8.Valid(data) {
rejected = append(rejected, fmt.Sprintf("%s: 非文本内容且无法识别为媒体(type=%q)", fh.Filename, useMIMEOr(detected, mime)))
continue
}
texts = append(texts, string(data))
}
content := strings.TrimSpace(r.FormValue("content"))
if content == "" {
content = strings.Join(texts, "\n\n")
}
if strings.TrimSpace(content) == "" && len(media) == 0 {
msg := "没有可写入的内容"
if len(rejected) > 0 {
msg += ":" + strings.Join(rejected, "; ")
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
return
}
if err := h.knowledge.AddWithMedia(name, content, media); err != nil {
code, msg := knowledgeStatusFor(err)
writeJSON(w, code, map[string]string{"error": msg})
return
}
resp := map[string]interface{}{"status": "created", "name": name, "media": media}
if len(rejected) > 0 {
resp["rejected"] = rejected
}
writeJSON(w, http.StatusCreated, resp)
}
func useMIMEOr(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func isMediaMIME(m string) bool {
return strings.HasPrefix(m, "image/") || strings.HasPrefix(m, "audio/") || strings.HasPrefix(m, "video/")
}
func mediaKindOf(m string) string {
switch {
case strings.HasPrefix(m, "image/"):
return "image"
case strings.HasPrefix(m, "audio/"):
return "audio"
case strings.HasPrefix(m, "video/"):
return "video"
}
return "file"
}
func (h *Handler) handleTextMemory(w http.ResponseWriter, r *http.Request) {
if h.textMem == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "text memory not available"})