feat: 管理面板补齐(RAG导入/记忆添加/会话接history/诚实表述) + design/16 §4.6 IM多会话隔离+第三方身份+黑白名单
agent-98 面板批次:
- 会话管理面板改接 history.ListSessions(主web+次级) + 新建次级会话按钮
- 记忆面板手动添加入口 POST /admin/memories(owner)
- 知识库真正实现 IndexDocument(分块+落projectindex) + POST /admin/rag 导入端点
- 三面板空态诚实表述(去除'后端M10+提供')
- RAG嵌入降级到keyword/line(无直属embedder,不伪造语义,符合红线)
agent-99 design/16 §4.6:
- model.Message.SenderName + 迁移0006 sender_name 列
- 第三方身份边界: session.ResolveActor判定(非LLM猜) + [第三方·昵称]前缀(不改源content)
- 群聊/陌生人落独立sess-xxx(persistIsolated),不写主会话,不广播
- 黑/白名单(allowGroups/denyGroups/allowUsers/denyUsers)最前拦截,不进LLM
- 多账号 CHANNELS_JSON 配置 + 每平台多实例
- 管理只读 GET /admin/channels/{platform}/messages(该平台非主人消息流)
实机验证(JWT): 会话管理返回主会话web / 记忆添加成功 / RAG导入documents非空+Cyrene命中; 后端build/vet/test全绿 + 前端build绿
This commit is contained in:
1 parent
37e98ec584
commit
9c2a37c4ac
32 files changed
+1768
-136
No files matched your search
@@ -0,0 +1,233 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
||||
)
|
||||
|
||||
func gwTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// newMemoryStoreForGateway opens an isolated in-memory SQLite DB and builds a
|
||||
// real memory.Store over it so the owner write path (which type-asserts to
|
||||
// *memory.Store) exercises the real Add.
|
||||
func newMemoryStoreForGateway(t *testing.T) *memory.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d, err := db.Open(ctx, ":memory:", gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
s, err := memory.NewStore(ctx, d, gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("memory.NewStore: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func newRagStoreForGateway(t *testing.T) *knowledge.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d, err := db.Open(ctx, ":memory:", gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
ps, err := projectindex.New(ctx, d, gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("projectindex.New: %v", err)
|
||||
}
|
||||
return knowledge.New(knowledge.Deps{Projects: ps, Log: gwTestLogger()})
|
||||
}
|
||||
|
||||
func newHistoryStoreForGateway(t *testing.T) *history.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d, err := db.Open(ctx, ":memory:", gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
s, err := history.NewStore(ctx, d, gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("history.NewStore: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestAdminAddMemoryWritesAndLists(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newMemoryStoreForGateway(t)
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store})
|
||||
_ = srv
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
|
||||
map[string]any{"personaId": "cyrene", "content": "她喜欢喝冰美式", "scope": "persona"}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("add status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var added struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &added); err != nil {
|
||||
t.Fatalf("decode add: %v (body=%s)", err, body)
|
||||
}
|
||||
if added.ID == "" || added.Scope != "persona" || added.Text != "她喜欢喝冰美式" {
|
||||
t.Fatalf("added = %+v, want persona/她喜欢喝冰美式", added)
|
||||
}
|
||||
|
||||
// The freshly added item is now listed.
|
||||
listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories", nil, tok)
|
||||
if listResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody)
|
||||
}
|
||||
var items []MemorySummary
|
||||
if err := json.Unmarshal(decodeBody(t, listBody)["data"], &items); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Text != "她喜欢喝冰美式" || items[0].Scope != "persona" {
|
||||
t.Fatalf("items = %+v, want one persona 她喜欢喝冰美式", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAddMemoryRejectsBadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newMemoryStoreForGateway(t)
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// Blank content.
|
||||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
|
||||
map[string]any{"personaId": "cyrene", "content": " "}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("blank content status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
// Invalid scope.
|
||||
resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
|
||||
map[string]any{"personaId": "cyrene", "content": "x", "scope": "bogus"}, tok)
|
||||
if resp2.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("bogus scope status = %d, want 400", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAddMemoryUnwired501(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no memory store
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
|
||||
map[string]any{"content": "x"}, tok)
|
||||
if resp.StatusCode != http.StatusNotImplemented {
|
||||
t.Fatalf("unwired status = %d, want 501", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminIndexRagImportsAndLists(t *testing.T) {
|
||||
t.Parallel()
|
||||
rag := newRagStoreForGateway(t)
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: rag})
|
||||
_ = srv
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag",
|
||||
map[string]any{"name": "内部知识库.md", "desc": "团队规范", "content": "第一段:命名规范。\n\n第二段:评审流程。"}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("index status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// The document now shows up in the list (imported + chunked).
|
||||
listResp, listBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
|
||||
if listResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list status = %d, want 200 (body=%s)", listResp.StatusCode, listBody)
|
||||
}
|
||||
var res AdminRagResponse
|
||||
if err := json.Unmarshal(decodeBody(t, listBody)["data"], &res); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(res.Documents) != 1 || res.Documents[0].Name != "内部知识库.md" || res.Documents[0].Chunks != 2 {
|
||||
t.Fatalf("documents = %+v, want one 内部知识库.md with 2 chunks", res.Documents)
|
||||
}
|
||||
|
||||
// It is immediately queryable (keyword/line path).
|
||||
qResp, qBody := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query",
|
||||
map[string]any{"query": "评审流程"}, tok)
|
||||
if qResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("query status = %d, want 200 (body=%s)", qResp.StatusCode, qBody)
|
||||
}
|
||||
var qres struct {
|
||||
Hits []AdminRagHit `json:"hits"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, qBody)["data"], &qres); err != nil {
|
||||
t.Fatalf("decode query: %v", err)
|
||||
}
|
||||
if len(qres.Hits) == 0 || qres.Hits[0].DocName != "内部知识库.md" {
|
||||
t.Fatalf("hits = %+v, want a hit on 内部知识库.md", qres.Hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminIndexRagRejectsBlankNameOrContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
rag := newRagStoreForGateway(t)
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: rag})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag",
|
||||
map[string]any{"name": " ", "content": "x"}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("blank name status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag",
|
||||
map[string]any{"name": "x", "content": " "}, tok)
|
||||
if resp2.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("blank content status = %d, want 400", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminIndexRagUnwired501(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag",
|
||||
map[string]any{"name": "x", "content": "y"}, tok)
|
||||
if resp.StatusCode != http.StatusNotImplemented {
|
||||
t.Fatalf("unwired status = %d, want 501", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminListSessionsHistoryFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHistoryStoreForGateway(t)
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: h})
|
||||
_ = srv
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/sessions", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var items []historySessionSummary
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Fatal("admin sessions list is empty; main session should always exist")
|
||||
}
|
||||
if items[0].Type != "main" || items[0].ID == "" {
|
||||
t.Fatalf("first session = %+v, want a main session with id", items[0])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user