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,
lastTokenCount,
lastModel,
useChat,
summarising,
} = useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions });
function navigate(nextView) {

View File

@@ -212,3 +212,14 @@ export async function fetchSessionSummaries(sessionId) {
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 { fetchSessions, updateProject, deleteProject } from '../api/orchestration';
import { fetchSessions, updateProject, deleteProject, generateProjectSummary, fetchProjectOverviewSummary } from '../api/orchestration';
import ProjectModal from './ProjectModal';
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 [menuOpen, setMenuOpen] = useState(false);
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(() => {
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() {
setLoading(true);
try {
@@ -71,6 +89,23 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
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 (
<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 ── */}
<div style={{ marginBottom: '40px' }}>
<p className="label-upper" style={{ marginBottom: '12px' }}>Project Memory</p>
<div style={{
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
padding: '20px',
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)' }}>
Project Summary
</span>
<span style={{
fontSize: '11px', padding: '2px 8px',
borderRadius: '999px',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
color: 'var(--text-muted)',
}}>Coming soon</span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
<p className="label-upper">Project Memory</p>
<button
className="btn-primary"
style={{ padding: '5px 12px', fontSize: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}
onClick={handleGenerateSummary}
disabled={generating}
>
{generating
? <><span className="spinner" />Generating</>
: overview ? 'Regenerate' : 'Generate Summary'
}
</button>
</div>
<div style={{
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
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>
<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>
{/* ── Notes ── */}

View File

@@ -115,3 +115,13 @@ html, body, #root {
.text-accent { color: var(--accent); }
.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; }
.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;
}
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 = {
createSession,
getSession,
@@ -221,5 +232,6 @@ module.exports = {
getEpisodesBySession,
getRecentEpisodes,
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));
res.status(201).json(summary);
} catch (err) {
// Distinguish "no data" from actual errors — both are non-500 from the client's perspective
if (err.message.includes('No session summaries')) {
if (err.message.includes('No session summaries or episodes')) {
return res.status(422).json({ error: err.message });
}
res.status(500).json({ error: err.message });

View File

@@ -6,6 +6,7 @@ const {
updateSummary,
} = require('../db/summaries');
const { getEpisodesByProject } = require('../episodic');
const { getProject } = require('../db/projects');
const EXTRACTION_URL = getEnv('EXTRACTION_URL', 'http://localhost:11434');
@@ -34,6 +35,55 @@ function buildProjectSummaryPrompt(projectName, sessionSummaries) {
].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) {
const prompt = buildProjectSummaryPrompt(projectName, sessionSummaries);
@@ -64,13 +114,23 @@ async function generateAndStoreProjectSummary(projectId) {
const project = getProject(projectId);
if (!project) throw new Error('Project not found');
let content;
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');
// Upsert — replace existing overview if present, otherwise create fresh
const existing = getProjectOverviewSummary(projectId);
if (existing) {
return updateSummary(existing.id, { content, tokenCount: null, episodeRange: null });