diff --git a/packages/chat-client/src/App.jsx b/packages/chat-client/src/App.jsx index 87f8114..b1f5482 100644 --- a/packages/chat-client/src/App.jsx +++ b/packages/chat-client/src/App.jsx @@ -12,6 +12,7 @@ import AllProjectsView from './components/AllProjectsView'; import SettingsView from './components/SettingsView'; import ProjectView from './components/ProjectView'; import MemoryView from './components/MemoryView'; +import SummaryView from './components/SummaryView'; /**** useHooks **** */ import { useSession } from './hooks/useSession'; @@ -27,6 +28,7 @@ const BACK_MAP = { 'settings': 'home', 'project': 'all-projects', 'memory': 'settings', + 'summaries': 'chat', }; export default function App() { @@ -37,6 +39,7 @@ export default function App() { const [viewHistory, setViewHistory] = useState([]); const [activeProject, setActiveProject] = useState(null); const { projects, refreshProjects } = useProjects(); + const [summarising, setSummarising] = useState(false); // Lifted model props — available to header + SettingsView const [modelProps, setModelProps] = useState(null); @@ -159,6 +162,7 @@ export default function App() { onBack={goBack} canGoBack={canGoBack} loadedModel={modelProps?.modelAlias ?? null} + summarising={summarising} /> )} @@ -205,6 +209,13 @@ export default function App() { /> )} + {view === 'summaries' && ( + + )} + setRightOpen(o => !o)} @@ -214,6 +225,8 @@ export default function App() { onModelChange={setSelectedModel} lastModel={lastModel} lastTokenCount={lastTokenCount} + summarising={summarising} + onViewSummary={() => navigate('summaries')} /> ); diff --git a/packages/chat-client/src/api/orchestration.js b/packages/chat-client/src/api/orchestration.js index 4477bf8..5cc3723 100644 --- a/packages/chat-client/src/api/orchestration.js +++ b/packages/chat-client/src/api/orchestration.js @@ -204,4 +204,11 @@ export async function getModelProps() { const res = await fetch(`${BASE_URL}/models/props`); if (!res.ok) throw new Error('Failed to fetch model props'); return res.json(); -} \ No newline at end of file +} + +export async function fetchSessionSummaries(sessionId) { + const res = await fetch(`${BASE_URL}/summaries/session/${sessionId}`); + if (!res.ok) throw new Error(`Failed to fetch summaries: ${res.status}`); + return res.json(); +} + diff --git a/packages/chat-client/src/components/ChatWindow.jsx b/packages/chat-client/src/components/ChatWindow.jsx index e599acd..b6e098a 100644 --- a/packages/chat-client/src/components/ChatWindow.jsx +++ b/packages/chat-client/src/components/ChatWindow.jsx @@ -12,6 +12,7 @@ export default function ChatWindow({ onBack, canGoBack, loadedModel, + summarising, }) { const bottomRef = useRef(null); const inputRef = useRef(null); @@ -86,6 +87,20 @@ export default function ChatWindow({ No model loaded )} + {summarising && ( +
+
+ + Summarising… + +
+ )}
diff --git a/packages/chat-client/src/components/InfoPanel.jsx b/packages/chat-client/src/components/InfoPanel.jsx index 33d813e..1212664 100644 --- a/packages/chat-client/src/components/InfoPanel.jsx +++ b/packages/chat-client/src/components/InfoPanel.jsx @@ -1,6 +1,17 @@ import React from 'react'; -export default function InfoPanel({ isOpen, onToggle, activeSession, lastModel, lastTokenCount, selectedModel, onModelChange, models }) { +export default function InfoPanel({ + isOpen, + onToggle, + activeSession, + lastModel, + lastTokenCount, + selectedModel, + onModelChange, + models, + summarising, + onViewSummary, +}) { return (
+ {/* Session Memory button */} + {activeSession && !activeSession.isNew && ( + + )} +
)} diff --git a/packages/chat-client/src/components/SummaryView.jsx b/packages/chat-client/src/components/SummaryView.jsx new file mode 100644 index 0000000..47a081a --- /dev/null +++ b/packages/chat-client/src/components/SummaryView.jsx @@ -0,0 +1,124 @@ +import React, { useState, useEffect } from 'react'; +import { fetchSessionSummaries } from '../api/orchestration'; +import ReactMarkdown from 'react-markdown'; + +export default function SummaryView({ activeSession, onBack }) { + const [summaries, setSummaries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(null); + + useEffect(() => { + if (!activeSession || activeSession.isNew) { + setLoading(false); + return; + } + setLoading(true); + fetchSessionSummaries(activeSession.external_id) + .then(data => setSummaries(Array.isArray(data) ? data : [])) + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); + }, [activeSession]); + + function formatTimestamp(ts) { + if (!ts) return '—'; + return new Date(ts * 1000).toLocaleString([], { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + } + + return ( +
+ + {/* Header */} +
+ + Session Memory + + {summaries.length} summar{summaries.length !== 1 ? 'ies' : 'y'} + +
+ + {/* Session name pill */} + {activeSession && ( +
+ + {activeSession.name || activeSession.external_id} + +
+ )} + + {/* Content */} +
+ {loading &&

Loading…

} + {error &&

{error}

} + + {!loading && !activeSession && ( +

No active session.

+ )} + + {!loading && activeSession && summaries.length === 0 && ( +
+ +

No summaries yet for this session.

+

+ Summaries generate automatically once a session accumulates enough conversation. +

+
+ )} + + {summaries.map(summary => ( +
+ {/* Card header */} +
setExpanded(expanded === summary.id ? null : summary.id)} + style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 14px', cursor: 'pointer' }} + > + + Episodes {summary.episode_range} + + {formatTimestamp(summary.created_at)} + + {expanded === summary.id ? '▲' : '▼'} + +
+ + {/* Expanded content */} + {expanded === summary.id && ( +
+ ( +

+ {children} +

+ ), + }}> + {summary.content} +
+ {summary.token_count > 0 && ( +

+ {summary.token_count.toLocaleString()} tokens covered +

+ )} +
+ )} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/packages/chat-client/src/hooks/useChat.js b/packages/chat-client/src/hooks/useChat.js index 9b812b1..144c13f 100644 --- a/packages/chat-client/src/hooks/useChat.js +++ b/packages/chat-client/src/hooks/useChat.js @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { streamMessage, updateSession } from '../api/orchestration'; export function useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions }) { @@ -7,6 +7,18 @@ export function useChat({ activeSession, appendMessage, updateLastMessage, refre const [lastTokenCount, setLastTokenCount] = useState(0); const [lastModel, setLastModel] = useState(null); const cancelRef = useRef(null); + const prevStreaming = React.useRef(false); + const [summarising, setSummarising] = useState(false); + + useEffect(() => { + if (prevStreaming.current && !streaming) { + // Stream just finished — trigger the summarising indicator + setSummarising(true); + const t = setTimeout(() => setSummarising(false), 8000); + return () => clearTimeout(t); + } + prevStreaming.current = streaming; + }, [streaming]); const sendMessage = useCallback(async (text, model, projectId = null, session=null) => { const targetSession = session ?? activeSession; diff --git a/packages/chat-client/src/index.css b/packages/chat-client/src/index.css index f0e77fa..95fa4d9 100644 --- a/packages/chat-client/src/index.css +++ b/packages/chat-client/src/index.css @@ -35,6 +35,10 @@ html, body, #root { 50% { opacity: 0; } } +@keyframes spin { + to { transform: rotate(360deg); } +} + /* ── Layout ─────────────────────────────────────────── */ .flex { display: flex; } diff --git a/packages/memory-service/src/entities/extraction.js b/packages/memory-service/src/entities/extraction.js index 8daa923..2818643 100644 --- a/packages/memory-service/src/entities/extraction.js +++ b/packages/memory-service/src/entities/extraction.js @@ -7,6 +7,7 @@ const EXTRACTION_MODEL = getEnv('EXTRACTION_MODEL', 'qwen2.5:3b'); const EMBEDDING_SERVICE_URL = getEnv('EMBEDDING_SERVICE_URL', SERVICES.EMBEDDING_URL); const ENTITY_TYPES = ['person', 'place', 'project', 'technology', 'concept', 'organization']; +const IGNORED_NAMES = ['good morning', 'good night', 'hello', 'goodbye', 'thanks', 'thank you']; function buildExtractionPrompt(userMessage, aiResponse, knownEntities = []) { const knownBlock = knownEntities.length > 0 @@ -101,8 +102,12 @@ async function extractAndStoreEntities(userMessage, aiResponse, projectId=null) if (!Array.isArray(entities)) throw new Error('Response was not a JSON array'); let saved = 0; + + for (const { name, type, notes } of entities) { + if (!name || !type || !ENTITY_TYPES.includes(type)) continue; + if (IGNORED_NAMES.includes(name.toLowerCase())) continue; const entity = upsertEntity(name, type, notes ?? null); console.log('[entities] Upserted entity:', entity); diff --git a/packages/orchestration-service/src/services/summarization.js b/packages/orchestration-service/src/services/summarization.js index c584d63..c82ffc8 100644 --- a/packages/orchestration-service/src/services/summarization.js +++ b/packages/orchestration-service/src/services/summarization.js @@ -72,6 +72,8 @@ async function maybeSummarize(session, allEpisodes) { console.log('[summarization] fetching existing summaries...'); // 2. Fetch existing summaries for session const summariesRes = await fetch(`${MEMORY_URL}/sessions/${session.id}/summaries`); + console.log('[summarization] memory URL:', MEMORY_URL); + console.log('[summarization] session:', session.id, session.external_id); console.log('[summarization] summaries fetch status:', summariesRes.status); if (!summariesRes.ok) return; const summaries = await summariesRes.json();