From 9d2c78b4544e647fc0285b4c55c57c05d3e7d89b Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Tue, 7 Jul 2026 16:09:28 +0530 Subject: [PATCH 01/10] feat: connect memory/rule deletion to backend API and align user_id - Add deleteMemory, deleteAllMemories, deleteRule, deleteAllRules API functions - MemoryList.tsx and RulesEditor.tsx now call backend API on delete - Use JWT sub claim for user identification instead of preferred_username Signed-off-by: Naveen Saharan --- src/frontend/components/layout/AppLayout.tsx | 2 +- .../components/settings/MemoryList.tsx | 5 +- .../components/settings/RulesEditor.tsx | 5 +- src/frontend/pages/ChatPage.tsx | 2 +- src/frontend/services/agent-rest.ts | 60 +++++++++++++++++++ 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/frontend/components/layout/AppLayout.tsx b/src/frontend/components/layout/AppLayout.tsx index a722121..91ee6ce 100644 --- a/src/frontend/components/layout/AppLayout.tsx +++ b/src/frontend/components/layout/AppLayout.tsx @@ -116,7 +116,7 @@ export function AppLayout({ children }: AppLayoutProps) { async function loadUserHistory() { try { dispatch(setLoadingThreads(true)); - const history = await getAllThreadsByUserId(window.USER_DATA.preferred_username); + const history = await getAllThreadsByUserId(window.USER_DATA.sub || window.USER_DATA.preferred_username); const backendIds = new Set(history.map((t) => t.id)); const local = chatsRef.current; diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx index a08fabd..f339b00 100644 --- a/src/frontend/components/settings/MemoryList.tsx +++ b/src/frontend/components/settings/MemoryList.tsx @@ -3,6 +3,7 @@ import { Button } from '@patternfly/react-core'; import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react'; import { useAppDispatch, useAppSelector } from '../../redux/hooks'; import { addMemory, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization'; +import { deleteMemory, deleteAllMemories } from '../../services/agent-rest'; export function MemoryList() { const dispatch = useAppDispatch(); @@ -75,7 +76,7 @@ export function MemoryList() {

{mem.content}

diff --git a/src/frontend/components/settings/RulesEditor.tsx b/src/frontend/components/settings/RulesEditor.tsx index dc7c030..ca8bafc 100644 --- a/src/frontend/components/settings/RulesEditor.tsx +++ b/src/frontend/components/settings/RulesEditor.tsx @@ -9,6 +9,7 @@ import { clearRules, selectRules, } from '../../redux/slices/personalization'; +import { deleteRule, deleteAllRules } from '../../services/agent-rest'; export function RulesEditor() { const dispatch = useAppDispatch(); @@ -93,7 +94,7 @@ export function RulesEditor() { {rule.content}

diff --git a/src/frontend/pages/ChatPage.tsx b/src/frontend/pages/ChatPage.tsx index c98b8e1..f645bb8 100644 --- a/src/frontend/pages/ChatPage.tsx +++ b/src/frontend/pages/ChatPage.tsx @@ -85,7 +85,7 @@ export function ChatPage({ threadId }: { threadId: string }) { const feedbackUserId = useMemo(() => { if (typeof window === 'undefined') return 'anonymous'; - const u = window.USER_DATA?.preferred_username; + const u = window.USER_DATA?.sub || window.USER_DATA?.preferred_username; return typeof u === 'string' && u.length > 0 ? u : 'anonymous'; }, []); diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts index 54a1b0d..49b34d6 100644 --- a/src/frontend/services/agent-rest.ts +++ b/src/frontend/services/agent-rest.ts @@ -161,6 +161,66 @@ export async function deleteThread(threadId: string): Promise { } } +/** + * Delete a single memory from the backend. + */ +export async function deleteMemory(memoryId: string): Promise { + try { + const resp = await authenticatedFetch(buildAgentApiUrl(`/memories/${memoryId}`), { + method: 'DELETE', + headers: getAuthHeaders(), + }); + return resp.ok || resp.status === 404; + } catch { + return false; + } +} + +/** + * Delete all memories for the authenticated user. + */ +export async function deleteAllMemories(): Promise { + try { + const resp = await authenticatedFetch(buildAgentApiUrl('/memories'), { + method: 'DELETE', + headers: getAuthHeaders(), + }); + return resp.ok; + } catch { + return false; + } +} + +/** + * Delete a single rule from the backend. + */ +export async function deleteRule(ruleId: string): Promise { + try { + const resp = await authenticatedFetch(buildAgentApiUrl(`/rules/${ruleId}`), { + method: 'DELETE', + headers: getAuthHeaders(), + }); + return resp.ok || resp.status === 404; + } catch { + return false; + } +} + +/** + * Delete all rules for the authenticated user. + */ +export async function deleteAllRules(): Promise { + try { + const resp = await authenticatedFetch(buildAgentApiUrl('/rules'), { + method: 'DELETE', + headers: getAuthHeaders(), + }); + return resp.ok; + } catch { + return false; + } +} + /** * Fetch full state for a single thread (lazy, on-demand). * Called only when a user navigates into a specific chat. From 0cc486481884314316a7ad41d4db85f6544e0118 Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Tue, 7 Jul 2026 18:17:14 +0530 Subject: [PATCH 02/10] fix: remove Content-Type header from DELETE requests to prevent proxy rejection Signed-off-by: Naveen Saharan --- src/frontend/services/agent-rest.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts index 49b34d6..7453f74 100644 --- a/src/frontend/services/agent-rest.ts +++ b/src/frontend/services/agent-rest.ts @@ -97,10 +97,11 @@ function combineToolCallandResult(messages: Message[]) { return newMessages; } -function getAuthHeaders(): Record { - const headers: Record = { - 'Content-Type': 'application/json', - }; +function getAuthHeaders(includeContentType = true): Record { + const headers: Record = {}; + if (includeContentType) { + headers['Content-Type'] = 'application/json'; + } if (window.USER_DATA?.accessToken) { headers['X-Token'] = window.USER_DATA.accessToken; } @@ -153,7 +154,7 @@ export async function deleteThread(threadId: string): Promise { try { const resp = await authenticatedFetch(deleteUrl, { method: 'DELETE', - headers: getAuthHeaders(), + headers: getAuthHeaders(false), }); return resp.ok || resp.status === 404; } catch { @@ -168,7 +169,7 @@ export async function deleteMemory(memoryId: string): Promise { try { const resp = await authenticatedFetch(buildAgentApiUrl(`/memories/${memoryId}`), { method: 'DELETE', - headers: getAuthHeaders(), + headers: getAuthHeaders(false), }); return resp.ok || resp.status === 404; } catch { @@ -183,7 +184,7 @@ export async function deleteAllMemories(): Promise { try { const resp = await authenticatedFetch(buildAgentApiUrl('/memories'), { method: 'DELETE', - headers: getAuthHeaders(), + headers: getAuthHeaders(false), }); return resp.ok; } catch { @@ -198,7 +199,7 @@ export async function deleteRule(ruleId: string): Promise { try { const resp = await authenticatedFetch(buildAgentApiUrl(`/rules/${ruleId}`), { method: 'DELETE', - headers: getAuthHeaders(), + headers: getAuthHeaders(false), }); return resp.ok || resp.status === 404; } catch { @@ -213,7 +214,7 @@ export async function deleteAllRules(): Promise { try { const resp = await authenticatedFetch(buildAgentApiUrl('/rules'), { method: 'DELETE', - headers: getAuthHeaders(), + headers: getAuthHeaders(false), }); return resp.ok; } catch { From 4b7a04d4aaaed52e387f26a02535c084f3bc67a6 Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Wed, 8 Jul 2026 12:23:41 +0530 Subject: [PATCH 03/10] feat: /memory command + fix stream cancel on navigation - /memory command: typing "/memory " in chat saves to backend DB - createMemory() API function added to agent-rest.ts - MemoryList.tsx: add-memory now syncs to backend API - Fix: cancel active stream when navigating between chats - Fix: reload thread state from backend when returning to a chat that only has human messages (stream was cancelled mid-flight) Signed-off-by: Naveen Saharan --- .../components/settings/MemoryList.tsx | 3 +- src/frontend/hooks/useStreamingAPI.ts | 30 +++++++++++++++++++ src/frontend/pages/ChatPage.tsx | 5 +++- src/frontend/services/agent-rest.ts | 16 ++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx index f339b00..3632271 100644 --- a/src/frontend/components/settings/MemoryList.tsx +++ b/src/frontend/components/settings/MemoryList.tsx @@ -3,7 +3,7 @@ import { Button } from '@patternfly/react-core'; import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react'; import { useAppDispatch, useAppSelector } from '../../redux/hooks'; import { addMemory, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization'; -import { deleteMemory, deleteAllMemories } from '../../services/agent-rest'; +import { createMemory, deleteMemory, deleteAllMemories } from '../../services/agent-rest'; export function MemoryList() { const dispatch = useAppDispatch(); @@ -14,6 +14,7 @@ export function MemoryList() { const text = draft.trim(); if (!text) return; dispatch(addMemory(text)); + createMemory(text).catch(() => {}); setDraft(''); }; diff --git a/src/frontend/hooks/useStreamingAPI.ts b/src/frontend/hooks/useStreamingAPI.ts index 1f0651c..a328410 100644 --- a/src/frontend/hooks/useStreamingAPI.ts +++ b/src/frontend/hooks/useStreamingAPI.ts @@ -250,6 +250,16 @@ export function useStreamingAPI(threadId: string) { }; }, []); + useEffect(() => { + const manager = managerRef.current; + return () => { + const st = manager?.getStatus(); + if (st === 'connecting' || st === 'streaming') { + manager?.cancel(); + } + }; + }, [threadId]); + useEffect(() => { if (!threadId) return; chatStorage.saveChatByThreadId(threadId, messages); @@ -296,6 +306,26 @@ export function useStreamingAPI(threadId: string) { const messageText = serializeLastMessage(clones); if (messageText === '') return; + if (messageText.startsWith('/memory ')) { + const memoryContent = messageText.slice('/memory '.length).trim(); + if (memoryContent) { + const { createMemory } = await import('@/services/agent-rest'); + const saved = await createMemory(memoryContent); + const confirmMsg: Message = { + id: `msg-${Date.now()}`, + type: 'ai' as const, + content: saved + ? `Memory saved: "${memoryContent}"` + : `Failed to save memory. Please try again.`, + }; + const updated = [...clones, confirmMsg]; + dispatch(updateChat({ id: threadId, updates: { messages: updated } })); + setMessages(updated.map((m) => JSON.parse(JSON.stringify(m)))); + chatStorage.saveChatByThreadId(threadId, updated); + } + return; + } + const token = typeof window.USER_DATA.accessToken === 'string' ? window.USER_DATA.accessToken : undefined; const userId = typeof window.USER_DATA.preferred_username === 'string' diff --git a/src/frontend/pages/ChatPage.tsx b/src/frontend/pages/ChatPage.tsx index f645bb8..a2ed44b 100644 --- a/src/frontend/pages/ChatPage.tsx +++ b/src/frontend/pages/ChatPage.tsx @@ -90,12 +90,15 @@ export function ChatPage({ threadId }: { threadId: string }) { }, []); useEffect(() => { - if (!chatId || hasMessages || hydrating) return; + if (!chatId || hydrating) return; const locState = location.state as Record | null; if (locState?.initialPrompt != null) return; if (isClientCreatedChat(chatId)) return; + const hasOnlyHumanMessages = hasMessages && currentChat?.messages.every(m => m.type === 'human'); + if (hasMessages && !hasOnlyHumanMessages) return; + let cancelled = false; setHydrating(true); diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts index 7453f74..1db6684 100644 --- a/src/frontend/services/agent-rest.ts +++ b/src/frontend/services/agent-rest.ts @@ -162,6 +162,22 @@ export async function deleteThread(threadId: string): Promise { } } +/** + * Create a persistent memory on the backend for the authenticated user. + */ +export async function createMemory(content: string): Promise { + try { + const resp = await authenticatedFetch(buildAgentApiUrl('/memories'), { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ content }), + }); + return resp.ok; + } catch { + return false; + } +} + /** * Delete a single memory from the backend. */ From bb47129060b27e064760e355c0dac5418a0debf2 Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Wed, 8 Jul 2026 12:32:32 +0530 Subject: [PATCH 04/10] fix: load backend memories into Settings page on mount Signed-off-by: Naveen Saharan --- src/frontend/components/settings/MemoryList.tsx | 16 ++++++++++++++-- src/frontend/services/agent-rest.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx index 3632271..9f787bc 100644 --- a/src/frontend/components/settings/MemoryList.tsx +++ b/src/frontend/components/settings/MemoryList.tsx @@ -1,15 +1,27 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Button } from '@patternfly/react-core'; import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react'; import { useAppDispatch, useAppSelector } from '../../redux/hooks'; import { addMemory, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization'; -import { createMemory, deleteMemory, deleteAllMemories } from '../../services/agent-rest'; +import { createMemory, deleteMemory, deleteAllMemories, listMemories } from '../../services/agent-rest'; export function MemoryList() { const dispatch = useAppDispatch(); const memories = useAppSelector(selectMemories); const [draft, setDraft] = useState(''); + useEffect(() => { + listMemories().then((backendMems) => { + const localIds = new Set(memories.map((m) => m.content)); + for (const bm of backendMems) { + if (!localIds.has(bm.content)) { + dispatch(addMemory(bm.content)); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleAdd = () => { const text = draft.trim(); if (!text) return; diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts index 1db6684..c4d9959 100644 --- a/src/frontend/services/agent-rest.ts +++ b/src/frontend/services/agent-rest.ts @@ -162,6 +162,22 @@ export async function deleteThread(threadId: string): Promise { } } +/** + * List all memories from the backend for the authenticated user. + */ +export async function listMemories(): Promise> { + try { + const resp = await authenticatedFetch(buildAgentApiUrl('/memories'), { + headers: getAuthHeaders(), + }); + if (!resp.ok) return []; + const data = await resp.json(); + return data.memories || []; + } catch { + return []; + } +} + /** * Create a persistent memory on the backend for the authenticated user. */ From acf3b027f5e6565bed951f48bed01fc9388d3412 Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Wed, 8 Jul 2026 12:35:27 +0530 Subject: [PATCH 05/10] fix: prevent duplicate memories when syncing backend to localStorage Signed-off-by: Naveen Saharan --- src/frontend/components/settings/MemoryList.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx index 9f787bc..345f04e 100644 --- a/src/frontend/components/settings/MemoryList.tsx +++ b/src/frontend/components/settings/MemoryList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '@patternfly/react-core'; import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react'; import { useAppDispatch, useAppSelector } from '../../redux/hooks'; @@ -9,12 +9,18 @@ export function MemoryList() { const dispatch = useAppDispatch(); const memories = useAppSelector(selectMemories); const [draft, setDraft] = useState(''); + const syncedRef = useRef(false); useEffect(() => { + if (syncedRef.current) return; + syncedRef.current = true; listMemories().then((backendMems) => { - const localIds = new Set(memories.map((m) => m.content)); + const existing = new Set( + document.querySelectorAll('[data-memory-content]') + ); + const localContents = new Set(memories.map((m) => m.content)); for (const bm of backendMems) { - if (!localIds.has(bm.content)) { + if (!localContents.has(bm.content)) { dispatch(addMemory(bm.content)); } } @@ -25,7 +31,10 @@ export function MemoryList() { const handleAdd = () => { const text = draft.trim(); if (!text) return; - dispatch(addMemory(text)); + const alreadyExists = memories.some((m) => m.content === text); + if (!alreadyExists) { + dispatch(addMemory(text)); + } createMemory(text).catch(() => {}); setDraft(''); }; From 9df525a0d04213df3d9633fc5a6a86623c2e3f47 Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Wed, 8 Jul 2026 12:41:14 +0530 Subject: [PATCH 06/10] fix: cancel backend stream and send beacon when navigating between chats Signed-off-by: Naveen Saharan --- src/frontend/hooks/useStreamingAPI.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/frontend/hooks/useStreamingAPI.ts b/src/frontend/hooks/useStreamingAPI.ts index a328410..3f9dda3 100644 --- a/src/frontend/hooks/useStreamingAPI.ts +++ b/src/frontend/hooks/useStreamingAPI.ts @@ -252,10 +252,20 @@ export function useStreamingAPI(threadId: string) { useEffect(() => { const manager = managerRef.current; + const currentThreadId = threadIdRef.current; return () => { const st = manager?.getStatus(); if (st === 'connecting' || st === 'streaming') { manager?.cancel(); + const apiUrl = typeof window.APP_DATA?.apiUrl === 'string' ? window.APP_DATA.apiUrl : ''; + const cancelUrl = apiUrl ? `${apiUrl}/v1/stream/cancel` : buildAgentApiUrl('/v1/stream/cancel'); + if (typeof navigator.sendBeacon === 'function') { + const payload = JSON.stringify({ + thread_id: currentThreadId, + event: 'client_stream_cancel', + }); + navigator.sendBeacon(cancelUrl, new Blob([payload], { type: 'application/json' })); + } } }; }, [threadId]); From a0b2eaa68eec33e37fb9480e6b8483d5800288dd Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Wed, 8 Jul 2026 16:37:30 +0530 Subject: [PATCH 07/10] fix: replace localStorage memories with backend data on mount to prevent duplicates Signed-off-by: Naveen Saharan --- src/frontend/components/settings/MemoryList.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx index 345f04e..dd8a67a 100644 --- a/src/frontend/components/settings/MemoryList.tsx +++ b/src/frontend/components/settings/MemoryList.tsx @@ -14,15 +14,12 @@ export function MemoryList() { useEffect(() => { if (syncedRef.current) return; syncedRef.current = true; + listMemories().then((backendMems) => { - const existing = new Set( - document.querySelectorAll('[data-memory-content]') - ); - const localContents = new Set(memories.map((m) => m.content)); + if (backendMems.length === 0) return; + dispatch(clearMemories()); for (const bm of backendMems) { - if (!localContents.has(bm.content)) { - dispatch(addMemory(bm.content)); - } + dispatch(addMemory(bm.content)); } }); // eslint-disable-next-line react-hooks/exhaustive-deps From 7c6780bff121ac8760b37562d1908d84a14747ad Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Wed, 8 Jul 2026 17:48:15 +0530 Subject: [PATCH 08/10] fix: handle empty body DELETE requests in BFF proxy and fix memory dedup Signed-off-by: Naveen Saharan --- src/server/router/proxy.router.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/server/router/proxy.router.ts b/src/server/router/proxy.router.ts index 34bf205..068dcff 100644 --- a/src/server/router/proxy.router.ts +++ b/src/server/router/proxy.router.ts @@ -318,6 +318,14 @@ function buildForwardedQueryString(query: Record): string { async function proxyRoutes(fastify: FastifyInstance) { await fastify.register(authCheckPlugin); + fastify.addContentTypeParser('application/json', { parseAs: 'string', bodyLimit: 1048576 }, (req, body, done) => { + if (!body || (typeof body === 'string' && body.trim() === '')) { + done(null, undefined); + } else { + try { done(null, JSON.parse(body as string)); } catch (err) { done(err as Error, undefined); } + } + }); + /** * Streaming endpoint — translates between the UI's simple * {message, thread_id, user_id} payload and Aegra's LangGraph @@ -691,9 +699,11 @@ async function proxyRoutes(fastify: FastifyInstance) { } const headers: Record = { - 'Content-Type': 'application/json', 'X-Trace-ID': traceId, }; + if (request.method !== 'DELETE' && request.method !== 'GET') { + headers['Content-Type'] = 'application/json'; + } if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}`; From af04af6d670b266beb154a8e9c589d5250b95088 Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Wed, 8 Jul 2026 18:23:26 +0530 Subject: [PATCH 09/10] fix: use preferred_username for thread search since metadata stores it, not sub Signed-off-by: Naveen Saharan --- src/frontend/components/layout/AppLayout.tsx | 2 +- src/frontend/pages/ChatPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/components/layout/AppLayout.tsx b/src/frontend/components/layout/AppLayout.tsx index 91ee6ce..3c96368 100644 --- a/src/frontend/components/layout/AppLayout.tsx +++ b/src/frontend/components/layout/AppLayout.tsx @@ -116,7 +116,7 @@ export function AppLayout({ children }: AppLayoutProps) { async function loadUserHistory() { try { dispatch(setLoadingThreads(true)); - const history = await getAllThreadsByUserId(window.USER_DATA.sub || window.USER_DATA.preferred_username); + const history = await getAllThreadsByUserId(window.USER_DATA.preferred_username || window.USER_DATA.sub); const backendIds = new Set(history.map((t) => t.id)); const local = chatsRef.current; diff --git a/src/frontend/pages/ChatPage.tsx b/src/frontend/pages/ChatPage.tsx index a2ed44b..c7d01ae 100644 --- a/src/frontend/pages/ChatPage.tsx +++ b/src/frontend/pages/ChatPage.tsx @@ -85,7 +85,7 @@ export function ChatPage({ threadId }: { threadId: string }) { const feedbackUserId = useMemo(() => { if (typeof window === 'undefined') return 'anonymous'; - const u = window.USER_DATA?.sub || window.USER_DATA?.preferred_username; + const u = window.USER_DATA?.preferred_username || window.USER_DATA?.sub; return typeof u === 'string' && u.length > 0 ? u : 'anonymous'; }, []); From 15c2419af828e747fed1bc773eea3915966fdcc8 Mon Sep 17 00:00:00 2001 From: Naveen Saharan Date: Thu, 30 Jul 2026 03:51:02 +0530 Subject: [PATCH 10/10] feat: user isolation UI hardening and backend sync - Rules: load from backend on mount (listRules), save on add (createRule) - Memories: preserve backend UUIDs with setMemories action - localStorage: scope keys per user (dataverse-ai-chats:{sub}) - Personalization: start empty, load from backend (single source of truth) - Feedback: use authenticatedFetch with USER_DATA.sub as userId - chatStorage: scope CHATS_STORAGE_KEY per user - auth-check: only set dummy user when session.user is unset - test-login: dev-only endpoint for multi-user browser testing Signed-off-by: Naveen Saharan --- src/frontend/components/layout/AppLayout.tsx | 2 +- .../components/settings/MemoryList.tsx | 7 ++-- .../components/settings/RulesEditor.tsx | 21 ++++++++++-- src/frontend/pages/ChatPage.tsx | 2 +- src/frontend/redux/slices/personalization.ts | 30 ++++++++++++----- src/frontend/services/agent-rest.ts | 32 +++++++++++++++++++ src/frontend/services/chatStorage.ts | 8 +++-- src/frontend/services/feedback-api.ts | 21 ++++++------ src/server/plugins/auth-check.plugin.ts | 2 +- src/server/server.ts | 25 +++++++++++++++ 10 files changed, 121 insertions(+), 29 deletions(-) diff --git a/src/frontend/components/layout/AppLayout.tsx b/src/frontend/components/layout/AppLayout.tsx index 3c96368..a722121 100644 --- a/src/frontend/components/layout/AppLayout.tsx +++ b/src/frontend/components/layout/AppLayout.tsx @@ -116,7 +116,7 @@ export function AppLayout({ children }: AppLayoutProps) { async function loadUserHistory() { try { dispatch(setLoadingThreads(true)); - const history = await getAllThreadsByUserId(window.USER_DATA.preferred_username || window.USER_DATA.sub); + const history = await getAllThreadsByUserId(window.USER_DATA.preferred_username); const backendIds = new Set(history.map((t) => t.id)); const local = chatsRef.current; diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx index dd8a67a..88a6c06 100644 --- a/src/frontend/components/settings/MemoryList.tsx +++ b/src/frontend/components/settings/MemoryList.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Button } from '@patternfly/react-core'; import { Plus, Trash2, Brain, AlertCircle } from 'lucide-react'; import { useAppDispatch, useAppSelector } from '../../redux/hooks'; -import { addMemory, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization'; +import { addMemory, setMemories, removeMemory, clearMemories, selectMemories } from '../../redux/slices/personalization'; import { createMemory, deleteMemory, deleteAllMemories, listMemories } from '../../services/agent-rest'; export function MemoryList() { @@ -17,10 +17,7 @@ export function MemoryList() { listMemories().then((backendMems) => { if (backendMems.length === 0) return; - dispatch(clearMemories()); - for (const bm of backendMems) { - dispatch(addMemory(bm.content)); - } + dispatch(setMemories(backendMems)); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/frontend/components/settings/RulesEditor.tsx b/src/frontend/components/settings/RulesEditor.tsx index ca8bafc..01cb362 100644 --- a/src/frontend/components/settings/RulesEditor.tsx +++ b/src/frontend/components/settings/RulesEditor.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button, Switch } from '@patternfly/react-core'; import { Plus, Trash2, ScrollText, AlertCircle } from 'lucide-react'; import { useAppDispatch, useAppSelector } from '../../redux/hooks'; @@ -8,18 +8,35 @@ import { toggleRule, clearRules, selectRules, + setRules, } from '../../redux/slices/personalization'; -import { deleteRule, deleteAllRules } from '../../services/agent-rest'; +import { createRule, deleteRule, deleteAllRules, listRules } from '../../services/agent-rest'; export function RulesEditor() { const dispatch = useAppDispatch(); const rules = useAppSelector(selectRules); const [draft, setDraft] = useState(''); + const loaded = useRef(false); + + useEffect(() => { + if (loaded.current) return; + loaded.current = true; + listRules().then((backendRules) => { + if (backendRules.length === 0) return; + const merged = backendRules.map((br) => ({ + id: br.id, + content: br.content, + isActive: br.is_active, + })); + dispatch(setRules(merged)); + }); + }, [dispatch]); const handleAdd = () => { const text = draft.trim(); if (!text) return; dispatch(addRule(text)); + createRule(text).catch(() => {}); setDraft(''); }; diff --git a/src/frontend/pages/ChatPage.tsx b/src/frontend/pages/ChatPage.tsx index c7d01ae..daabd91 100644 --- a/src/frontend/pages/ChatPage.tsx +++ b/src/frontend/pages/ChatPage.tsx @@ -85,7 +85,7 @@ export function ChatPage({ threadId }: { threadId: string }) { const feedbackUserId = useMemo(() => { if (typeof window === 'undefined') return 'anonymous'; - const u = window.USER_DATA?.preferred_username || window.USER_DATA?.sub; + const u = window.USER_DATA?.preferred_username; return typeof u === 'string' && u.length > 0 ? u : 'anonymous'; }, []); diff --git a/src/frontend/redux/slices/personalization.ts b/src/frontend/redux/slices/personalization.ts index b66f232..d4e6234 100644 --- a/src/frontend/redux/slices/personalization.ts +++ b/src/frontend/redux/slices/personalization.ts @@ -19,21 +19,18 @@ interface PersonalizationState { rules: RuleItem[]; } -const STORAGE_KEY = 'template-ui-personalization'; +function storageKey(): string { + const userId = globalThis.window?.USER_DATA?.sub || globalThis.window?.USER_DATA?.preferred_username || ''; + return userId ? `template-ui-personalization:${userId}` : 'template-ui-personalization'; +} function loadState(): PersonalizationState { - try { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) return JSON.parse(stored); - } catch { - /* ignore */ - } return { memories: [], rules: [] }; } function persist(state: PersonalizationState) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + localStorage.setItem(storageKey(), JSON.stringify(state)); } catch { /* ignore */ } @@ -55,6 +52,14 @@ const personalizationSlice = createSlice({ state.memories = state.memories.filter((m) => m.id !== action.payload); persist(state); }, + setMemories(state, action: PayloadAction>) { + state.memories = action.payload.map((m) => ({ + id: m.id, + content: m.content, + createdAt: new Date().toISOString(), + })); + persist(state); + }, clearMemories(state) { state.memories = []; persist(state); @@ -87,6 +92,13 @@ const personalizationSlice = createSlice({ state.rules = []; persist(state); }, + setRules(state, action: PayloadAction>) { + state.rules = action.payload.map((r) => ({ + ...r, + createdAt: new Date().toISOString(), + })); + persist(state); + }, resetPersonalization(state) { state.memories = []; state.rules = []; @@ -97,6 +109,7 @@ const personalizationSlice = createSlice({ export const { addMemory, + setMemories, removeMemory, clearMemories, addRule, @@ -104,6 +117,7 @@ export const { toggleRule, removeRule, clearRules, + setRules, resetPersonalization, } = personalizationSlice.actions; diff --git a/src/frontend/services/agent-rest.ts b/src/frontend/services/agent-rest.ts index c4d9959..d7c8428 100644 --- a/src/frontend/services/agent-rest.ts +++ b/src/frontend/services/agent-rest.ts @@ -224,6 +224,38 @@ export async function deleteAllMemories(): Promise { } } +/** + * List all rules from the backend for the authenticated user. + */ +export async function listRules(): Promise> { + try { + const resp = await authenticatedFetch(buildAgentApiUrl('/rules'), { + headers: getAuthHeaders(), + }); + if (!resp.ok) return []; + const data = await resp.json(); + return data.rules || []; + } catch { + return []; + } +} + +/** + * Create a rule on the backend for the authenticated user. + */ +export async function createRule(content: string): Promise { + try { + const resp = await authenticatedFetch(buildAgentApiUrl('/rules'), { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ content, is_active: true }), + }); + return resp.ok; + } catch { + return false; + } +} + /** * Delete a single rule from the backend. */ diff --git a/src/frontend/services/chatStorage.ts b/src/frontend/services/chatStorage.ts index e4dab57..d83823f 100644 --- a/src/frontend/services/chatStorage.ts +++ b/src/frontend/services/chatStorage.ts @@ -1,8 +1,12 @@ import { ChatItem } from '../types/chat'; class ChatStorageService { - private readonly CHATS_STORAGE_KEY = 'dataverse-ai-chats'; - private readonly MAX_CHATS = 50; // Limit to prevent localStorage bloat + private readonly MAX_CHATS = 50; + + private get CHATS_STORAGE_KEY(): string { + const userId = globalThis.window?.USER_DATA?.sub || globalThis.window?.USER_DATA?.preferred_username || ''; + return userId ? `dataverse-ai-chats:${userId}` : 'dataverse-ai-chats'; + } /** * Save chats to localStorage with error handling and size limits diff --git a/src/frontend/services/feedback-api.ts b/src/frontend/services/feedback-api.ts index f3e49bc..c8b8dc8 100644 --- a/src/frontend/services/feedback-api.ts +++ b/src/frontend/services/feedback-api.ts @@ -1,3 +1,4 @@ +import { authenticatedFetch } from './authenticated-fetch'; import { buildAgentApiUrl } from '../lib/app-paths'; export interface FeedbackPayload { @@ -10,11 +11,15 @@ export interface FeedbackPayload { userId?: string; } +function getCurrentUserId(): string { + return window.USER_DATA?.sub || window.USER_DATA?.preferred_username || 'anonymous'; +} + export async function submitFeedback(payload: FeedbackPayload): Promise { - const response = await fetch(buildAgentApiUrl('/feedback'), { + const userId = payload.userId || getCurrentUserId(); + const response = await authenticatedFetch(buildAgentApiUrl('/feedback'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - credentials: 'include', body: JSON.stringify({ trace_id: payload.traceId, name: payload.name, @@ -22,7 +27,7 @@ export async function submitFeedback(payload: FeedbackPayload): Promise { kwargs: payload.comment ? { comment: payload.comment } : {}, thread_id: payload.threadId, message_id: payload.messageId, - user_id: payload.userId || 'anonymous', + user_id: userId, }), }); if (!response.ok) { @@ -32,13 +37,11 @@ export async function submitFeedback(payload: FeedbackPayload): Promise { export async function getThreadFeedback( threadId: string, - userId: string = 'anonymous', + userId?: string, ): Promise> { - const response = await fetch( - `${buildAgentApiUrl(`/feedback/${encodeURIComponent(threadId)}`)}?user_id=${encodeURIComponent(userId)}`, - { - credentials: 'include', - }, + const effectiveUserId = userId || getCurrentUserId(); + const response = await authenticatedFetch( + `${buildAgentApiUrl(`/feedback/${encodeURIComponent(threadId)}`)}?user_id=${encodeURIComponent(effectiveUserId)}`, ); if (!response.ok) return {}; const data = (await response.json()) as { feedback?: Array<{ message_id: string; feedback: 'up' | 'down' }> }; diff --git a/src/server/plugins/auth-check.plugin.ts b/src/server/plugins/auth-check.plugin.ts index 4527cb4..89f29c5 100644 --- a/src/server/plugins/auth-check.plugin.ts +++ b/src/server/plugins/auth-check.plugin.ts @@ -72,7 +72,7 @@ function authCheck( preferred_username: gwEmail.split("@")[0], sub: gwSub || gwEmail, }; - } else { + } else if (!request.session.user) { const dummyUser = { accessToken: "access-token", expiresAt: "2026-10-29T23:20:00.417Z", diff --git a/src/server/server.ts b/src/server/server.ts index 18b4b23..7a128ad 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -146,6 +146,31 @@ export async function setupServer(): Promise { await fastify.register(logoutPlugin); + if (process.env.ENVIRONMENT === "development" && process.env.TEST_JWT_DIR) { + const fs = await import("fs"); + fastify.get("/test-login/:username", async (request, reply) => { + const { username } = request.params as { username: string }; + const tokenFile = `${process.env.TEST_JWT_DIR}/${username}_token.txt`; + if (!fs.existsSync(tokenFile)) { + return reply.status(404).send({ error: `No token for '${username}'. Generate with jwt_provider.` }); + } + const accessToken = fs.readFileSync(tokenFile, "utf-8").trim(); + const session = (request as any).session; + session.user = { + sub: username, + preferred_username: username, + name: username.charAt(0).toUpperCase() + username.slice(1), + displayName: username.charAt(0).toUpperCase() + username.slice(1), + email: `${username}@test.local`, + }; + session.token = { + access_token: accessToken, + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }; + return reply.redirect("/"); + }); + } + await fastify.register(apiRoutes, { prefix: "/api" }); await fastify.register(proxyRoutes, { prefix: "/api" });