diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index c62b9a0..dba914c 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -1644,17 +1644,23 @@ "" + '
' + __("项目", "Items") + - '' + - (k?.knowledge?.item_count || "-") + + '' + + (k?.knowledge?.item_count ?? "-") + "
" + - '
' + + '
' + '' + + '' + '" + - '
' + + '
' + '
' + '' + - '" + + '' + + "
" + ""; html += ""; cont.innerHTML = html; @@ -2900,56 +2909,180 @@ } } + // 知识库面板:搜索 / 创建 / 删除 / 刷新计数。 + // + // 此前三处问题:搜索把裸 JSON 直接 stringify 丢进
(用户看到一坨
+      // 机器码);创建后不刷新计数(1644 行读的是 state.kernel 快照,创建
+      // 完仍是旧值);没有任何删除入口,也没有媒体上传。
+      function knowEsc(v) {
+        return String(v == null ? "" : v)
+          .replace(/&/g, "&")
+          .replace(//g, ">");
+      }
+
+      async function refreshKnowledgeCount() {
+        try {
+          var d = await api("/knowledge");
+          var el = document.getElementById("know-count");
+          if (el && d && d.names) el.textContent = d.names.length;
+          // 稠密路(多模态)状态提示
+          var hint = document.getElementById("know-media-hint");
+          if (hint && d && d.dense) {
+            if (d.dense.enabled) {
+              hint.textContent =
+                __("多模态已就绪 ", "Multimodal ready ") +
+                (d.dense.ready || 0) +
+                "/" +
+                ((d.dense.ready || 0) + (d.dense.stale || 0));
+            } else {
+              hint.textContent = __(
+                "未接入多模态:图片只记录不参与召回",
+                "Multimodal off: images recorded but not searchable",
+              );
+            }
+          }
+        } catch (e) {
+          /* 计数刷新失败不该打断用户操作 */
+        }
+      }
+
+      function renderKnowledgeResults(views, q) {
+        if (!views || !views.length) {
+          return (
+            '

' + + __("未找到相关知识", "No matching knowledge") + + "

" + ); + } + var html = ""; + views.forEach(function (v) { + html += '
'; + html += '
'; + html += '' + knowEsc(v.name) + ""; + if (v.size) { + html += '' + v.size + " B"; + } + html += + '"; + html += "
"; + if (v.media && v.media.length) { + html += '
'; + v.media.forEach(function (m) { + var kind = m.kind || "file"; + html += + '[' + knowEsc(kind) + "] " + knowEsc(m.digest.slice(0, 8)) + "…"; + }); + html += "
"; + } + if (v.preview) { + html += + '
' + + knowEsc(v.preview) + + "
"; + } + html += "
"; + }); + return html; + } + async function searchKnowledgeChat() { var q = document.getElementById("know-query")?.value; + var cat = document.getElementById("know-category")?.value || ""; var r = document.getElementById("know-result-chat"); - if (!r || !q) return; + if (!r) return; + if (!q) { + r.innerHTML = + '

' + + __("请输入查询关键词", "Enter a keyword") + + "

"; + return; + } r.innerHTML = '
'; try { - var data = await api("/knowledge?q=" + encodeURIComponent(q)); - r.innerHTML = - '
' +
-            escHtml(JSON.stringify(data, null, 2)) +
-            "
"; + var url = "/knowledge?q=" + encodeURIComponent(q); + if (cat) url += "&category=" + encodeURIComponent(cat); + var data = await api(url); + r.innerHTML = renderKnowledgeResults(data && data.results, q); } catch (e) { r.innerHTML = - '

' + - __("搜索失败: ", "Search failed: ") + - escHtml(e.message) + - "

"; + '

' + __("搜索失败: ", "Search failed: ") + knowEsc(e.message) + "

"; + } + } + + async function deleteKnowledge(name) { + if ( + !confirm( + __("确定删除知识「", "Delete knowledge \"") + name + __("」?此操作不可撤销。", "\"? This cannot be undone."), + ) + ) { + return; + } + try { + await api("/knowledge?name=" + encodeURIComponent(name), { method: "DELETE" }); + toast(__("知识「", 'Knowledge "') + name + __("」已删除", '" deleted')); + refreshKnowledgeCount(); + searchKnowledgeChat(); + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); } } async function createKnowledgeChat() { var name = document.getElementById("know-name")?.value; var content = document.getElementById("know-content")?.value; - if (!name || !content) { + var fileInput = document.getElementById("know-media"); + var files = fileInput && fileInput.files ? fileInput.files : null; + if (!name) { + toast(__("名称不能为空", "Name is required"), true); + return; + } + if ((!content || !content.trim()) && (!files || !files.length)) { toast( - __("名称和内容不能为空", "Name and content cannot be empty"), + __("内容与媒体至少要有一项", "Content or media is required"), true, ); return; } try { - var r = await api("/knowledge", { - method: "POST", - body: JSON.stringify({ name: name, content: content }), - }); - if (r.status || r.id) { - toast( - __("知识「", 'Knowledge "') + name + __("」已创建", '" created'), - ); - document.getElementById("know-name").value = ""; - document.getElementById("know-content").value = ""; + var r; + if (files && files.length) { + // 有文件走 multipart:服务端按**探测到的真实类型**分流, + // 图片/音视频入媒体库并按 digest 挂到条目上,文本存正文。 + var fd = new FormData(); + fd.append("name", name); + if (content) fd.append("content", content); + for (var i = 0; i < files.length; i++) fd.append("file", files[i]); + r = await api("/knowledge", { method: "POST", body: fd }); } else { - toast(__("创建失败", "Create failed"), true); + r = await api("/knowledge", { + method: "POST", + body: JSON.stringify({ name: name, content: content }), + }); } + var msg = __("知识「", 'Knowledge "') + name + __("」已创建", '" created'); + if (r && r.media) msg += __(",含 ", " with ") + r.media + __(" 个媒体", " media item(s)"); + if (r && r.rejected && r.rejected.length) { + msg += __(";", "; ") + r.rejected.length + __(" 项被跳过", " skipped"); + } + toast(msg); + document.getElementById("know-name").value = ""; + document.getElementById("know-content").value = ""; + if (fileInput) fileInput.value = ""; + refreshKnowledgeCount(); } catch (e) { toast(__("创建失败: ", "Create failed: ") + e.message, true); } } function switchChatPanel(tab, el) { + // 切到知识面板时拉实时计数:面板里的数字来自 state.kernel 快照, + // 而知识条目会经工具/上传增删,快照不会自己变(实测创建后仍显示 "-")。 + if (tab === "knowledge") refreshKnowledgeCount(); var panels = { chat: document.getElementById("chat-panel-chat"), starmap: document.getElementById("chat-panel-starmap"), diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 64d93d6..5555ba6 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -100,6 +100,7 @@ type Handler struct { status sdk.StatusAPI term sdk.TerminalAPI llm sdk.LLMAPI + mediaStore sdk.MediaAPI // hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。 hostPort string @@ -161,6 +162,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler { st sdk.StatusAPI term sdk.TerminalAPI llm sdk.LLMAPI + ms sdk.MediaAPI ) if s != nil { sup, mem, idx = s.Supervisor(), s.Memory(), s.Indexer() @@ -169,6 +171,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler { se, pm = s.Settings(), s.PluginMgr() st, llm = s.Status(), s.LLM() term = s.Terminal() + ms = s.Media() } h := &Handler{ sdk: s, @@ -186,6 +189,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler { status: st, term: term, llm: llm, + mediaStore: ms, sessions: make(map[string]time.Time), loginLimiter: newLoginLimiter(loginMaxFails, loginWindow), pendingIdx: -1, diff --git a/internal/plugins/webui/handler_knowledge_test.go b/internal/plugins/webui/handler_knowledge_test.go new file mode 100644 index 0000000..f2e855d --- /dev/null +++ b/internal/plugins/webui/handler_knowledge_test.go @@ -0,0 +1,508 @@ +package webui + +import ( + "bytes" + "encoding/json" + "image" + "image/color" + "image/png" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "path/filepath" + + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + "gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +// newKnowledgeHandler 构造一个带知识库(可选媒体存储)的 Handler。 +func newKnowledgeHandler(t *testing.T, withMedia bool) (*Handler, *knowledge.Store, func()) { + t.Helper() + ks := knowledge.NewStore(t.TempDir()) + if err := ks.Start(); err != nil { + t.Fatal(err) + } + + cfg := &types.Config{Daemon: types.DaemonConfig{ + CheckInterval: time.Minute, HeartbeatInterval: 30 * time.Second, + }} + sup := supervisor.New(cfg) + sup.Start() + + sdkCfg := sdk.SDKConfig{ + Supervisor: supervisor.NewSDKAdapter(sup), + Knowledge: sdk.NewKnowledge(ks), + Config: sdk.NewConfig(cfg), + } + if withMedia { + ms, err := media.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sdkCfg.Media = sdk.NewMedia(ms) + } + h := NewHandler(testSDK(sdkCfg)) + return h, ks, func() { sup.Shutdown() } +} + +// 上传 PNG 绝不能被当文本存成乱码正文。 +// +// 修复前的行为:任何文件都是 file.Read 后 string(buf[:n]) 直接当 Markdown +// 存进 content.md —— 传张图得到一份乱码文本知识,且无任何迹象。 +func TestKnowledgeUploadPNGIsNotStoredAsText(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + + raw := makePNG(t) + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("name", "cat-photo") + fw, err := mw.CreateFormFile("file", "cat.png") + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write(raw); err != nil { + t.Fatal(err) + } + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("应 201,实为 %d: %s", w.Code, w.Body.String()) + } + + k := knowledgeEntry(t, ks, "cat-photo") + if k == nil { + t.Fatalf("条目未创建,List=%v", ks.List()) + } + // 正文里绝不能出现 PNG 字节被强转后的乱码 + if k.Content != "" { + for i, r := range k.Content { + if r == 0xFFFD || r == 0 { + t.Fatalf("正文含二进制强转的乱码(偏移 %d),说明 PNG 被当文本存了", i) + } + } + if !isPrintableOrSpace(k.Content) { + t.Errorf("正文含不可打印字符,非文本被当 Markdown 存了") + } + } + if len(k.Media) != 1 { + t.Fatalf("应挂 1 个媒体,实为 %+v", k.Media) + } + if k.Media[0].Digest == "" { + t.Errorf("媒体 digest 为空: %+v", k.Media[0]) + } + if !strings.HasPrefix(k.Media[0].MIME, "image/") { + t.Errorf("媒体 MIME 应为 image/*,实为 %q", k.Media[0].MIME) + } +} + +// 声明 text/plain 但实际是 PNG(Content-Type 不可信)也必须入 CAS。 +func TestKnowledgeUploadDetectsRealTypeOverClaimedHeader(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + + raw := makePNG(t) + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("name", "liar") + // 故意谎报为纯文本 + fw, _ := mw.CreateFormFile("file", "x.png") + fw.Write(raw) + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("应 201,实为 %d: %s", w.Code, w.Body.String()) + } + k := knowledgeEntry(t, ks, "liar") + if k == nil || len(k.Media) != 1 { + t.Fatalf("谎报 Content-Type 的 PNG 应被探测为媒体并入 CAS,实为 %+v", k) + } +} + +// 纯文本上传仍走原路径存正文。 +func TestKnowledgeUploadTextStaysAsContent(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("name", "notes") + fw, _ := mw.CreateFormFile("file", "n.txt") + fw.Write([]byte("这是正文内容")) + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("应 201,实为 %d: %s", w.Code, w.Body.String()) + } + k := knowledgeEntry(t, ks, "notes") + if k == nil { + t.Fatal("条目未创建") + } + if !strings.Contains(k.Content, "这是正文内容") { + t.Errorf("文本应存为正文,实为 %q", k.Content) + } + if len(k.Media) != 0 { + t.Errorf("文本不该有媒体,实为 %+v", k.Media) + } +} + +// 非文本也非媒体的垃圾字节应被明确拒绝,而不是当文本存。 +func TestKnowledgeUploadRejectsBinaryGarbage(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + + // 非法 UTF-8 且探测不出媒体 + garbage := []byte{0xFF, 0xFE, 0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0x00} + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("name", "junk") + fw, _ := mw.CreateFormFile("file", "j.bin") + fw.Write(garbage) + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("垃圾字节应 400,实为 %d: %s", w.Code, w.Body.String()) + } + if knowledgeEntry(t, ks, "junk") != nil { + t.Error("被拒绝的条目不该落库") + } +} + +// 媒体存储未初始化时上传图片必须 503,不能静默把二进制当文本存。 +func TestKnowledgeUploadMediaWithoutStoreIsUnavailable(t *testing.T) { + h, _, done := newKnowledgeHandler(t, false) + defer done() + + raw := makePNG(t) + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("name", "x") + fw, _ := mw.CreateFormFile("file", "a.png") + fw.Write(raw) + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("无媒体存储应 503,实为 %d: %s", w.Code, w.Body.String()) + } +} + +// 状态码语义:名称非法是 400,不存在是 404,缺名是 400。 +func TestKnowledgeStatusCodes(t *testing.T) { + h, _, done := newKnowledgeHandler(t, true) + defer done() + + // 非法名称(..) + body := `{"name":"../evil","content":"x"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("非法名称应 400,实为 %d: %s", w.Code, w.Body.String()) + } + + // 缺名 + req = httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(`{"content":"x"}`)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("缺名应 400,实为 %d", w.Code) + } + + // 删除不存在 + req = httptest.NewRequest(http.MethodDelete, "/api/v1/knowledge?name=nope", nil) + w = httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("删除不存在应 404,实为 %d", w.Code) + } +} + +// 搜索结果应带分类/预览等前端需要的字段,而不是裸 JSON。 +func TestKnowledgeSearchReturnsViews(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + if err := ks.Add("tech/go/并发", "goroutine 调度 GMP 抢占"); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=GMP", nil) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusOK { + t.Fatalf("应 200,实为 %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Results []knowledgeView `json:"results"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Results) == 0 { + t.Fatal("应命中") + } + r := resp.Results[0] + if r.Name != "tech/go/并发" { + t.Errorf("name 不对: %q", r.Name) + } + if r.Preview == "" { + t.Error("缺 preview") + } + // 不得泄露服务端绝对路径 + if strings.Contains(r.Preview, string(filepath.Separator)) && strings.Contains(r.Preview, "tmp") { + t.Errorf("疑似泄露服务端路径: %q", r.Preview) + } +} + +// 分类过滤参数应真正生效。 +func TestKnowledgeSearchCategoryFilter(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + for _, e := range []struct{ n, c string }{ + {"tech/go/a", "并发 调度"}, + {"life/b", "作息 睡眠"}, + } { + if err := ks.Add(e.n, e.c); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=%E5%B9%B2%E8%8D%89&category=tech", nil) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusOK { + t.Fatalf("应 200,实为 %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Results []knowledgeView `json:"results"` + } + json.Unmarshal(w.Body.Bytes(), &resp) + for _, r := range resp.Results { + if !strings.HasPrefix(r.Name, "tech/") { + t.Errorf("分类过滤失效,混入 %q", r.Name) + } + } +} + +// 列表端点应返回 names + stats(供前端刷新计数),并附稠密路状态。 +func TestKnowledgeListReturnsStats(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + _ = ks.Add("a", "A") + _ = ks.Add("b", "B") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge", nil) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusOK { + t.Fatalf("应 200,实为 %d", w.Code) + } + var resp struct { + Names []string `json:"names"` + Stats map[string]interface{} `json:"stats"` + Dense map[string]interface{} `json:"dense"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Names) != 2 { + t.Errorf("names 应有 2 条,实为 %v", resp.Names) + } + if resp.Stats == nil { + t.Error("缺 stats") + } + if resp.Dense == nil { + t.Error("缺 dense 状态(前端要据此提示多模态是否就绪)") + } +} + +// knowledgeEntry 按名字取一条知识(含媒体引用与正文)。 +// Store 没有导出的 Items(),测试里用「全库检索 + 名字匹配」拿到同一条。 +func knowledgeEntry(t *testing.T, ks *knowledge.Store, name string) *knowledge.Knowledge { + t.Helper() + for _, k := range ks.Search(name, 100) { + if k.Name == name { + return k + } + } + // 检索可能因分词而漏,退回遍历 List + 逐条检索 + for _, n := range ks.List() { + if n != name { + continue + } + for _, k := range ks.Search(n, 100) { + if k.Name == name { + return k + } + } + } + return nil +} + +func makePNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + img.Set(0, 0, color.RGBA{R: 255, A: 255}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func isPrintableOrSpace(s string) bool { + for _, r := range s { + if r < 0x20 && r != '\n' && r != '\t' && r != '\r' { + return false + } + } + return true +} + +// 前端实际发的 multipart 形状:name + 可选 content + 多个 file 字段。 +// 前端在 dashboard.js 的 createKnowledgeChat 里用 FormData 组装, +// 逐个 append("file", files[i]) —— 这里逐字复刻,确认服务端吃得下。 +func TestKnowledgeUploadMultipleFilesFromFrontend(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("name", "cat-dog") + _ = mw.WriteField("content", "两只动物") + for i, name := range []string{"a.png", "b.png"} { + fw, _ := mw.CreateFormFile("file", name) + fw.Write(makePNG(t)) + _ = i + } + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("应 201,实为 %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Media []sdk.KnowledgeMediaRef `json:"media"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + // 同一张图传两次 → 内容寻址去重,两个 digest 相同(这是 CAS 的预期行为) + if len(resp.Media) != 2 { + t.Fatalf("应返回 2 个媒体,实为 %d", len(resp.Media)) + } + if resp.Media[0].Digest != resp.Media[1].Digest { + t.Errorf("相同内容应去重为同一 digest,实为 %s vs %s", resp.Media[0].Digest, resp.Media[1].Digest) + } + k := knowledgeEntry(t, ks, "cat-dog") + if k == nil { + t.Fatal("条目未创建") + } + if k.Content != "两只动物" { + t.Errorf("显式 content 应被采用,实为 %q", k.Content) + } +} + +// 文本文件不带 content 字段时,文本内容应被采纳为正文。 +func TestKnowledgeUploadTextFileBecomesContent(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("name", "fromfile") + fw, _ := mw.CreateFormFile("file", "a.md") + fw.Write([]byte("# 标题\n\n正文")) + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("应 201,实为 %d: %s", w.Code, w.Body.String()) + } + k := knowledgeEntry(t, ks, "fromfile") + if k == nil || !strings.Contains(k.Content, "正文") { + t.Errorf("文本文件内容应成为正文,实为 %+v", k) + } +} + +// 上传的媒体必须能在媒体库里取回字节(digest 有效),否则引用是死的。 +func TestKnowledgeUploadedMediaRetrievable(t *testing.T) { + h, ks, done := newKnowledgeHandler(t, true) + defer done() + + raw := makePNG(t) + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("name", "m") + fw, _ := mw.CreateFormFile("file", "a.png") + fw.Write(raw) + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.handleKnowledge(w, req) + if w.Code != http.StatusCreated { + t.Fatal(w.Body.String()) + } + var resp struct { + Media []sdk.KnowledgeMediaRef `json:"media"` + } + json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Media) != 1 { + t.Fatalf("应 1 个媒体,实为 %d", len(resp.Media)) + } + // Handler 持有的媒体存储应能按 digest 取回原始字节 + got, err := h.mediaStore.Get(resp.Media[0].Digest) + if err != nil { + t.Fatalf("媒体不可取回: %v", err) + } + if !bytes.Equal(got, raw) { + t.Error("取回的字节与上传的不一致") + } + info, err := h.mediaStore.Stat(resp.Media[0].Digest) + if err != nil { + t.Fatal(err) + } + if info.Kind != "image" { + t.Errorf("kind 应为 image,实为 %q", info.Kind) + } + // 条目上也应持久化了引用(重启不丢) + _ = ks.Flush() +} diff --git a/internal/plugins/webui/handler_memory.go b/internal/plugins/webui/handler_memory.go index 1b2efd2..b96ad33 100644 --- a/internal/plugins/webui/handler_memory.go +++ b/internal/plugins/webui/handler_memory.go @@ -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"})