project summaries addition

This commit is contained in:
Storme-bit
2026-04-26 21:02:42 -07:00
parent fcaf0e651f
commit 855de6d0af
7 changed files with 190 additions and 36 deletions

View File

@@ -65,7 +65,7 @@ export default function App() {
streaming, streaming,
lastTokenCount, lastTokenCount,
lastModel, lastModel,
useChat, summarising,
} = useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions }); } = useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions });
function navigate(nextView) { function navigate(nextView) {

View File

@@ -212,3 +212,14 @@ export async function fetchSessionSummaries(sessionId) {
return res.json(); return res.json();
} }
export async function generateProjectSummary(projectId) {
const res = await fetch(`${BASE_URL}/summaries/project/${projectId}/generate`, { method: 'POST' });
if (!res.ok) throw new Error(`Failed to generate project summary: ${res.status}`);
return res.json();
}
export async function fetchProjectOverviewSummary(projectId) {
const res = await fetch(`${BASE_URL}/summaries/project/${projectId}/overview`);
if (!res.ok) throw new Error(`Failed to fetch project overview: ${res.status}`);
return res.json(); // null if none exists yet
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { fetchSessions, updateProject, deleteProject } from '../api/orchestration'; import { fetchSessions, updateProject, deleteProject, generateProjectSummary, fetchProjectOverviewSummary } from '../api/orchestration';
import ProjectModal from './ProjectModal'; import ProjectModal from './ProjectModal';
export default function ProjectView({ project, onNavigate, onBack, onSelectSession, onNewProjectChat, onProjectsChange }) { export default function ProjectView({ project, onNavigate, onBack, onSelectSession, onNewProjectChat, onProjectsChange }) {
@@ -8,9 +8,27 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [modal, setModal] = useState(null); const [modal, setModal] = useState(null);
const [overview, setOverview] = useState(null);
const [overviewLoading, setOverviewLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [generateError, setGenerateError] = useState(null);
useEffect(() => { load(); }, [project.id]); useEffect(() => { load(); }, [project.id]);
useEffect(() => {
async function loadOverview() {
setOverviewLoading(true);
try {
setOverview(await fetchProjectOverviewSummary(project.id));
} catch (err) {
console.error('[ProjectView] Failed to load overview:', err.message);
} finally {
setOverviewLoading(false);
}
}
loadOverview();
}, [project.id]);
async function load() { async function load() {
setLoading(true); setLoading(true);
try { try {
@@ -70,6 +88,23 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
if (diffDays === 1) return 'Yesterday'; if (diffDays === 1) return 'Yesterday';
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }); return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
} }
async function handleGenerateSummary() {
setGenerating(true);
setGenerateError(null);
try {
setOverview(await generateProjectSummary(project.id));
} catch (err) {
// 422 means no session summaries exist yet — surface a friendly message
setGenerateError(
err.message.includes('422')
? 'No conversations found in this project yet.'
: 'Failed to generate summary. Please try again.'
);
} finally {
setGenerating(false);
}
}
return ( return (
<div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}> <div className="flex-col flex-1 overflow-hidden" style={{ background: 'var(--bg-base)' }}>
@@ -198,34 +233,61 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
{/* ── Project Memory ── */} {/* ── Project Memory ── */}
<div style={{ marginBottom: '40px' }}> <div style={{ marginBottom: '40px' }}>
<p className="label-upper" style={{ marginBottom: '12px' }}>Project Memory</p> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
<div style={{ <p className="label-upper">Project Memory</p>
background: 'var(--bg-surface)', <button
border: '1px solid var(--border)', className="btn-primary"
borderRadius: 'var(--radius-lg)', style={{ padding: '5px 12px', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}
padding: '20px', onClick={handleGenerateSummary}
display: 'flex', flexDirection: 'column', gap: '10px', disabled={generating}
}}> >
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}> {generating
<span style={{ fontSize: '20px', opacity: 0.4 }}></span> ? <><span className="spinner" />Generating</>
<span className="text-sm" style={{ fontWeight: 500, color: 'var(--text-primary)' }}> : overview ? 'Regenerate' : 'Generate Summary'
Project Summary }
</span> </button>
<span style={{ </div>
fontSize: '11px', padding: '2px 8px',
borderRadius: '999px', <div style={{
background: 'var(--bg-elevated)', background: 'var(--bg-surface)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
color: 'var(--text-muted)', borderRadius: 'var(--radius-lg)',
}}>Coming soon</span> padding: '20px',
}}>
{overviewLoading ? (
<p className="text-sm text-muted">Loading</p>
) : generateError ? (
<p className="text-sm" style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>
{generateError}
</p>
) : overview ? (
<>
<p className="text-sm" style={{ color: 'var(--text-secondary)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{overview.content}
</p>
<p className="text-xs text-muted" style={{ marginTop: '12px' }}>
Last generated {formatTimestamp(overview.created_at)}
</p>
</>
) : (
// No overview exists yet — explain what this section is for
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '20px', opacity: 0.4 }}></span>
<span className="text-sm" style={{ fontWeight: 500, color: 'var(--text-primary)' }}>
No project summary yet
</span>
</div>
<p className="text-sm text-muted" style={{ lineHeight: 1.6, maxWidth: '520px' }}>
Generate a summary to create a concise overview of this project's goals,
progress, and key decisions — built from your session summaries.
</p>
</div>
)}
</div> </div>
<p className="text-sm text-muted" style={{ lineHeight: 1.6, maxWidth: '520px' }}>
Once this project has enough conversations, NexusAI will automatically
generate a rolling summary of key themes, decisions, and context giving
the model a condensed view of the project's memory without consuming the
full context window.
</p>
</div>
</div> </div>
{/* ── Notes ── */} {/* ── Notes ── */}

View File

@@ -114,4 +114,14 @@ html, body, #root {
.text-secondary { color: var(--text-secondary); } .text-secondary { color: var(--text-secondary); }
.text-accent { color: var(--accent); } .text-accent { color: var(--accent); }
.label-upper { font-size: 13px; font-weight: 750; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; } .label-upper { font-size: 13px; font-weight: 750; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spinner {
width: 12px;
height: 12px;
border: 2px solid var(--border);
border-top-color: var(--text-muted);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}

View File

@@ -207,6 +207,17 @@ async function getEpisodeEmbedding(userMessage, aiResponse){
return data.embedding; return data.embedding;
} }
function getEpisodesByProject(projectId, limit = 200) {
const db = getDB();
return db.prepare(`
SELECT e.* FROM episodes e
JOIN sessions s ON s.id = e.session_id
WHERE s.project_id = ?
ORDER BY e.created_at ASC
LIMIT ?
`).all(projectId, limit).map(parseRow);
}
module.exports = { module.exports = {
createSession, createSession,
getSession, getSession,
@@ -221,5 +232,6 @@ module.exports = {
getEpisodesBySession, getEpisodesBySession,
getRecentEpisodes, getRecentEpisodes,
searchEpisodes, searchEpisodes,
deleteEpisode deleteEpisode,
getEpisodesByProject
}; };

View File

@@ -252,8 +252,7 @@ app.post('/projects/:id/summarize', async (req, res) => {
const summary = await generateAndStoreProjectSummary(Number(req.params.id)); const summary = await generateAndStoreProjectSummary(Number(req.params.id));
res.status(201).json(summary); res.status(201).json(summary);
} catch (err) { } catch (err) {
// Distinguish "no data" from actual errors — both are non-500 from the client's perspective if (err.message.includes('No session summaries or episodes')) {
if (err.message.includes('No session summaries')) {
return res.status(422).json({ error: err.message }); return res.status(422).json({ error: err.message });
} }
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });

View File

@@ -6,6 +6,7 @@ const {
updateSummary, updateSummary,
} = require('../db/summaries'); } = require('../db/summaries');
const { getEpisodesByProject } = require('../episodic');
const { getProject } = require('../db/projects'); const { getProject } = require('../db/projects');
const EXTRACTION_URL = getEnv('EXTRACTION_URL', 'http://localhost:11434'); const EXTRACTION_URL = getEnv('EXTRACTION_URL', 'http://localhost:11434');
@@ -34,6 +35,55 @@ function buildProjectSummaryPrompt(projectName, sessionSummaries) {
].join('\n'); ].join('\n');
} }
function buildProjectSummaryFromEpisodesPrompt(projectName, episodes) {
// Condense episodes into a readable block, truncating if needed
let episodeBlock = episodes
.map(ep => `User: ${ep.user_message}\nAssistant: ${ep.ai_response}`)
.join('\n\n');
if (episodeBlock.length > MAX_SUMMARY_CHARS) {
// Keep the most recent episodes — slice from the end
episodeBlock = episodeBlock.slice(-MAX_SUMMARY_CHARS);
}
return [
'<|im_start|>user',
`The following are conversations from a project called "${projectName}".`,
'Write a project overview covering: goals, progress, key decisions, and current state.',
'Scale the length to the material — use multiple paragraphs for complex projects, a few sentences for simple ones.',
'Be comprehensive but avoid padding. Do not repeat the same point twice.',
'Write in third person. Output only the overview text, no headings or labels.',
'',
episodeBlock,
'<|im_end|>',
'<|im_start|>assistant',
].join('\n');
}
async function generateProjectSummaryFromEpisodes(projectName, episodes) {
const prompt = buildProjectSummaryFromEpisodesPrompt(projectName, episodes);
const res = await fetch(`${EXTRACTION_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: EXTRACTION_MODEL,
prompt,
stream: false,
options: { temperature: 0.2, num_predict: 1200 },
}),
});
if (!res.ok) throw new Error(`Ollama responded ${res.status}`);
const data = await res.json();
const raw = data.response?.trim() ?? '';
return raw
.replace(/<\|im_start\|>.*?<\|im_end\|>/gs, '')
.replace(/<\|im_start\|>|<\|im_end\|>|<\|im_sep\|>/g, '')
.trim();
}
async function generateProjectSummary(projectName, sessionSummaries) { async function generateProjectSummary(projectName, sessionSummaries) {
const prompt = buildProjectSummaryPrompt(projectName, sessionSummaries); const prompt = buildProjectSummaryPrompt(projectName, sessionSummaries);
@@ -64,13 +114,23 @@ async function generateAndStoreProjectSummary(projectId) {
const project = getProject(projectId); const project = getProject(projectId);
if (!project) throw new Error('Project not found'); if (!project) throw new Error('Project not found');
let content;
const sessionSummaries = getSessionSummariesForProject(projectId); const sessionSummaries = getSessionSummariesForProject(projectId);
if (!sessionSummaries.length) throw new Error('No session summaries available to summarize');
const content = await generateProjectSummary(project.name, sessionSummaries); if (sessionSummaries.length > 0) {
// Preferred path — summarize the summaries
content = await generateProjectSummary(project.name, sessionSummaries);
} else {
// Fallback — summarize raw episodes directly
const episodes = getEpisodesByProject(projectId);
if (!episodes.length) {
throw new Error('No session summaries or episodes found for this project');
}
content = await generateProjectSummaryFromEpisodes(project.name, episodes);
}
if (!content) throw new Error('Model returned empty summary'); if (!content) throw new Error('Model returned empty summary');
// Upsert — replace existing overview if present, otherwise create fresh
const existing = getProjectOverviewSummary(projectId); const existing = getProjectOverviewSummary(projectId);
if (existing) { if (existing) {
return updateSummary(existing.id, { content, tokenCount: null, episodeRange: null }); return updateSummary(existing.id, { content, tokenCount: null, episodeRange: null });