project summaries addition
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -71,6 +89,23 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
|
|||||||
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 ── */}
|
||||||
|
|||||||
@@ -115,3 +115,13 @@ html, body, #root {
|
|||||||
.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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user