project summaries addition
This commit is contained in:
@@ -65,7 +65,7 @@ export default function App() {
|
||||
streaming,
|
||||
lastTokenCount,
|
||||
lastModel,
|
||||
useChat,
|
||||
summarising,
|
||||
} = useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions });
|
||||
|
||||
function navigate(nextView) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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={{ 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',
|
||||
display: 'flex', flexDirection: 'column', gap: '10px',
|
||||
}}>
|
||||
{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)' }}>
|
||||
Project Summary
|
||||
No project summary yet
|
||||
</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>
|
||||
<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.
|
||||
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>
|
||||
|
||||
{/* ── Notes ── */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user