Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation

## Projects & Milestones (Phases A + G)
- New models: Project, Milestone (Project → Milestone → Task hierarchy)
- notes table: project_id + milestone_id FKs; parent_id FK constraint activated
- Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones)
- Services: projects.py, milestones.py (CRUD + progress tracking)
- Routes: /api/projects + /api/projects/<id>/milestones
- LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools
- Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated

## RAG Auto-injection (Phase B)
- Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each)
- excluded_note_ids param; ChatView "Auto-included" sidebar section

## Summarisation improvements (Phase C)
- Threshold 20→30, keep-recent 6→8, max_tokens 200→400
- Two-pass summarisation for histories >50 messages

## Browser push notifications (Phase E)
- PushSubscription model + migration; pywebpush dependency
- /api/push routes; VAPID config; fire-and-forget on generation complete
- Frontend: sw.js, push store, Settings toggle

## PWA manifest (Phase F)
- manifest.json, Apple meta tags, service worker registration in main.ts

## Tag normalisation
- All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize)
- Note/Task types gain project_id + milestone_id fields; store signatures updated

## CalDAV
- Radicale embedded server reverted; back to user-configured external CalDAV

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:52:21 -05:00
parent 3d7be5888e
commit 012eb1d46b
52 changed files with 4319 additions and 62 deletions
+78 -16
View File
@@ -37,9 +37,15 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
const includedNoteIds = ref<Set<number>>(new Set());
const includedNotes = ref<{ id: number; title: string }[]>([]);
// Suggested notes — auto-found by search, not yet included
// Suggested notes — auto-found by search, not yet included (score 0.450.59)
const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
// Auto-injected notes — injected automatically because score >= 0.60
const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
// Note IDs excluded from auto-injection on next message
const excludedNoteIds = ref<number[]>([]);
let prevConvId: number | null = null;
const convId = computed(() => {
@@ -98,6 +104,8 @@ watch(convId, async (newId) => {
includedNoteIds.value = new Set();
includedNotes.value = [];
suggestedNotes.value = [];
autoInjectedNotes.value = [];
excludedNoteIds.value = [];
store.lastContextMeta = null;
if (newId) {
// Skip re-fetch if this conversation is already loaded (avoids race
@@ -118,14 +126,35 @@ watch(
);
watch(
() => store.lastContextMeta?.auto_notes,
(newNotes) => {
if (!newNotes) return;
() => store.lastContextMeta,
(meta) => {
if (!meta) return;
const alreadyIncluded = includedNoteIds.value;
const alreadyAutoInjected = new Set(autoInjectedNotes.value.map((n) => n.id));
const alreadySuggested = new Set(suggestedNotes.value.map((n) => n.id));
for (const note of newNotes) {
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id)) {
suggestedNotes.value.push(note);
// Process auto_injected_notes (explicitly marked as injected)
const injectedFromMeta = meta.auto_injected_notes ?? [];
for (const note of injectedFromMeta) {
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
autoInjectedNotes.value.push(note);
alreadyAutoInjected.add(note.id);
}
}
// Process auto_notes — auto_injected flag separates injected from suggested
const autoNotes = meta.auto_notes ?? [];
for (const note of autoNotes) {
if (note.auto_injected) {
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
autoInjectedNotes.value.push(note);
alreadyAutoInjected.add(note.id);
}
} else {
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id) && !alreadyAutoInjected.has(note.id)) {
suggestedNotes.value.push(note);
}
}
}
}
@@ -182,6 +211,8 @@ async function sendMessage() {
undefined,
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
true, // enable thinking in the full chat view
undefined,
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
);
sending.value = false;
@@ -303,6 +334,14 @@ function includeNote(note: { id: number; title: string }) {
includedNoteIds.value = new Set([...includedNoteIds.value, note.id]);
includedNotes.value.push(note);
suggestedNotes.value = suggestedNotes.value.filter((n) => n.id !== note.id);
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== note.id);
}
function excludeAutoNote(noteId: number) {
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== noteId);
if (!excludedNoteIds.value.includes(noteId)) {
excludedNoteIds.value = [...excludedNoteIds.value, noteId];
}
}
function removeIncludedNote(noteId: number) {
@@ -461,23 +500,31 @@ onUnmounted(() => {
</div>
<!-- Context sidebar -->
<aside v-if="includedNotes.length || suggestedNotes.length" class="context-sidebar">
<!-- IN CONTEXT section -->
<template v-if="includedNotes.length">
<div class="context-sidebar-header">In Context</div>
<!-- Explicitly included notes -->
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<aside v-if="autoInjectedNotes.length || includedNotes.length || suggestedNotes.length" class="context-sidebar">
<!-- AUTO-INCLUDED section -->
<template v-if="autoInjectedNotes.length">
<div class="context-sidebar-header">Auto-included</div>
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">&times;</button>
<span
v-if="note.score != null"
class="context-note-score"
:class="{
'score-high': note.score >= 0.75,
'score-medium': note.score >= 0.60 && note.score < 0.75,
'score-low': note.score < 0.60,
}"
:title="`Relevance score: ${note.score}`"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Exclude from auto-injection">&times;</button>
</div>
</template>
<!-- SUGGESTED section -->
<template v-if="suggestedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': includedNotes.length }">Suggested</div>
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
@@ -495,6 +542,18 @@ onUnmounted(() => {
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
</div>
</template>
<!-- IN CONTEXT section -->
<template v-if="includedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
<!-- Explicitly included notes -->
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">&times;</button>
</div>
</template>
</aside>
</div>
@@ -814,6 +873,9 @@ onUnmounted(() => {
.context-note-included {
border-color: var(--color-primary);
}
.context-note-auto {
border-color: var(--color-success);
}
.context-note-suggested {
opacity: 0.85;
}