Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions src/frontend/components/settings/MemoryList.tsx
Original file line number Diff line number Diff line change
@@ -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('');
};

Expand Down Expand Up @@ -75,7 +92,7 @@ export function MemoryList() {
<Brain className="w-4 h-4 text-primary/60 mt-0.5 shrink-0" />
<p className="flex-1 text-sm text-foreground leading-relaxed">{mem.content}</p>
<button
onClick={() => dispatch(removeMemory(mem.id))}
onClick={() => { dispatch(removeMemory(mem.id)); deleteMemory(mem.id).catch(() => {}); }}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
aria-label="Remove memory"
>
Expand All @@ -90,7 +107,7 @@ export function MemoryList() {
variant="plain"
isDanger
size="sm"
onClick={() => dispatch(clearMemories())}
onClick={() => { dispatch(clearMemories()); deleteAllMemories().catch(() => {}); }}
>
Clear all memories
</Button>
Expand Down
24 changes: 21 additions & 3 deletions src/frontend/components/settings/RulesEditor.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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('');
};

Expand Down Expand Up @@ -93,7 +111,7 @@ export function RulesEditor() {
{rule.content}
</p>
<button
onClick={() => dispatch(removeRule(rule.id))}
onClick={() => { dispatch(removeRule(rule.id)); deleteRule(rule.id).catch(() => {}); }}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
aria-label="Remove rule"
>
Expand All @@ -108,7 +126,7 @@ export function RulesEditor() {
variant="plain"
isDanger
size="sm"
onClick={() => dispatch(clearRules())}
onClick={() => { dispatch(clearRules()); deleteAllRules().catch(() => {}); }}
>
Clear all rules
</Button>
Expand Down
40 changes: 40 additions & 0 deletions src/frontend/hooks/useStreamingAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,26 @@
};
}, []);

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);
Expand Down Expand Up @@ -296,6 +316,26 @@
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'
Expand Down Expand Up @@ -585,7 +625,7 @@
await new Promise<void>((r) => setTimeout(r, computeRetryDelayMs(attempt + 1)));
}
},
[dispatch, threadId, memories, activeRules, handleStreamActivityStatus],

Check warning on line 628 in src/frontend/hooks/useStreamingAPI.ts

View workflow job for this annotation

GitHub Actions / lint

React Hook useCallback has a missing dependency: 'setMessages'. Either include it or remove the dependency array
);

/**
Expand Down
5 changes: 4 additions & 1 deletion src/frontend/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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);

Expand Down
30 changes: 22 additions & 8 deletions src/frontend/redux/slices/personalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
}
Expand All @@ -55,6 +52,14 @@ const personalizationSlice = createSlice({
state.memories = state.memories.filter((m) => m.id !== action.payload);
persist(state);
},
setMemories(state, action: PayloadAction<Array<{ id: string; content: string }>>) {
state.memories = action.payload.map((m) => ({
id: m.id,
content: m.content,
createdAt: new Date().toISOString(),
}));
persist(state);
},
clearMemories(state) {
state.memories = [];
persist(state);
Expand Down Expand Up @@ -87,6 +92,13 @@ const personalizationSlice = createSlice({
state.rules = [];
persist(state);
},
setRules(state, action: PayloadAction<Array<{ id: string; content: string; isActive: boolean }>>) {
state.rules = action.payload.map((r) => ({
...r,
createdAt: new Date().toISOString(),
}));
persist(state);
},
resetPersonalization(state) {
state.memories = [];
state.rules = [];
Expand All @@ -97,13 +109,15 @@ const personalizationSlice = createSlice({

export const {
addMemory,
setMemories,
removeMemory,
clearMemories,
addRule,
updateRule,
toggleRule,
removeRule,
clearRules,
setRules,
resetPersonalization,
} = personalizationSlice.actions;

Expand Down
Loading
Loading