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:
viettranx committed 2026-03-09 14:54:41 +07:00
1 parent a321af8b04
commit b284f963f5
19 files changed
+1546 -2

No files matched your search

+1
View File
@@ -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
+5
View File
@@ -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))
+9
View File
@@ -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 }
+43
View File
@@ -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)
}
}
+190
View File
@@ -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),
})
}
+28
View File
@@ -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)
+163
View File
@@ -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
}
+2
View File
@@ -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}>
+1 -1
View File
@@ -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
)}
+1 -1
View File
@@ -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}
+1
View File
@@ -31,6 +31,7 @@ export const ROUTES = {
TTS: "/tts",
STORAGE: "/storage",
PENDING_MESSAGES: "/pending-messages",
MEMORY: "/memory",
SETUP: "/setup",
} as const;
+4
View File
@@ -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,
},
};
+201
View File
@@ -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>
);
}
+305
View File
@@ -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>
);
}
+4
View File
@@ -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 */}
+35
View File
@@ -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;
}