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:
@@ -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.45–0.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">×</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">×</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">×</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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user