mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 21:33:05 +00:00
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:
@ -1644,17 +1644,23 @@
|
||||
"</h2>" +
|
||||
'<div class="kv-row"><span class="key">' +
|
||||
__("项目", "Items") +
|
||||
'</span><span class="val">' +
|
||||
(k?.knowledge?.item_count || "-") +
|
||||
'</span><span class="val" id="know-count">' +
|
||||
(k?.knowledge?.item_count ?? "-") +
|
||||
"</span></div>" +
|
||||
'<div style="margin-top:8px">' +
|
||||
'<div style="margin-top:8px;display:flex;gap:4px;flex-wrap:wrap">' +
|
||||
'<input id="know-query" placeholder="' +
|
||||
__("搜索知识", "Search knowledge") +
|
||||
'" style="flex:1;min-width:120px">' +
|
||||
'<input id="know-category" placeholder="' +
|
||||
__("分类(可选)", "Category (optional)") +
|
||||
'" style="width:110px" title="' +
|
||||
__("限定在该分类子树内,如 tech 会搜 tech/go、tech/rust。留空则搜全库",
|
||||
"Limit search to a category subtree, e.g. tech covers tech/go, tech/rust. Empty searches all") +
|
||||
'">' +
|
||||
'<button class="btn btn-primary btn-sm" onclick="searchKnowledgeChat()">' +
|
||||
__("搜索", "Search") +
|
||||
"</button>" +
|
||||
'</div><div id="know-result-chat" style="margin-top:8px;max-height:180px;overflow:auto"></div>' +
|
||||
'</div><div id="know-result-chat" style="margin-top:8px;max-height:220px;overflow:auto"></div>' +
|
||||
'<div style="margin-top:12px;border-top:1px solid var(--border-color);padding-top:8px">' +
|
||||
'<input id="know-name" placeholder="' +
|
||||
__("知识名称", "Knowledge name") +
|
||||
@ -1662,9 +1668,12 @@
|
||||
'<textarea id="know-content" placeholder="' +
|
||||
__("内容", "Content") +
|
||||
'" style="min-height:50px;margin-bottom:4px"></textarea>' +
|
||||
'<button class="btn btn-primary btn-sm" onclick="createKnowledgeChat()">' +
|
||||
'<input type="file" id="know-media" multiple accept="image/*,audio/*,video/*,.md,.txt" style="margin-bottom:6px;font-size:11px">' +
|
||||
'<div><button class="btn btn-primary btn-sm" onclick="createKnowledgeChat()">' +
|
||||
__("创建", "Create") +
|
||||
"</button>" +
|
||||
'<span id="know-media-hint" style="margin-left:6px;font-size:11px;opacity:.7"></span>' +
|
||||
"</div>" +
|
||||
"</div></div></div>";
|
||||
html += "</div>";
|
||||
cont.innerHTML = html;
|
||||
@ -2900,56 +2909,180 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 知识库面板:搜索 / 创建 / 删除 / 刷新计数。
|
||||
//
|
||||
// 此前三处问题:搜索把裸 JSON 直接 stringify 丢进 <pre>(用户看到一坨
|
||||
// 机器码);创建后不刷新计数(1644 行读的是 state.kernel 快照,创建
|
||||
// 完仍是旧值);没有任何删除入口,也没有媒体上传。
|
||||
function knowEsc(v) {
|
||||
return String(v == null ? "" : v)
|
||||
.replace(/&/g, "&")
|
||||
.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 (
|
||||
'<p style="opacity:.7;font-size:12px">' +
|
||||
__("未找到相关知识", "No matching knowledge") +
|
||||
"</p>"
|
||||
);
|
||||
}
|
||||
var html = "";
|
||||
views.forEach(function (v) {
|
||||
html += '<div class="know-item" style="padding:6px 0;border-bottom:1px solid var(--border-color)">';
|
||||
html += '<div style="display:flex;gap:6px;align-items:baseline">';
|
||||
html += '<strong style="font-size:12px;flex:1;word-break:break-all">' + knowEsc(v.name) + "</strong>";
|
||||
if (v.size) {
|
||||
html += '<span style="font-size:10px;opacity:.6">' + v.size + " B</span>";
|
||||
}
|
||||
html +=
|
||||
'<button class="btn btn-sm" style="font-size:10px" onclick="deleteKnowledge(' +
|
||||
JSON.stringify(v.name).replace(/"/g, """) +
|
||||
')">' +
|
||||
__("删除", "Delete") +
|
||||
"</button>";
|
||||
html += "</div>";
|
||||
if (v.media && v.media.length) {
|
||||
html += '<div style="font-size:10px;opacity:.7;margin-top:2px">';
|
||||
v.media.forEach(function (m) {
|
||||
var kind = m.kind || "file";
|
||||
html +=
|
||||
'<span style="margin-right:6px">[' + knowEsc(kind) + "] " + knowEsc(m.digest.slice(0, 8)) + "…</span>";
|
||||
});
|
||||
html += "</div>";
|
||||
}
|
||||
if (v.preview) {
|
||||
html +=
|
||||
'<div style="font-size:11px;opacity:.8;margin-top:3px;white-space:pre-wrap;max-height:80px;overflow:auto">' +
|
||||
knowEsc(v.preview) +
|
||||
"</div>";
|
||||
}
|
||||
html += "</div>";
|
||||
});
|
||||
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 =
|
||||
'<p style="opacity:.7;font-size:12px">' +
|
||||
__("请输入查询关键词", "Enter a keyword") +
|
||||
"</p>";
|
||||
return;
|
||||
}
|
||||
r.innerHTML = '<div class="loading"></div>';
|
||||
try {
|
||||
var data = await api("/knowledge?q=" + encodeURIComponent(q));
|
||||
r.innerHTML =
|
||||
'<pre style="font-size:11px">' +
|
||||
escHtml(JSON.stringify(data, null, 2)) +
|
||||
"</pre>";
|
||||
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 =
|
||||
'<p style="color:#fca5a5">' +
|
||||
__("搜索失败: ", "Search failed: ") +
|
||||
escHtml(e.message) +
|
||||
"</p>";
|
||||
'<p style="color:#fca5a5">' + __("搜索失败: ", "Search failed: ") + knowEsc(e.message) + "</p>";
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@ -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,
|
||||
|
||||
508
internal/plugins/webui/handler_knowledge_test.go
Normal file
508
internal/plugins/webui/handler_knowledge_test.go
Normal file
@ -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()
|
||||
}
|
||||
@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user