diff --git a/src/frontend/components/settings/MemoryList.tsx b/src/frontend/components/settings/MemoryList.tsx index a08fabd..88a6c06 100644 --- a/src/frontend/components/settings/MemoryList.tsx +++ b/src/frontend/components/settings/MemoryList.tsx @@ -1,18 +1,35 @@ -import { 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'; -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() { 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) => { + if (backendMems.length === 0) return; + dispatch(setMemories(backendMems)); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); 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(''); }; @@ -75,7 +92,7 @@ export function MemoryList() {

{mem.content}

diff --git a/src/frontend/components/settings/RulesEditor.tsx b/src/frontend/components/settings/RulesEditor.tsx index dc7c030..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,17 +8,35 @@ import { toggleRule, clearRules, selectRules, + setRules, } from '../../redux/slices/personalization'; +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(''); }; @@ -93,7 +111,7 @@ export function RulesEditor() { {rule.content}

diff --git a/src/frontend/hooks/useStreamingAPI.ts b/src/frontend/hooks/useStreamingAPI.ts index 1f0651c..3f9dda3 100644 --- a/src/frontend/hooks/useStreamingAPI.ts +++ b/src/frontend/hooks/useStreamingAPI.ts @@ -250,6 +250,26 @@ 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]); + useEffect(() => { if (!threadId) return; chatStorage.saveChatByThreadId(threadId, messages); @@ -296,6 +316,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 c98b8e1..daabd91 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/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 54a1b0d..d7c8428 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,14 +154,138 @@ export async function deleteThread(threadId: string): Promise { try { const resp = await authenticatedFetch(deleteUrl, { method: 'DELETE', + headers: getAuthHeaders(false), + }); + return resp.ok || resp.status === 404; + } catch { + return false; + } +} + +/** + * 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. + */ +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. + */ +export async function deleteMemory(memoryId: string): Promise { + try { + const resp = await authenticatedFetch(buildAgentApiUrl(`/memories/${memoryId}`), { + method: 'DELETE', + headers: getAuthHeaders(false), + }); + 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(false), + }); + return resp.ok; + } catch { + return false; + } +} + +/** + * 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. + */ +export async function deleteRule(ruleId: string): Promise { + try { + const resp = await authenticatedFetch(buildAgentApiUrl(`/rules/${ruleId}`), { + method: 'DELETE', + headers: getAuthHeaders(false), + }); 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(false), + }); + 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. 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/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}`; 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" });