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
@@ -96,8 +96,16 @@ func (s *Server) adminListPersonas(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.collectPersonas())
|
||||
}
|
||||
|
||||
// adminListSessions lists channel sessions (admin read).
|
||||
// adminListSessions lists the persistent chat-history sessions (main + sub) when a
|
||||
// history store is wired, otherwise falls back to the channel-session view. It is
|
||||
// the admin counterpart of handleListSessions: the panel shows the real main and
|
||||
// secondary sessions (design/16 §2) rather than stale channel-session rows, and
|
||||
// the fallback keeps the pre-design/16 contract for servers built without history.
|
||||
func (s *Server) adminListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
if s.history != nil {
|
||||
s.handleHistorySessions(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.collectSessions(r.Context()))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// Admin memory + knowledge-base write endpoints (owner-only, audited). The memory
|
||||
// add writes a real item into the memory store (owner-private, safe), and the RAG
|
||||
// index imports a document into the project index so it becomes searchable. Both
|
||||
// degrade to a clear 501 when their backing store is not wired.
|
||||
|
||||
// memoryAddRequest is the POST /api/v1/admin/memories body.
|
||||
type memoryAddRequest struct {
|
||||
PersonaID string `json:"personaId"`
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// adminAddMemory writes a single memory item for the owner. It is the manual
|
||||
// "添加记忆" path: the item is scoped to the persona (default), so it behaves like
|
||||
// the "记住 X" path the loop already writes. Scope may be omitted (default
|
||||
// persona) or one of user|persona|project; an invalid non-empty scope is rejected.
|
||||
func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) {
|
||||
var req memoryAddRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
personaID := strings.TrimSpace(req.PersonaID)
|
||||
if personaID == "" {
|
||||
personaID = "cyrene"
|
||||
}
|
||||
content := strings.TrimSpace(req.Content)
|
||||
if content == "" {
|
||||
s.auditClaims(r, "memory.add", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
|
||||
return
|
||||
}
|
||||
rawScope := strings.TrimSpace(req.Scope)
|
||||
if rawScope != "" && !isValidMemoryScope(rawScope) {
|
||||
s.auditClaims(r, "memory.add", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "scope must be one of user|persona|project"))
|
||||
return
|
||||
}
|
||||
store, ok := s.memories.(*memory.Store)
|
||||
if !ok {
|
||||
s.auditClaims(r, "memory.add", "error")
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "记忆存储未配置(未注入 memory.Store)", HTTPCode: http.StatusNotImplemented})
|
||||
return
|
||||
}
|
||||
stored, err := store.Add(r.Context(), memory.MemoryItem{
|
||||
PersonaID: personaID,
|
||||
Content: content,
|
||||
Scope: memory.Scope(rawScope),
|
||||
})
|
||||
if err != nil {
|
||||
s.auditClaims(r, "memory.add", "error")
|
||||
s.log.Error("memory add", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "memory_add_failed", "failed to add memory"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "memory.add", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": stored.ID, "scope": string(stored.Scope), "text": stored.Content})
|
||||
}
|
||||
|
||||
// isValidMemoryScope reports whether raw is one of the defined memory scopes.
|
||||
func isValidMemoryScope(raw string) bool {
|
||||
switch memory.Scope(strings.TrimSpace(raw)) {
|
||||
case memory.ScopeUser, memory.ScopePersona, memory.ScopeProject:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ragIndexRequest is the POST /api/v1/admin/rag body.
|
||||
type ragIndexRequest struct {
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// adminIndexRag imports one knowledge document: it hands the name/description and
|
||||
// content to the knowledge store, which chunks it and indexes it into the project
|
||||
// index so the document becomes keyword/searchable. It is the admin "导入文档" path.
|
||||
func (s *Server) adminIndexRag(w http.ResponseWriter, r *http.Request) {
|
||||
var req ragIndexRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
s.auditClaims(r, "rag.index", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return
|
||||
}
|
||||
content := strings.TrimSpace(req.Content)
|
||||
if content == "" {
|
||||
s.auditClaims(r, "rag.index", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "content is required"))
|
||||
return
|
||||
}
|
||||
if s.rag == nil {
|
||||
s.auditClaims(r, "rag.index", "error")
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "知识库 RAG 服务未配置(未注入 RagStore)", HTTPCode: http.StatusNotImplemented})
|
||||
return
|
||||
}
|
||||
if err := s.rag.IndexDocument(r.Context(), knowledge.Doc{
|
||||
Name: name,
|
||||
Desc: strings.TrimSpace(req.Desc),
|
||||
Content: content,
|
||||
}); err != nil {
|
||||
s.auditClaims(r, "rag.index", "error")
|
||||
s.log.Error("rag index", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "rag_index_failed", "failed to index document"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "rag.index", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"name": name})
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,16 @@ func (s *Server) auditClaims(r *http.Request, what, result string) {
|
||||
s.audit(r.Context(), who, what, result)
|
||||
}
|
||||
|
||||
// AuditEvent records an external (non-HTTP) audit event with an explicit caller
|
||||
// identity, so programmatic sinks outside the request path (for example the
|
||||
// channel bridge's blacklist rejection hook, design/16 §4.6 §6) can append to the
|
||||
// same who/what/when/result trail. who is a free-form actor label ("channel" or
|
||||
// "bridge:telegram"); what is the operation and result its outcome. It is safe for
|
||||
// concurrent use.
|
||||
func (s *Server) AuditEvent(who, what, result string) {
|
||||
s.audit(context.Background(), who, what, result)
|
||||
}
|
||||
|
||||
// audit emits one structured audit record on the gateway logger and persists it to
|
||||
// the configured AuditStore (falling back to an in-memory store when none is
|
||||
// injected). A persist failure is logged but never blocks the admin write, so a
|
||||
|
||||
@@ -34,7 +34,11 @@ type historyMessage struct {
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
// SenderName is the human-readable sender display name of a third-party
|
||||
// message (group/stranger, design/16 §4.6). It is blank for the owner's
|
||||
// private chat on the main session, so empty means "labeled as the owner".
|
||||
SenderName string `json:"senderName,omitempty"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// handleHistorySessions returns the main session first, then every sub session (oldest
|
||||
@@ -125,6 +129,52 @@ func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleChannelPlatformLog returns up to limit messages for an IM platform across its
|
||||
// isolated (group/stranger) sessions, newest first, for the admin adapter log page
|
||||
// (design/16 §4.6 §4). It excludes the fixed main web session, so only the platform's
|
||||
// non-owner message stream is surfaced; the owner-private mirror stays out of this view.
|
||||
func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request) {
|
||||
if s.history == nil {
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
|
||||
return
|
||||
}
|
||||
platform := r.PathValue("platform")
|
||||
if platform == "" {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
|
||||
return
|
||||
}
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := historyDefaultLimit
|
||||
if limitStr != "" {
|
||||
v, err := strconv.Atoi(limitStr)
|
||||
if err != nil || v < 1 {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
|
||||
return
|
||||
}
|
||||
limit = v
|
||||
}
|
||||
var before int64
|
||||
if b := r.URL.Query().Get("before"); b != "" {
|
||||
v, err := strconv.ParseInt(b, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid before"))
|
||||
return
|
||||
}
|
||||
before = v
|
||||
}
|
||||
msgs, err := s.history.ListPlatformLog(r.Context(), platform, before, limit)
|
||||
if err != nil {
|
||||
s.log.Error("history platform log", "err", err, "platform", platform)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "platform_log_failed", "failed to list platform log"))
|
||||
return
|
||||
}
|
||||
out := make([]historyMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, toHistoryMessage(m))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleDeleteSession deletes a sub session and all its messages. The main session is
|
||||
// immutable (design/16 §6) and returns 403.
|
||||
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -160,9 +210,10 @@ func toHistoryMessage(m history.Message) historyMessage {
|
||||
Mood: m.Mood,
|
||||
Image: m.Image,
|
||||
Thinking: m.Thinking,
|
||||
Platform: m.Platform,
|
||||
DeviceID: m.DeviceID,
|
||||
CreatedAt: m.CreatedAt,
|
||||
Platform: m.Platform,
|
||||
DeviceID: m.DeviceID,
|
||||
SenderName: m.SenderName,
|
||||
CreatedAt: m.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ type HistoryStore interface {
|
||||
AppendFull(ctx context.Context, msg history.Message) (history.Message, error)
|
||||
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
|
||||
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
||||
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]history.Message, error)
|
||||
DeleteSession(ctx context.Context, sessionID string) error
|
||||
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
||||
ListSessions(ctx context.Context) ([]history.SessionMeta, error)
|
||||
@@ -347,6 +348,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /api/v1/admin/memories", adminAuth(http.HandlerFunc(s.adminListMemories)))
|
||||
mux.Handle("GET /api/v1/admin/plugins", adminAuth(http.HandlerFunc(s.adminListPlugins)))
|
||||
mux.Handle("GET /api/v1/admin/channels", adminAuth(http.HandlerFunc(s.adminListChannels)))
|
||||
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", adminAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
|
||||
mux.Handle("GET /api/v1/admin/iot", adminAuth(http.HandlerFunc(s.adminListIoT)))
|
||||
mux.Handle("GET /api/v1/admin/llm", adminAuth(http.HandlerFunc(s.adminGetLlm)))
|
||||
mux.Handle("GET /api/v1/admin/automations", adminAuth(http.HandlerFunc(s.adminListAutomations)))
|
||||
@@ -383,7 +385,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
|
||||
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
|
||||
mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall)))
|
||||
mux.Handle("POST /api/v1/admin/memories", ownerAuth(http.HandlerFunc(s.adminAddMemory)))
|
||||
mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch)))
|
||||
mux.Handle("POST /api/v1/admin/rag", ownerAuth(http.HandlerFunc(s.adminIndexRag)))
|
||||
mux.Handle("GET /api/v1/admin/sessions/{id}/export", adminAuth(http.HandlerFunc(s.adminExportSession)))
|
||||
|
||||
// External webhook callbacks (e.g. the webhook channel) are mounted without
|
||||
|
||||
@@ -183,6 +183,10 @@ func (f *fakeHistory) Recent(context.Context, string, int) ([]history.Message, e
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeHistory) ListPlatformLog(context.Context, string, int64, int) ([]history.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeHistory) DeleteSession(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user