mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
feat(memory): add memory management page with CRUD, search, and indexing
Add full-stack memory document management: - Backend: extend MemoryStore with admin queries (ListAllDocumentsGlobal, GetDocumentDetail, ListChunks), HTTP handler with auth middleware - Frontend: memory page with agent/scope filters, document table with pagination, view/edit dialog with content and chunks tabs, create dialog with scope selection, semantic search dialog - UI fixes: reduce input/textarea focus ring width, prevent ring clipping in dialog scroll containers, widen memory dialogs on desktop
This commit is contained in:
1 parent
a321af8b04
commit
b284f963f5
19 files changed
+1546
-2
No files matched your search
@@ -77,3 +77,4 @@ Go conventions to follow:
|
||||
- Use `switch/case` instead of `if/else if` chains on the same variable
|
||||
- Use `append(dst, src...)` instead of loop-based append
|
||||
- Always handle errors; don't ignore return values
|
||||
- **Migrations:** When adding a new SQL migration file in `migrations/`, bump `RequiredSchemaVersion` in `internal/upgrade/version.go` to match the new migration number
|
||||
@@ -617,6 +617,11 @@ func runGateway() {
|
||||
server.SetPendingMessagesHandler(pendingMessagesH)
|
||||
}
|
||||
|
||||
// Memory management API (wired directly, only needs MemoryStore + token)
|
||||
if pgStores != nil && pgStores.Memory != nil {
|
||||
server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token))
|
||||
}
|
||||
|
||||
// Workspace file serving endpoint — serves files by absolute path, auth-token protected.
|
||||
// Supports media from any agent workspace (each agent has its own workspace from DB).
|
||||
server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token))
|
||||
|
||||
@@ -45,6 +45,7 @@ type Server struct {
|
||||
delegationsHandler *httpapi.DelegationsHandler // delegation history API
|
||||
builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API
|
||||
pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API
|
||||
memoryHandler *httpapi.MemoryHandler // memory management API
|
||||
oauthHandler *httpapi.OAuthHandler // OAuth endpoints
|
||||
filesHandler *httpapi.FilesHandler // workspace file serving
|
||||
storageHandler *httpapi.StorageHandler // storage file management
|
||||
@@ -205,6 +206,11 @@ func (s *Server) BuildMux() *http.ServeMux {
|
||||
s.pendingMessagesHandler.RegisterRoutes(mux)
|
||||
}
|
||||
|
||||
// Memory management API
|
||||
if s.memoryHandler != nil {
|
||||
s.memoryHandler.RegisterRoutes(mux)
|
||||
}
|
||||
|
||||
// Workspace file serving (available in all modes)
|
||||
if s.filesHandler != nil {
|
||||
s.filesHandler.RegisterRoutes(mux)
|
||||
@@ -385,6 +391,9 @@ func (s *Server) SetMediaUploadHandler(h *httpapi.MediaUploadHandler) { s.mediaU
|
||||
// SetMediaServeHandler sets the media serve handler.
|
||||
func (s *Server) SetMediaServeHandler(h *httpapi.MediaServeHandler) { s.mediaServeHandler = h }
|
||||
|
||||
// SetMemoryHandler sets the memory management handler.
|
||||
func (s *Server) SetMemoryHandler(h *httpapi.MemoryHandler) { s.memoryHandler = h }
|
||||
|
||||
// SetAgentStore sets the agent store for context injection in tools_invoke.
|
||||
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// MemoryHandler handles memory document management endpoints.
|
||||
type MemoryHandler struct {
|
||||
store store.MemoryStore
|
||||
token string
|
||||
}
|
||||
|
||||
// NewMemoryHandler creates a handler for memory management endpoints.
|
||||
func NewMemoryHandler(s store.MemoryStore, token string) *MemoryHandler {
|
||||
return &MemoryHandler{store: s, token: token}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all memory routes on the given mux.
|
||||
func (h *MemoryHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/memory/documents", h.auth(h.handleListAllDocuments))
|
||||
mux.HandleFunc("GET /v1/agents/{agentID}/memory/documents", h.auth(h.handleListDocuments))
|
||||
mux.HandleFunc("GET /v1/agents/{agentID}/memory/documents/{path...}", h.auth(h.handleGetDocument))
|
||||
mux.HandleFunc("PUT /v1/agents/{agentID}/memory/documents/{path...}", h.auth(h.handlePutDocument))
|
||||
mux.HandleFunc("DELETE /v1/agents/{agentID}/memory/documents/{path...}", h.auth(h.handleDeleteDocument))
|
||||
mux.HandleFunc("GET /v1/agents/{agentID}/memory/chunks", h.auth(h.handleListChunks))
|
||||
mux.HandleFunc("POST /v1/agents/{agentID}/memory/index", h.auth(h.handleIndexDocument))
|
||||
mux.HandleFunc("POST /v1/agents/{agentID}/memory/index-all", h.auth(h.handleIndexAll))
|
||||
mux.HandleFunc("POST /v1/agents/{agentID}/memory/search", h.auth(h.handleSearch))
|
||||
}
|
||||
|
||||
func (h *MemoryHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if h.token != "" {
|
||||
if extractBearerToken(r) != h.token {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func (h *MemoryHandler) handleListAllDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
docs, err := h.store.ListAllDocumentsGlobal(r.Context())
|
||||
if err != nil {
|
||||
slog.Warn("memory.list_all_documents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if docs == nil {
|
||||
docs = []store.DocumentInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, docs)
|
||||
}
|
||||
|
||||
func (h *MemoryHandler) handleListDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
|
||||
var docs []store.DocumentInfo
|
||||
var err error
|
||||
if userID == "" {
|
||||
docs, err = h.store.ListAllDocuments(r.Context(), agentID)
|
||||
} else {
|
||||
docs, err = h.store.ListDocuments(r.Context(), agentID, userID)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("memory.list_documents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if docs == nil {
|
||||
docs = []store.DocumentInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, docs)
|
||||
}
|
||||
|
||||
func (h *MemoryHandler) handleGetDocument(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
path := r.PathValue("path")
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
|
||||
detail, err := h.store.GetDocumentDetail(r.Context(), agentID, userID, path)
|
||||
if err != nil {
|
||||
slog.Warn("memory.get_document failed", "error", err, "path", path)
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (h *MemoryHandler) handlePutDocument(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
path := r.PathValue("path")
|
||||
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.PutDocument(r.Context(), agentID, body.UserID, path, body.Content); err != nil {
|
||||
slog.Warn("memory.put_document failed", "error", err, "path", path)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "path": path})
|
||||
}
|
||||
|
||||
func (h *MemoryHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
path := r.PathValue("path")
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
|
||||
if err := h.store.DeleteDocument(r.Context(), agentID, userID, path); err != nil {
|
||||
slog.Warn("memory.delete_document failed", "error", err, "path", path)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (h *MemoryHandler) handleListChunks(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
path := r.URL.Query().Get("path")
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
|
||||
if path == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path query parameter required"})
|
||||
return
|
||||
}
|
||||
|
||||
chunks, err := h.store.ListChunks(r.Context(), agentID, userID, path)
|
||||
if err != nil {
|
||||
slog.Warn("memory.list_chunks failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if chunks == nil {
|
||||
chunks = []store.ChunkInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, chunks)
|
||||
}
|
||||
|
||||
func (h *MemoryHandler) handleIndexDocument(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if body.Path == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.IndexDocument(r.Context(), agentID, body.UserID, body.Path); err != nil {
|
||||
slog.Warn("memory.index_document failed", "error", err, "path", body.Path)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "indexed", "path": body.Path})
|
||||
}
|
||||
|
||||
func (h *MemoryHandler) handleIndexAll(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
|
||||
var body struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
if err := h.store.IndexAll(r.Context(), agentID, body.UserID); err != nil {
|
||||
slog.Warn("memory.index_all failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "indexed_all"})
|
||||
}
|
||||
|
||||
func (h *MemoryHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("agentID")
|
||||
|
||||
var body struct {
|
||||
Query string `json:"query"`
|
||||
UserID string `json:"user_id"`
|
||||
MaxResults int `json:"max_results"`
|
||||
MinScore float64 `json:"min_score"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if body.Query == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "query is required"})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.store.Search(r.Context(), body.Query, agentID, body.UserID, store.MemorySearchOptions{
|
||||
MaxResults: body.MaxResults,
|
||||
MinScore: body.MinScore,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("memory.search failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if results == nil {
|
||||
results = []store.MemorySearchResult{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import "context"
|
||||
type DocumentInfo struct {
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
@@ -36,6 +37,27 @@ type EmbeddingProvider interface {
|
||||
Embed(ctx context.Context, texts []string) ([][]float32, error)
|
||||
}
|
||||
|
||||
// DocumentDetail provides full document info including chunk/embedding stats.
|
||||
type DocumentDetail struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Hash string `json:"hash"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
ChunkCount int `json:"chunk_count"`
|
||||
EmbeddedCount int `json:"embedded_count"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ChunkInfo describes a single memory chunk.
|
||||
type ChunkInfo struct {
|
||||
ID string `json:"id"`
|
||||
StartLine int `json:"start_line"`
|
||||
EndLine int `json:"end_line"`
|
||||
TextPreview string `json:"text_preview"`
|
||||
HasEmbedding bool `json:"has_embedding"`
|
||||
}
|
||||
|
||||
// MemoryStore manages memory documents and search.
|
||||
type MemoryStore interface {
|
||||
// Document CRUD
|
||||
@@ -44,6 +66,12 @@ type MemoryStore interface {
|
||||
DeleteDocument(ctx context.Context, agentID, userID, path string) error
|
||||
ListDocuments(ctx context.Context, agentID, userID string) ([]DocumentInfo, error)
|
||||
|
||||
// Admin queries
|
||||
ListAllDocumentsGlobal(ctx context.Context) ([]DocumentInfo, error)
|
||||
ListAllDocuments(ctx context.Context, agentID string) ([]DocumentInfo, error)
|
||||
GetDocumentDetail(ctx context.Context, agentID, userID, path string) (*DocumentDetail, error)
|
||||
ListChunks(ctx context.Context, agentID, userID, path string) ([]ChunkInfo, error)
|
||||
|
||||
// Search
|
||||
Search(ctx context.Context, query string, agentID, userID string, opts MemorySearchOptions) ([]MemorySearchResult, error)
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// ListAllDocumentsGlobal returns all documents across all agents (for admin overview).
|
||||
func (s *PGMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.DocumentInfo, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT agent_id, path, hash, user_id, updated_at
|
||||
FROM memory_documents
|
||||
ORDER BY updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.DocumentInfo
|
||||
for rows.Next() {
|
||||
var agentID, path, hash string
|
||||
var uid *string
|
||||
var updatedAt time.Time
|
||||
if err := rows.Scan(&agentID, &path, &hash, &uid, &updatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
info := store.DocumentInfo{
|
||||
AgentID: agentID,
|
||||
Path: path,
|
||||
Hash: hash,
|
||||
UpdatedAt: updatedAt.UnixMilli(),
|
||||
}
|
||||
if uid != nil {
|
||||
info.UserID = *uid
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListAllDocuments returns all documents for an agent across all users (global + personal).
|
||||
func (s *PGMemoryStore) ListAllDocuments(ctx context.Context, agentID string) ([]store.DocumentInfo, error) {
|
||||
aid := mustParseUUID(agentID)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT agent_id, path, hash, user_id, updated_at
|
||||
FROM memory_documents WHERE agent_id = $1
|
||||
ORDER BY updated_at DESC`, aid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.DocumentInfo
|
||||
for rows.Next() {
|
||||
var aID, path, hash string
|
||||
var uid *string
|
||||
var updatedAt time.Time
|
||||
if err := rows.Scan(&aID, &path, &hash, &uid, &updatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
info := store.DocumentInfo{
|
||||
AgentID: aID,
|
||||
Path: path,
|
||||
Hash: hash,
|
||||
UpdatedAt: updatedAt.UnixMilli(),
|
||||
}
|
||||
if uid != nil {
|
||||
info.UserID = *uid
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDocumentDetail returns full document info with chunk and embedding counts.
|
||||
func (s *PGMemoryStore) GetDocumentDetail(ctx context.Context, agentID, userID, path string) (*store.DocumentDetail, error) {
|
||||
aid := mustParseUUID(agentID)
|
||||
|
||||
var q string
|
||||
var args []interface{}
|
||||
if userID == "" {
|
||||
q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at,
|
||||
COUNT(c.id) AS chunk_count,
|
||||
COUNT(c.embedding) AS embedded_count
|
||||
FROM memory_documents d
|
||||
LEFT JOIN memory_chunks c ON c.document_id = d.id
|
||||
WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL
|
||||
GROUP BY d.id`
|
||||
args = []interface{}{aid, path}
|
||||
} else {
|
||||
q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at,
|
||||
COUNT(c.id) AS chunk_count,
|
||||
COUNT(c.embedding) AS embedded_count
|
||||
FROM memory_documents d
|
||||
LEFT JOIN memory_chunks c ON c.document_id = d.id
|
||||
WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3
|
||||
GROUP BY d.id`
|
||||
args = []interface{}{aid, path, userID}
|
||||
}
|
||||
|
||||
var detail store.DocumentDetail
|
||||
var uid *string
|
||||
var createdAt, updatedAt time.Time
|
||||
err := s.db.QueryRowContext(ctx, q, args...).Scan(
|
||||
&detail.Path, &detail.Content, &detail.Hash, &uid,
|
||||
&createdAt, &updatedAt,
|
||||
&detail.ChunkCount, &detail.EmbeddedCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uid != nil {
|
||||
detail.UserID = *uid
|
||||
}
|
||||
detail.CreatedAt = createdAt.UnixMilli()
|
||||
detail.UpdatedAt = updatedAt.UnixMilli()
|
||||
return &detail, nil
|
||||
}
|
||||
|
||||
// ListChunks returns chunks for a document identified by agent, user, and path.
|
||||
func (s *PGMemoryStore) ListChunks(ctx context.Context, agentID, userID, path string) ([]store.ChunkInfo, error) {
|
||||
aid := mustParseUUID(agentID)
|
||||
|
||||
var q string
|
||||
var args []interface{}
|
||||
if userID == "" {
|
||||
q = `SELECT c.id, c.start_line, c.end_line,
|
||||
LEFT(c.text, 200) AS text_preview,
|
||||
(c.embedding IS NOT NULL) AS has_embedding
|
||||
FROM memory_chunks c
|
||||
JOIN memory_documents d ON c.document_id = d.id
|
||||
WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL
|
||||
ORDER BY c.start_line`
|
||||
args = []interface{}{aid, path}
|
||||
} else {
|
||||
q = `SELECT c.id, c.start_line, c.end_line,
|
||||
LEFT(c.text, 200) AS text_preview,
|
||||
(c.embedding IS NOT NULL) AS has_embedding
|
||||
FROM memory_chunks c
|
||||
JOIN memory_documents d ON c.document_id = d.id
|
||||
WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3
|
||||
ORDER BY c.start_line`
|
||||
args = []interface{}{aid, path, userID}
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.ChunkInfo
|
||||
for rows.Next() {
|
||||
var ci store.ChunkInfo
|
||||
if err := rows.Scan(&ci.ID, &ci.StartLine, &ci.EndLine, &ci.TextPreview, &ci.HasEmbedding); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, ci)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ArrowRightLeft,
|
||||
HardDrive,
|
||||
Inbox,
|
||||
Brain,
|
||||
} from "lucide-react";
|
||||
import { SidebarGroup } from "./sidebar-group";
|
||||
import { SidebarItem } from "./sidebar-item";
|
||||
@@ -81,6 +82,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
<SidebarItem to={ROUTES.CUSTOM_TOOLS} icon={Wrench} label="Custom Tools" collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.BUILTIN_TOOLS} icon={Package} label="Built-in Tools" collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.MCP} icon={Plug} label="MCP Servers" collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.MEMORY} icon={Brain} label="Memory" collapsed={collapsed} />
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup label="Monitoring" collapsed={collapsed}>
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-1",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ROUTES = {
|
||||
TTS: "/tts",
|
||||
STORAGE: "/storage",
|
||||
PENDING_MESSAGES: "/pending-messages",
|
||||
MEMORY: "/memory",
|
||||
SETUP: "/setup",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -58,4 +58,8 @@ export const queryKeys = {
|
||||
all: ["teams"] as const,
|
||||
detail: (id: string) => ["teams", id] as const,
|
||||
},
|
||||
memory: {
|
||||
all: ["memory"] as const,
|
||||
list: (params: Record<string, unknown>) => ["memory", params] as const,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type {
|
||||
MemoryDocument,
|
||||
MemoryDocumentDetail,
|
||||
MemoryChunk,
|
||||
MemorySearchResult,
|
||||
} from "@/types/memory";
|
||||
|
||||
export interface MemoryDocFilters {
|
||||
agentId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function useMemoryDocuments(filters: MemoryDocFilters) {
|
||||
const http = useHttp();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryKey = queryKeys.memory.list({ ...filters });
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
// No agent selected → list all memory across all agents
|
||||
if (!filters.agentId) {
|
||||
const res = await http.get<MemoryDocument[]>("/v1/memory/documents");
|
||||
return res ?? [];
|
||||
}
|
||||
const params: Record<string, string> = {};
|
||||
if (filters.userId) params.user_id = filters.userId;
|
||||
const res = await http.get<MemoryDocument[]>(
|
||||
`/v1/agents/${filters.agentId}/memory/documents`,
|
||||
params,
|
||||
);
|
||||
return res ?? [];
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const documents = data ?? [];
|
||||
|
||||
const invalidate = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: queryKeys.memory.all }),
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const getDocument = useCallback(
|
||||
async (path: string, userId?: string) => {
|
||||
const params: Record<string, string> = {};
|
||||
if (userId) params.user_id = userId;
|
||||
return http.get<MemoryDocumentDetail>(
|
||||
`/v1/agents/${filters.agentId}/memory/documents/${path}`,
|
||||
params,
|
||||
);
|
||||
},
|
||||
[http, filters.agentId],
|
||||
);
|
||||
|
||||
const createDocument = useCallback(
|
||||
async (path: string, content: string, userId?: string) => {
|
||||
try {
|
||||
await http.put(`/v1/agents/${filters.agentId}/memory/documents/${path}`, {
|
||||
content,
|
||||
user_id: userId || "",
|
||||
});
|
||||
await invalidate();
|
||||
toast.success("Document created", path);
|
||||
} catch (err) {
|
||||
toast.error("Failed to create document", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, filters.agentId, invalidate],
|
||||
);
|
||||
|
||||
const updateDocument = useCallback(
|
||||
async (path: string, content: string, userId?: string) => {
|
||||
try {
|
||||
await http.put(`/v1/agents/${filters.agentId}/memory/documents/${path}`, {
|
||||
content,
|
||||
user_id: userId || "",
|
||||
});
|
||||
await invalidate();
|
||||
toast.success("Document updated", path);
|
||||
} catch (err) {
|
||||
toast.error("Failed to update document", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, filters.agentId, invalidate],
|
||||
);
|
||||
|
||||
const deleteDocument = useCallback(
|
||||
async (path: string, userId?: string) => {
|
||||
try {
|
||||
const qs = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
|
||||
await http.delete(`/v1/agents/${filters.agentId}/memory/documents/${path}${qs}`);
|
||||
await invalidate();
|
||||
toast.success("Document deleted", path);
|
||||
} catch (err) {
|
||||
toast.error("Failed to delete document", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, filters.agentId, invalidate],
|
||||
);
|
||||
|
||||
const getChunks = useCallback(
|
||||
async (path: string, userId?: string) => {
|
||||
const params: Record<string, string> = { path };
|
||||
if (userId) params.user_id = userId;
|
||||
return http.get<MemoryChunk[]>(
|
||||
`/v1/agents/${filters.agentId}/memory/chunks`,
|
||||
params,
|
||||
);
|
||||
},
|
||||
[http, filters.agentId],
|
||||
);
|
||||
|
||||
const indexDocument = useCallback(
|
||||
async (path: string, userId?: string) => {
|
||||
try {
|
||||
await http.post(`/v1/agents/${filters.agentId}/memory/index`, {
|
||||
path,
|
||||
user_id: userId || "",
|
||||
});
|
||||
toast.success("Document indexed", path);
|
||||
} catch (err) {
|
||||
toast.error("Failed to index document", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, filters.agentId],
|
||||
);
|
||||
|
||||
const indexAll = useCallback(
|
||||
async (userId?: string) => {
|
||||
try {
|
||||
await http.post(`/v1/agents/${filters.agentId}/memory/index-all`, {
|
||||
user_id: userId || "",
|
||||
});
|
||||
toast.success("All documents indexed");
|
||||
} catch (err) {
|
||||
toast.error("Failed to index all", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, filters.agentId],
|
||||
);
|
||||
|
||||
return {
|
||||
documents,
|
||||
loading: isLoading,
|
||||
fetching: isFetching,
|
||||
refresh: invalidate,
|
||||
getDocument,
|
||||
createDocument,
|
||||
updateDocument,
|
||||
deleteDocument,
|
||||
getChunks,
|
||||
indexDocument,
|
||||
indexAll,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMemorySearch(agentId: string) {
|
||||
const http = useHttp();
|
||||
const [results, setResults] = useState<MemorySearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const search = useCallback(
|
||||
async (query: string, userId?: string, maxResults?: number, minScore?: number) => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await http.post<{ results: MemorySearchResult[]; count: number }>(
|
||||
`/v1/agents/${agentId}/memory/search`,
|
||||
{
|
||||
query,
|
||||
user_id: userId || "",
|
||||
max_results: maxResults || 10,
|
||||
min_score: minScore || 0,
|
||||
},
|
||||
);
|
||||
setResults(res.results ?? []);
|
||||
return res.results ?? [];
|
||||
} catch (err) {
|
||||
toast.error("Search failed", err instanceof Error ? err.message : "Unknown error");
|
||||
setResults([]);
|
||||
return [];
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
},
|
||||
[http, agentId],
|
||||
);
|
||||
|
||||
return { results, searching, search };
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { useMemoryDocuments } from "./hooks/use-memory";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
|
||||
interface MemoryCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Pre-selected agent from parent filter (optional) */
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
/** Known user/group IDs from existing docs */
|
||||
knownUserIds?: string[];
|
||||
}
|
||||
|
||||
export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId, knownUserIds = [] }: MemoryCreateDialogProps) {
|
||||
const { agents } = useAgents();
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
const [path, setPath] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [scopeMode, setScopeMode] = useState<"global" | "existing" | "custom">("global");
|
||||
const [selectedUserId, setSelectedUserId] = useState("");
|
||||
const [customUserId, setCustomUserId] = useState("");
|
||||
const [autoIndex, setAutoIndex] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const effectiveAgentId = selectedAgentId || parentAgentId || "";
|
||||
const { createDocument, indexDocument } = useMemoryDocuments({ agentId: effectiveAgentId || undefined });
|
||||
|
||||
const selectedAgent: AgentData | undefined = agents.find((a) => a.id === effectiveAgentId);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedAgentId(parentAgentId || "");
|
||||
setPath("");
|
||||
setContent("");
|
||||
setScopeMode("global");
|
||||
setSelectedUserId("");
|
||||
setCustomUserId("");
|
||||
setAutoIndex(true);
|
||||
setError("");
|
||||
}
|
||||
}, [open, parentAgentId]);
|
||||
|
||||
const resolvedUserId = (): string | undefined => {
|
||||
if (scopeMode === "global") return undefined;
|
||||
if (scopeMode === "existing") return selectedUserId || undefined;
|
||||
return customUserId.trim() || undefined;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!effectiveAgentId) {
|
||||
setError("Please select an agent");
|
||||
return;
|
||||
}
|
||||
if (!path.trim()) {
|
||||
setError("Path is required");
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError("Content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const uid = resolvedUserId();
|
||||
await createDocument(path.trim(), content, uid);
|
||||
if (autoIndex) {
|
||||
await indexDocument(path.trim(), uid);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !loading && onOpenChange(v)}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Memory Document</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2 px-1 -mx-1 overflow-y-auto min-h-0">
|
||||
{/* Agent selector */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="mc-agent">Agent *</Label>
|
||||
<select
|
||||
id="mc-agent"
|
||||
value={selectedAgentId || parentAgentId || ""}
|
||||
onChange={(e) => setSelectedAgentId(e.target.value)}
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select agent...</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.display_name || a.agent_key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedAgent?.workspace && (
|
||||
<p className="font-mono text-[10px] text-muted-foreground">{selectedAgent.workspace}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope selector */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Scope</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeMode === "global" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setScopeMode("global")}
|
||||
>
|
||||
Global
|
||||
</Button>
|
||||
{knownUserIds.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeMode === "existing" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setScopeMode("existing")}
|
||||
>
|
||||
Existing scope
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeMode === "custom" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setScopeMode("custom")}
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
{scopeMode === "existing" && knownUserIds.length > 0 && (
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select group/user...</option>
|
||||
{knownUserIds.map((uid) => (
|
||||
<option key={uid} value={uid}>
|
||||
{formatScopeLabel(uid)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{scopeMode === "custom" && (
|
||||
<Input
|
||||
value={customUserId}
|
||||
onChange={(e) => setCustomUserId(e.target.value)}
|
||||
placeholder="e.g. group:telegram:-100123456"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Global = shared across all users. Personal = scoped to a specific user or group chat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="mc-path">Path *</Label>
|
||||
<Input
|
||||
id="mc-path"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="e.g. notes/project-overview.md"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="mc-content">Content *</Label>
|
||||
<Textarea
|
||||
id="mc-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Document content..."
|
||||
className="font-mono text-xs min-h-[200px]"
|
||||
rows={10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="mc-index" checked={autoIndex} onCheckedChange={setAutoIndex} />
|
||||
<Label htmlFor="mc-index">Auto-index after creation</Label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatScopeLabel(userId: string): string {
|
||||
if (userId.startsWith("group:")) {
|
||||
const parts = userId.split(":");
|
||||
if (parts.length >= 3) {
|
||||
const channel = parts[1]!.charAt(0).toUpperCase() + parts[1]!.slice(1);
|
||||
return `${channel} ${parts.slice(2).join(":")}`;
|
||||
}
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { MemoryDocument, MemoryDocumentDetail, MemoryChunk } from "@/types/memory";
|
||||
|
||||
interface MemoryDocumentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agentId: string;
|
||||
document: MemoryDocument | null;
|
||||
}
|
||||
|
||||
export function MemoryDocumentDialog({ open, onOpenChange, agentId, document }: MemoryDocumentDialogProps) {
|
||||
const http = useHttp();
|
||||
const [tab, setTab] = useState<"content" | "chunks">("content");
|
||||
const [detail, setDetail] = useState<MemoryDocumentDetail | null>(null);
|
||||
const [chunks, setChunks] = useState<MemoryChunk[]>([]);
|
||||
const [content, setContent] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [loadingChunks, setLoadingChunks] = useState(false);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!document || !agentId) return;
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (document.user_id) params.user_id = document.user_id;
|
||||
const res = await http.get<MemoryDocumentDetail>(
|
||||
`/v1/agents/${agentId}/memory/documents/${document.path}`,
|
||||
params,
|
||||
);
|
||||
setDetail(res);
|
||||
setContent(res.content);
|
||||
} catch {
|
||||
toast.error("Failed to load document");
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}, [http, agentId, document]);
|
||||
|
||||
const loadChunks = useCallback(async () => {
|
||||
if (!document || !agentId) return;
|
||||
setLoadingChunks(true);
|
||||
try {
|
||||
const params: Record<string, string> = { path: document.path };
|
||||
if (document.user_id) params.user_id = document.user_id;
|
||||
const res = await http.get<MemoryChunk[]>(
|
||||
`/v1/agents/${agentId}/memory/chunks`,
|
||||
params,
|
||||
);
|
||||
setChunks(res ?? []);
|
||||
} catch {
|
||||
setChunks([]);
|
||||
} finally {
|
||||
setLoadingChunks(false);
|
||||
}
|
||||
}, [http, agentId, document]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && document) {
|
||||
setTab("content");
|
||||
setDetail(null);
|
||||
setChunks([]);
|
||||
loadDetail();
|
||||
}
|
||||
}, [open, document, loadDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "chunks" && chunks.length === 0 && !loadingChunks && document) {
|
||||
loadChunks();
|
||||
}
|
||||
}, [tab, chunks.length, loadingChunks, document, loadChunks]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!document || !agentId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await http.put(`/v1/agents/${agentId}/memory/documents/${document.path}`, {
|
||||
content,
|
||||
user_id: document.user_id || "",
|
||||
});
|
||||
toast.success("Document updated", document.path);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error("Failed to save", err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = detail != null && content !== detail.content;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !saving && onOpenChange(v)}>
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{document?.path}</span>
|
||||
{document?.user_id ? (
|
||||
<Badge variant="secondary">Personal</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Global</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${tab === "content" ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setTab("content")}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${tab === "chunks" ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setTab("chunks")}
|
||||
>
|
||||
Chunks {detail && `(${detail.chunk_count})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-2 px-1 -mx-1">
|
||||
{tab === "content" && (
|
||||
<div className="grid gap-3">
|
||||
{detail && (
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span>Chunks: {detail.chunk_count}</span>
|
||||
<span>Embedded: {detail.embedded_count}/{detail.chunk_count}</span>
|
||||
<span>Created: {new Date(detail.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Content</Label>
|
||||
{loadingDetail ? (
|
||||
<div className="h-48 flex items-center justify-center text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="font-mono text-xs min-h-[300px]"
|
||||
rows={15}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "chunks" && (
|
||||
<div>
|
||||
{loadingChunks ? (
|
||||
<div className="py-8 text-center text-muted-foreground">Loading chunks...</div>
|
||||
) : chunks.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">No chunks. Index this document first.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{chunks.map((chunk) => (
|
||||
<div key={chunk.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium">
|
||||
Lines {chunk.start_line}-{chunk.end_line}
|
||||
</span>
|
||||
<Badge variant={chunk.has_embedding ? "default" : "secondary"} className="text-[10px]">
|
||||
{chunk.has_embedding ? "Embedded" : "No embedding"}
|
||||
</Badge>
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{chunk.text_preview}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
Close
|
||||
</Button>
|
||||
{tab === "content" && (
|
||||
<Button onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Brain, Plus, RefreshCw, Search, Database, Trash2, RotateCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { useMemoryDocuments } from "./hooks/use-memory";
|
||||
import { MemoryDocumentDialog } from "./memory-document-dialog";
|
||||
import { MemoryCreateDialog } from "./memory-create-dialog";
|
||||
import { MemorySearchDialog } from "./memory-search-dialog";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import type { MemoryDocument } from "@/types/memory";
|
||||
|
||||
export function MemoryPage() {
|
||||
const { agents } = useAgents();
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [userIdFilter, setUserIdFilter] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [viewDoc, setViewDoc] = useState<MemoryDocument | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MemoryDocument | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [indexAllLoading, setIndexAllLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const {
|
||||
documents,
|
||||
loading,
|
||||
fetching,
|
||||
refresh,
|
||||
deleteDocument,
|
||||
indexDocument,
|
||||
indexAll,
|
||||
} = useMemoryDocuments({ agentId: agentId || undefined, userId: userIdFilter || undefined });
|
||||
|
||||
const spinning = useMinLoading(fetching);
|
||||
const showSkeleton = useDeferredLoading(loading && documents.length === 0);
|
||||
|
||||
// Extract unique user_ids from documents for the scope filter dropdown
|
||||
const userIds = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const doc of documents) {
|
||||
if (doc.user_id) set.add(doc.user_id);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [documents]);
|
||||
|
||||
// Build agent lookup map for displaying agent names in global view
|
||||
const agentMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const a of agents) {
|
||||
map.set(a.id, a.display_name || a.agent_key);
|
||||
}
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const selectedAgent = agents.find((a) => a.id === agentId);
|
||||
|
||||
// Client-side pagination
|
||||
const total = documents.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const paginatedDocs = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return documents.slice(start, start + pageSize);
|
||||
}, [documents, page, pageSize]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteDocument(deleteTarget.path, deleteTarget.user_id);
|
||||
setDeleteTarget(null);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIndexAll = async () => {
|
||||
setIndexAllLoading(true);
|
||||
try {
|
||||
await indexAll(userIdFilter || undefined);
|
||||
} finally {
|
||||
setIndexAllLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReindex = async (doc: MemoryDocument) => {
|
||||
await indexDocument(doc.path, doc.user_id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title="Memory"
|
||||
description="Browse and manage agent memory documents, chunks, and embeddings"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
{agentId && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setSearchOpen(true)} className="gap-1" variant="outline">
|
||||
<Search className="h-3.5 w-3.5" /> Search
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)} className="gap-1">
|
||||
<Plus className="h-3.5 w-3.5" /> Create
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => refresh()} className="gap-1">
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mt-4 flex flex-wrap items-end gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="mem-agent" className="text-xs">Agent</Label>
|
||||
<select
|
||||
id="mem-agent"
|
||||
value={agentId}
|
||||
onChange={(e) => { setAgentId(e.target.value); setUserIdFilter(""); setPage(1); }}
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">All agents</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.display_name || a.agent_key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="mem-scope" className="text-xs">Scope</Label>
|
||||
<select
|
||||
id="mem-scope"
|
||||
value={userIdFilter}
|
||||
onChange={(e) => { setUserIdFilter(e.target.value); setPage(1); }}
|
||||
className="h-9 rounded-md border bg-background px-3 text-sm min-w-[180px]"
|
||||
>
|
||||
<option value="">All (global + personal)</option>
|
||||
{userIds.map((uid) => (
|
||||
<option key={uid} value={uid}>
|
||||
{formatScopeLabel(uid)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{agentId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleIndexAll}
|
||||
disabled={indexAllLoading}
|
||||
className="h-9 gap-1"
|
||||
>
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
{indexAllLoading ? "Indexing..." : "Index All"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document table */}
|
||||
<div className="mt-4">
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : documents.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Brain}
|
||||
title="No memory documents"
|
||||
description={agentId ? "This agent has no memory documents yet." : "No memory documents found across any agent."}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">Path</th>
|
||||
{!agentId && <th className="px-4 py-3 text-left font-medium">Agent</th>}
|
||||
<th className="px-4 py-3 text-left font-medium">Scope</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Hash</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Updated</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedDocs.map((doc) => (
|
||||
<tr key={`${doc.agent_id}-${doc.path}-${doc.user_id || "global"}`} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
className="flex items-start gap-2 text-left hover:underline cursor-pointer"
|
||||
onClick={() => setViewDoc(doc)}
|
||||
>
|
||||
<Brain className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<span className="font-mono text-xs font-medium">{doc.path}</span>
|
||||
{selectedAgent?.workspace && (
|
||||
<p className="font-mono text-[10px] text-muted-foreground">{selectedAgent.workspace}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
{!agentId && (
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||
{doc.agent_id ? (agentMap.get(doc.agent_id) || doc.agent_id.slice(0, 8)) : "-"}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={doc.user_id ? "secondary" : "outline"}>
|
||||
{doc.user_id ? "Personal" : "Global"}
|
||||
</Badge>
|
||||
{doc.user_id && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">{formatScopeLabel(doc.user_id)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{doc.hash.slice(0, 8)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">
|
||||
{new Date(doc.updated_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleReindex(doc)} className="gap-1">
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(doc)}
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<MemoryDocumentDialog
|
||||
open={!!viewDoc}
|
||||
onOpenChange={(open) => !open && setViewDoc(null)}
|
||||
agentId={viewDoc?.agent_id || agentId}
|
||||
document={viewDoc}
|
||||
/>
|
||||
|
||||
<MemoryCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
agentId={agentId || undefined}
|
||||
knownUserIds={userIds}
|
||||
/>
|
||||
|
||||
<MemorySearchDialog
|
||||
open={searchOpen}
|
||||
onOpenChange={setSearchOpen}
|
||||
agentId={agentId}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
title="Delete Memory Document"
|
||||
description={`Are you sure you want to delete "${deleteTarget?.path}"? This will also delete all associated chunks and embeddings.`}
|
||||
confirmLabel="Delete"
|
||||
variant="destructive"
|
||||
onConfirm={handleDelete}
|
||||
loading={deleteLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format user_id into a readable scope label (e.g. "group:telegram:-100xxx" -> "Telegram -100xxx") */
|
||||
function formatScopeLabel(userId: string): string {
|
||||
if (userId.startsWith("group:")) {
|
||||
const parts = userId.split(":");
|
||||
if (parts.length >= 3) {
|
||||
const channel = parts[1]!.charAt(0).toUpperCase() + parts[1]!.slice(1);
|
||||
return `${channel} ${parts.slice(2).join(":")}`;
|
||||
}
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useMemorySearch } from "./hooks/use-memory";
|
||||
|
||||
interface MemorySearchDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function MemorySearchDialog({ open, onOpenChange, agentId }: MemorySearchDialogProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const { results, searching, search } = useMemorySearch(agentId);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) return;
|
||||
await search(query.trim(), userId.trim() || undefined);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !searching) {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Search Memory</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search query..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Input
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="User ID (opt.)"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={searching || !query.trim()} className="gap-1">
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
{searching ? "..." : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto mt-2">
|
||||
{results.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
{searching ? "Searching..." : "Enter a query and press Search."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{results.length} result(s)</p>
|
||||
{results.map((r, i) => (
|
||||
<div key={i} className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs font-medium">{r.path}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
L{r.start_line}-{r.end_line}
|
||||
</span>
|
||||
<ScoreBar score={r.score} />
|
||||
{r.scope && (
|
||||
<Badge variant={r.scope === "personal" ? "secondary" : "outline"} className="text-[10px]">
|
||||
{r.scope}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{r.snippet}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBar({ score }: { score: number }) {
|
||||
const pct = Math.min(100, Math.round(score * 100));
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-1.5 w-12 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,6 +81,9 @@ const SetupPage = lazy(() =>
|
||||
const PendingMessagesPage = lazy(() =>
|
||||
import("@/pages/pending-messages/pending-messages-page").then((m) => ({ default: m.PendingMessagesPage })),
|
||||
);
|
||||
const MemoryPage = lazy(() =>
|
||||
import("@/pages/memory/memory-page").then((m) => ({ default: m.MemoryPage })),
|
||||
);
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
@@ -148,6 +151,7 @@ export function AppRoutes() {
|
||||
<Route path={ROUTES.TTS} element={<TtsPage />} />
|
||||
<Route path={ROUTES.STORAGE} element={<StoragePage />} />
|
||||
<Route path={ROUTES.PENDING_MESSAGES} element={<PendingMessagesPage />} />
|
||||
<Route path={ROUTES.MEMORY} element={<MemoryPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all → overview */}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface MemoryDocument {
|
||||
path: string;
|
||||
hash: string;
|
||||
agent_id?: string;
|
||||
user_id?: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface MemoryDocumentDetail {
|
||||
path: string;
|
||||
content: string;
|
||||
hash: string;
|
||||
user_id?: string;
|
||||
chunk_count: number;
|
||||
embedded_count: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface MemoryChunk {
|
||||
id: string;
|
||||
start_line: number;
|
||||
end_line: number;
|
||||
text_preview: string;
|
||||
has_embedding: boolean;
|
||||
}
|
||||
|
||||
export interface MemorySearchResult {
|
||||
path: string;
|
||||
start_line: number;
|
||||
end_line: number;
|
||||
score: number;
|
||||
snippet: string;
|
||||
scope?: string;
|
||||
}
|
||||
Reference in new issue
Block a user