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" });