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
+48
View File
@@ -0,0 +1,48 @@
"""Add projects table and project_id to notes."""
from alembic import op
revision = "0017"
down_revision = "0016"
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
goal TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
color TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_id ON projects(user_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_projects_status ON projects(status)")
op.execute("ALTER TABLE notes ADD COLUMN IF NOT EXISTS project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
op.execute("CREATE INDEX IF NOT EXISTS ix_notes_project_id ON notes(project_id)")
# Add FK constraint to existing parent_id column (was nullable with no FK)
# Only add if constraint doesn't already exist
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'notes_parent_id_fkey'
) THEN
ALTER TABLE notes ADD CONSTRAINT notes_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES notes(id) ON DELETE SET NULL;
END IF;
END $$;
""")
def downgrade() -> None:
op.execute("ALTER TABLE notes DROP CONSTRAINT IF EXISTS notes_parent_id_fkey")
op.execute("DROP INDEX IF EXISTS ix_notes_project_id")
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS project_id")
op.execute("DROP INDEX IF EXISTS ix_projects_status")
op.execute("DROP INDEX IF EXISTS ix_projects_user_id")
op.execute("DROP TABLE IF EXISTS projects")
@@ -0,0 +1,28 @@
"""Add push_subscriptions table for web push notifications."""
from alembic import op
revision = "0018"
down_revision = "0017"
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_used TIMESTAMP WITH TIME ZONE,
UNIQUE(user_id, endpoint)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_push_subscriptions_user_id ON push_subscriptions(user_id)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_push_subscriptions_user_id")
op.execute("DROP TABLE IF EXISTS push_subscriptions")
+37
View File
@@ -0,0 +1,37 @@
"""Add events table for internal CalDAV (Radicale) linkage."""
from alembic import op
revision = "0019"
down_revision = "0018"
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
uid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
start_dt TIMESTAMP WITH TIME ZONE NOT NULL,
end_dt TIMESTAMP WITH TIME ZONE,
all_day BOOLEAN NOT NULL DEFAULT FALSE,
description TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
recurrence TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, uid)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_events_user_id ON events(user_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_events_project_id ON events(project_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_events_start_dt ON events(start_dt)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_events_start_dt")
op.execute("DROP INDEX IF EXISTS ix_events_project_id")
op.execute("DROP INDEX IF EXISTS ix_events_user_id")
op.execute("DROP TABLE IF EXISTS events")
+38
View File
@@ -0,0 +1,38 @@
"""Add milestones table and milestone_id to notes."""
from alembic import op
revision = "0020"
down_revision = "0019"
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS milestones (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
order_index INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_milestones_user_id ON milestones(user_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_milestones_project_id ON milestones(project_id)")
op.execute("""
ALTER TABLE notes
ADD COLUMN IF NOT EXISTS milestone_id INTEGER REFERENCES milestones(id) ON DELETE SET NULL
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_notes_milestone_id ON notes(milestone_id)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_notes_milestone_id")
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS milestone_id")
op.execute("DROP INDEX IF EXISTS ix_milestones_project_id")
op.execute("DROP INDEX IF EXISTS ix_milestones_user_id")
op.execute("DROP TABLE IF EXISTS milestones")
+4
View File
@@ -21,6 +21,10 @@ services:
# SEARXNG_URL: "http://searxng:8080"
# IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path
# IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed
# Push notifications (VAPID keys - generate with: python -c "from py_vapid import Vapid01; v=Vapid01(); v.generate_keys(); print(v.private_key, v.public_key)")
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
VAPID_CLAIMS_SUB: "${VAPID_CLAIMS_SUB:-mailto:admin@fabledassistant.local}"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 10s
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#6c63ff">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Fabled">
<title>Fabled Assistant</title>
</head>
<body>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Fabled Assistant",
"short_name": "Fabled",
"description": "Your self-hosted second brain with AI assistance",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#6c63ff",
"orientation": "portrait-primary",
"icons": [
{
"src": "/favicon.ico",
"sizes": "any",
"type": "image/x-icon"
}
]
}
+35
View File
@@ -0,0 +1,35 @@
// Service Worker for push notifications and PWA support
self.addEventListener('push', event => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || 'Fabled Assistant', {
body: data.body || '',
icon: '/favicon.ico',
badge: '/favicon.ico',
data: { url: data.url || '/' },
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
const url = event.notification.data.url;
for (const client of clientList) {
if (client.url.includes(url) && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// PWA: serve cached assets for offline support (minimal)
self.addEventListener('fetch', event => {
// Pass-through — no offline caching in v1
});
+1
View File
@@ -73,6 +73,7 @@ router.afterEach(() => {
<div class="nav-links" :class="{ open: mobileMenuOpen }">
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" class="nav-link">Chat</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { apiGet } from "@/api/client";
interface Milestone {
id: number;
title: string;
status: string;
}
const props = defineProps<{
projectId: number | null;
modelValue: number | null;
}>();
const emit = defineEmits<{
(e: "update:modelValue", val: number | null): void;
}>();
const milestones = ref<Milestone[]>([]);
const loading = ref(false);
watch(
() => props.projectId,
async (pid) => {
milestones.value = [];
if (!pid) {
emit("update:modelValue", null);
return;
}
loading.value = true;
try {
const data = await apiGet<{ milestones: Milestone[] }>(
`/api/projects/${pid}/milestones`
);
milestones.value = data.milestones.filter((m) => m.status === "active");
} catch {
milestones.value = [];
} finally {
loading.value = false;
}
},
{ immediate: true }
);
function onChange(e: Event) {
const val = (e.target as HTMLSelectElement).value;
emit("update:modelValue", val ? Number(val) : null);
}
</script>
<template>
<select
class="milestone-select"
:value="modelValue ?? ''"
:disabled="!projectId || loading"
@change="onChange"
>
<option value="">{{ projectId ? "No milestone" : "(Select project first)" }}</option>
<option v-for="m in milestones" :key="m.id" :value="m.id">
{{ m.title }}
</option>
</select>
</template>
<style scoped>
.milestone-select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.milestone-select:focus {
outline: none;
border-color: var(--color-primary);
}
.milestone-select:disabled {
opacity: 0.5;
cursor: default;
}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet } from "@/api/client";
interface Project {
id: number;
title: string;
status: string;
}
defineProps<{
modelValue: number | null;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: number | null): void;
}>();
const projects = ref<Project[]>([]);
let cacheExpiry = 0;
async function loadProjects() {
const now = Date.now();
if (projects.value.length > 0 && now < cacheExpiry) return;
try {
const data = await apiGet<{ projects: Project[] }>("/api/projects?status=active");
projects.value = data.projects;
cacheExpiry = now + 60_000; // 60 second cache
} catch {
// Silently fail — selector will just be empty
}
}
onMounted(loadProjects);
function onChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
emit("update:modelValue", value ? Number(value) : null);
}
</script>
<template>
<select class="project-selector" :value="modelValue ?? ''" @change="onChange">
<option value="">No project</option>
<option v-for="project in projects" :key="project.id" :value="project.id">
{{ project.title }}
</option>
</select>
</template>
<style scoped>
.project-selector {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
.project-selector:focus {
outline: none;
border-color: var(--color-primary);
}
</style>
+1 -1
View File
@@ -18,7 +18,7 @@ const inputRef = ref<HTMLInputElement | null>(null);
let fetchDebounce: ReturnType<typeof setTimeout> | null = null;
function sanitize(raw: string): string {
return raw.trim().replace(/\s+/g, "-").replace(/^#+/, "");
return raw.trim().toLowerCase().replace(/\s+/g, "-").replace(/^#+/, "");
}
function addTag(raw: string) {
+9
View File
@@ -9,3 +9,12 @@ const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
// Register service worker for push notifications and PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// SW registration failed — app still works, just no push notifications
})
})
}
+10
View File
@@ -60,6 +60,16 @@ const router = createRouter({
name: "note-edit",
component: () => import("@/views/NoteEditorView.vue"),
},
{
path: "/projects",
name: "projects",
component: () => import("@/views/ProjectListView.vue"),
},
{
path: "/projects/:id",
name: "project-view",
component: () => import("@/views/ProjectView.vue"),
},
{
path: "/tasks",
name: "tasks",
+2
View File
@@ -123,6 +123,7 @@ export const useChatStore = defineStore("chat", () => {
includeNoteIds?: number[],
think = false,
contextNoteTitle?: string,
excludeNoteIds?: number[],
) {
if (!currentConversation.value) return;
@@ -169,6 +170,7 @@ export const useChatStore = defineStore("chat", () => {
content,
context_note_id: contextNoteId,
include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined,
excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
think,
},
);
+2 -1
View File
@@ -78,6 +78,7 @@ export const useNotesStore = defineStore("notes", () => {
title: string;
body: string;
tags?: string[];
project_id?: number | null;
}): Promise<Note> {
try {
return await apiPost<Note>("/api/notes", data);
@@ -89,7 +90,7 @@ export const useNotesStore = defineStore("notes", () => {
async function updateNote(
id: number,
data: Partial<Pick<Note, "title" | "body" | "tags">>
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id">>
): Promise<Note> {
try {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
+108
View File
@@ -0,0 +1,108 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
export const usePushStore = defineStore('push', () => {
const isSupported = ref(
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
)
const permission = ref<NotificationPermission>(
'Notification' in window ? Notification.permission : 'denied'
)
const isSubscribed = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
async function checkSubscription(): Promise<void> {
if (!isSupported.value) return
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
isSubscribed.value = !!sub
} catch {
isSubscribed.value = false
}
}
async function subscribe(): Promise<void> {
if (!isSupported.value) {
error.value = 'Push notifications not supported in this browser'
return
}
loading.value = true
error.value = null
try {
// Request permission
permission.value = await Notification.requestPermission()
if (permission.value !== 'granted') {
error.value = 'Notification permission denied'
return
}
// Fetch VAPID public key
const resp = await fetch('/api/push/vapid-public-key')
if (!resp.ok) {
error.value = 'Push notifications not configured on server'
return
}
const { publicKey } = await resp.json()
// Register service worker
const reg = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
// Subscribe
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
// Save to server
const saveResp = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
})
if (!saveResp.ok) throw new Error('Failed to save subscription')
isSubscribed.value = true
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to subscribe'
} finally {
loading.value = false
}
}
async function unsubscribe(): Promise<void> {
loading.value = true
error.value = null
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await fetch('/api/push/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
})
await sub.unsubscribe()
}
isSubscribed.value = false
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to unsubscribe'
} finally {
loading.value = false
}
}
return { isSupported, permission, isSubscribed, loading, error, checkSubscription, subscribe, unsubscribe }
})
+4 -1
View File
@@ -69,6 +69,9 @@ export const useTasksStore = defineStore("tasks", () => {
status?: TaskStatus;
priority?: TaskPriority;
due_date?: string | null;
project_id?: number | null;
milestone_id?: number | null;
parent_id?: number | null;
}): Promise<Task> {
try {
return await apiPost<Task>("/api/tasks", data);
@@ -81,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask(
id: number,
data: Partial<
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date">
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id">
>
): Promise<Task> {
try {
+2 -1
View File
@@ -54,7 +54,8 @@ export interface ConversationDetail extends Conversation {
export interface ContextMeta {
context_note_id: number | null;
context_note_title: string | null;
auto_notes: { id: number; title: string; score?: number | null }[];
auto_notes: { id: number; title: string; score?: number | null; auto_injected?: boolean }[];
auto_injected_notes?: { id: number; title: string; score?: number | null }[];
}
export interface OllamaStatus {
+2
View File
@@ -7,6 +7,8 @@ export interface Note {
body: string;
tags: string[];
parent_id: number | null;
project_id: number | null;
milestone_id: number | null;
status: TaskStatus | null;
priority: TaskPriority | null;
due_date: string | null;
+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;
}
+41 -3
View File
@@ -10,6 +10,7 @@ import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
const route = useRoute();
const router = useRouter();
@@ -19,6 +20,7 @@ const toast = useToastStore();
const title = ref("");
const body = ref("");
const tags = ref<string[]>([]);
const projectId = ref<number | null>(null);
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -136,12 +138,14 @@ function dismissTagSuggestions() {
let savedTitle = "";
let savedBody = "";
let savedTags: string[] = [];
let savedProjectId: number | null = null;
function markDirty() {
dirty.value =
title.value !== savedTitle ||
body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags);
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
projectId.value !== savedProjectId;
}
function onBodyUpdate(newVal: string) {
@@ -156,9 +160,11 @@ onMounted(async () => {
title.value = store.currentNote.title;
body.value = store.currentNote.body;
tags.value = [...(store.currentNote.tags || [])];
projectId.value = store.currentNote.project_id ?? null;
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
}
}
});
@@ -172,10 +178,12 @@ async function save() {
title: title.value,
body: body.value,
tags: tags.value,
project_id: projectId.value,
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
dirty.value = false;
toast.show("Note saved");
} else {
@@ -183,6 +191,7 @@ async function save() {
title: title.value,
body: body.value,
tags: tags.value,
project_id: projectId.value,
});
dirty.value = false;
toast.show("Note created");
@@ -223,10 +232,11 @@ async function autoSave() {
if (!isEditing.value || !dirty.value || saving.value) return;
saving.value = true;
try {
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value });
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value, project_id: projectId.value } as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
dirty.value = false;
toast.show("Auto-saved");
} catch {
@@ -290,6 +300,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
@input="markDirty"
/>
<div class="field-row-meta">
<div class="meta-field">
<label class="meta-label">Project</label>
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
</div>
</div>
<TagInput
v-model="tags"
:fetchTags="(q: string) => store.fetchAllTags(q)"
@@ -480,4 +497,25 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</main>
</template>
<style src="@/assets/editor-shared.css" />
<style src="@/assets/editor-shared.css" />
<style scoped>
.field-row-meta {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.meta-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 180px;
flex: 1;
max-width: 300px;
}
.meta-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-weight: 500;
}
</style>
+581
View File
@@ -0,0 +1,581 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
interface MilestoneSummary {
id: number;
title: string;
status: string;
pct: number;
total: number;
completed: number;
}
interface Project {
id: number;
title: string;
description: string | null;
goal: string | null;
status: "active" | "completed" | "archived";
color: string | null;
created_at: string;
updated_at: string;
summary?: {
task_counts: { todo?: number; in_progress?: number; done?: number };
note_count: number;
milestone_summary: MilestoneSummary[];
};
}
const router = useRouter();
const toast = useToastStore();
const projects = ref<Project[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"all" | "active" | "completed" | "archived">("all");
// New project modal
const showNewProjectModal = ref(false);
const newTitle = ref("");
const newDescription = ref("");
const newGoal = ref("");
const creating = ref(false);
const filteredProjects = computed(() => {
if (activeTab.value === "all") return projects.value;
return projects.value.filter((p) => p.status === activeTab.value);
});
async function loadProjects() {
loading.value = true;
error.value = null;
try {
const data = await apiGet<{ projects: Project[] }>("/api/projects");
projects.value = data.projects;
// Fetch summaries (including milestone_summary) in parallel
await Promise.allSettled(
projects.value.map(async (p) => {
try {
const full = await apiGet<Project>(`/api/projects/${p.id}`);
p.summary = full.summary;
} catch {
// non-fatal
}
})
);
} catch {
error.value = "Failed to load projects.";
} finally {
loading.value = false;
}
}
function milestoneColor(index: number): string {
const palette = [
"var(--color-primary)",
"var(--color-success)",
"#c98a00",
"#8b5cf6",
"#ef4444",
"#06b6d4",
];
return palette[index % palette.length];
}
onMounted(loadProjects);
function openNewProjectModal() {
newTitle.value = "";
newDescription.value = "";
newGoal.value = "";
showNewProjectModal.value = true;
}
function closeModal() {
showNewProjectModal.value = false;
}
async function createProject() {
if (!newTitle.value.trim()) return;
creating.value = true;
try {
const project = await apiPost<Project>("/api/projects", {
title: newTitle.value.trim(),
description: newDescription.value.trim() || undefined,
goal: newGoal.value.trim() || undefined,
});
projects.value.unshift(project);
showNewProjectModal.value = false;
toast.show("Project created");
router.push(`/projects/${project.id}`);
} catch {
toast.show("Failed to create project", "error");
} finally {
creating.value = false;
}
}
function statusLabel(status: Project["status"]): string {
if (status === "active") return "Active";
if (status === "completed") return "Completed";
if (status === "archived") return "Archived";
return status;
}
function truncate(text: string | null, max = 120): string {
if (!text) return "";
return text.length > max ? text.slice(0, max) + "..." : text;
}
</script>
<template>
<main class="projects-list">
<div class="page-header">
<h1>Projects</h1>
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
</div>
<!-- Filter tabs -->
<div class="filter-tabs">
<button
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
:key="tab"
:class="['tab-btn', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
</button>
</div>
<p v-if="loading" class="loading-msg">Loading...</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<div v-else-if="filteredProjects.length === 0" class="empty-state">
<p class="empty-title">No projects yet</p>
<p class="empty-subtitle">Create your first project to organize tasks and notes.</p>
<button class="btn-cta" @click="openNewProjectModal">+ New Project</button>
</div>
<div v-else class="projects-grid">
<div
v-for="project in filteredProjects"
:key="project.id"
class="project-card"
@click="router.push(`/projects/${project.id}`)"
>
<div class="card-header">
<span class="project-title">{{ project.title }}</span>
<span
:class="['status-badge', `status-${project.status}`]"
>{{ statusLabel(project.status) }}</span>
</div>
<p v-if="project.goal" class="project-goal">
<span class="field-label">Goal:</span> {{ truncate(project.goal) }}
</p>
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
<!-- Milestone progress bars -->
<div
v-if="project.summary?.milestone_summary?.length"
class="milestone-bars"
>
<div
v-for="(ms, i) in project.summary.milestone_summary"
:key="ms.id"
class="milestone-bar-row"
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
>
<span class="milestone-bar-label">{{ ms.title }}</span>
<div class="milestone-bar-track">
<div
class="milestone-bar-fill"
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
></div>
</div>
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
</div>
</div>
<div class="card-footer">
<span class="meta-date">Updated {{ new Date(project.updated_at).toLocaleDateString() }}</span>
</div>
</div>
</div>
<!-- New Project Modal -->
<teleport to="body">
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-card">
<h3 class="modal-title">New Project</h3>
<div class="modal-field">
<label>Title <span class="required">*</span></label>
<input
v-model="newTitle"
type="text"
class="modal-input"
placeholder="Project title"
autofocus
@keydown.enter="createProject"
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<label>Goal</label>
<input
v-model="newGoal"
type="text"
class="modal-input"
placeholder="What are you trying to achieve?"
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<label>Description</label>
<textarea
v-model="newDescription"
class="modal-textarea"
placeholder="Optional description..."
rows="3"
@keydown.escape="closeModal"
></textarea>
</div>
<div class="modal-actions">
<button class="modal-btn" @click="closeModal">Cancel</button>
<button
class="modal-btn modal-btn-primary"
@click="createProject"
:disabled="!newTitle.trim() || creating"
>
{{ creating ? "Creating..." : "Create" }}
</button>
</div>
</div>
</div>
</teleport>
</main>
</template>
<style scoped>
.projects-list {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.page-header h1 {
margin: 0;
}
.btn-primary {
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
}
.btn-primary:hover {
opacity: 0.9;
}
.filter-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0;
}
.tab-btn {
padding: 0.4rem 0.85rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary);
font-family: inherit;
margin-bottom: -1px;
}
.tab-btn:hover {
color: var(--color-primary);
}
.tab-btn.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
.loading-msg,
.error-msg {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-top: 1rem;
}
.error-msg {
color: var(--color-danger);
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.project-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem 1.1rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-card:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 8px var(--color-shadow);
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.project-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
min-width: 0;
flex: 1;
word-break: break-word;
}
.status-badge {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
white-space: nowrap;
}
.status-active {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.status-completed {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
}
.status-archived {
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
.project-goal {
font-size: 0.875rem;
color: var(--color-text);
margin: 0;
line-height: 1.4;
}
.field-label {
font-weight: 600;
color: var(--color-text-secondary);
}
.project-desc {
font-size: 0.82rem;
color: var(--color-text-secondary);
margin: 0;
line-height: 1.45;
}
.milestone-bars {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.1rem;
}
.milestone-bar-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
}
.milestone-bar-label {
color: var(--color-text-secondary);
min-width: 0;
flex: 0 0 30%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.milestone-bar-track {
flex: 1;
height: 5px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
.milestone-bar-fill {
height: 100%;
border-radius: 999px;
transition: width 0.3s ease;
}
.milestone-bar-pct {
color: var(--color-text-muted);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
}
.card-footer {
margin-top: auto;
}
.meta-date {
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.5rem;
width: 100%;
max-width: 480px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-title {
margin: 0;
font-size: 1.1rem;
}
.modal-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.modal-field label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.required {
color: var(--color-danger);
}
.modal-input,
.modal-textarea {
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.modal-input:focus,
.modal-textarea:focus {
outline: none;
border-color: var(--color-primary);
}
.modal-textarea {
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--color-bg);
}
.modal-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.modal-btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) {
.projects-grid {
grid-template-columns: 1fr;
}
.modal-card {
margin: 1rem;
max-width: calc(100vw - 2rem);
}
}
</style>
File diff suppressed because it is too large Load Diff
+93
View File
@@ -4,10 +4,12 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut } from "@/api/client";
import { usePushStore } from "@/stores/push";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const pushStore = usePushStore();
const assistantName = ref("");
const defaultModel = ref("");
const installedModels = ref<string[]>([]);
@@ -73,6 +75,7 @@ const searchError = ref("");
onMounted(async () => {
await store.fetchSettings();
pushStore.checkSubscription();
assistantName.value = store.assistantName;
newEmail.value = authStore.user?.email ?? "";
@@ -640,6 +643,59 @@ function hostname(url: string): string {
</template>
</section>
<!-- Push Notifications half width -->
<section class="settings-section">
<h2>Push Notifications</h2>
<p class="section-desc">
Receive browser push notifications when a response is ready.
</p>
<template v-if="!pushStore.isSupported">
<p class="push-unsupported">Push notifications are not supported in this browser.</p>
</template>
<template v-else>
<div class="push-status-row">
<span class="push-status-label">Permission:</span>
<span
:class="['push-permission-badge', {
'perm-granted': pushStore.permission === 'granted',
'perm-denied': pushStore.permission === 'denied',
'perm-default': pushStore.permission === 'default',
}]"
>
{{ pushStore.permission === 'granted' ? 'Granted' : pushStore.permission === 'denied' ? 'Denied' : 'Not asked' }}
</span>
</div>
<div class="push-status-row">
<span class="push-status-label">Status:</span>
<span :class="['push-sub-badge', { 'sub-active': pushStore.isSubscribed }]">
{{ pushStore.isSubscribed ? 'Subscribed' : 'Not subscribed' }}
</span>
</div>
<p v-if="pushStore.error" class="push-error">{{ pushStore.error }}</p>
<div class="actions" style="margin-top: 0.75rem;">
<button
v-if="!pushStore.isSubscribed"
class="btn-save"
@click="pushStore.subscribe()"
:disabled="pushStore.loading || pushStore.permission === 'denied'"
>
{{ pushStore.loading ? 'Enabling...' : 'Enable Notifications' }}
</button>
<button
v-else
class="btn-secondary"
@click="pushStore.unsubscribe()"
:disabled="pushStore.loading"
>
{{ pushStore.loading ? 'Disabling...' : 'Disable Notifications' }}
</button>
</div>
<p v-if="pushStore.permission === 'denied'" class="field-hint" style="margin-top: 0.5rem;">
Notifications are blocked. Allow them in your browser site settings to re-enable.
</p>
</template>
</section>
<!-- Application URL (admin) full width -->
<section v-if="authStore.isAdmin" class="settings-section full-width">
<h2>Application URL</h2>
@@ -1022,6 +1078,43 @@ function hostname(url: string): string {
max-width: 480px;
}
/* Push notifications */
.push-unsupported {
font-size: 0.875rem;
color: var(--color-text-muted);
font-style: italic;
}
.push-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-size: 0.875rem;
}
.push-status-label {
color: var(--color-text-secondary);
font-weight: 500;
}
.push-permission-badge,
.push-sub-badge {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.perm-denied { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
.sub-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.push-error {
font-size: 0.82rem;
color: var(--color-danger, #e74c3c);
margin: 0.25rem 0 0;
}
/* Responsive — collapse to 1 column on narrow screens */
@media (max-width: 640px) {
.settings-grid {
+181 -3
View File
@@ -6,12 +6,14 @@ import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist";
import { apiPost } from "@/api/client";
import { apiPost, apiGet } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
const route = useRoute();
const router = useRouter();
@@ -25,6 +27,15 @@ const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const dueDate = ref("");
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const parentId = ref<number | null>(null);
const parentTitle = ref("");
const parentSearchQuery = ref("");
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
const parentSearchLoading = ref(false);
const showParentDropdown = ref(false);
let parentSearchTimer: ReturnType<typeof setTimeout> | null = null;
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -144,6 +155,9 @@ let savedTags: string[] = [];
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
let savedParentId: number | null = null;
function markDirty() {
dirty.value =
@@ -152,7 +166,10 @@ function markDirty() {
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate;
dueDate.value !== savedDueDate ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId ||
parentId.value !== savedParentId;
}
function onBodyUpdate(newVal: string) {
@@ -160,6 +177,58 @@ function onBodyUpdate(newVal: string) {
markDirty();
}
function onParentSearchInput() {
if (parentSearchTimer) clearTimeout(parentSearchTimer);
if (!parentSearchQuery.value.trim()) {
parentSearchResults.value = [];
showParentDropdown.value = false;
return;
}
parentSearchTimer = setTimeout(async () => {
const q = parentSearchQuery.value.trim();
if (!q) return;
parentSearchLoading.value = true;
showParentDropdown.value = true;
try {
const data = await apiGet<{ notes: Array<{ id: number; title: string }> }>(
`/api/notes?q=${encodeURIComponent(q)}&type=task&limit=8`
);
parentSearchResults.value = data.notes.filter((t) =>
!taskId.value || t.id !== taskId.value
);
} catch {
parentSearchResults.value = [];
} finally {
parentSearchLoading.value = false;
}
}, 250);
}
function selectParentTask(task: { id: number; title: string }) {
parentId.value = task.id;
parentTitle.value = task.title;
parentSearchQuery.value = task.title;
showParentDropdown.value = false;
markDirty();
}
function onParentFocus() {
if (parentSearchQuery.value) showParentDropdown.value = true;
}
function hideParentDropdown() {
setTimeout(() => { showParentDropdown.value = false; }, 200);
}
function clearParentTask() {
parentId.value = null;
parentTitle.value = "";
parentSearchQuery.value = "";
parentSearchResults.value = [];
showParentDropdown.value = false;
markDirty();
}
onMounted(async () => {
if (taskId.value) {
await store.fetchTask(taskId.value);
@@ -170,12 +239,21 @@ onMounted(async () => {
status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority;
dueDate.value = store.currentTask.due_date || "";
const taskRec = store.currentTask as Record<string, unknown>;
projectId.value = (taskRec.project_id as number | null) ?? null;
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
parentId.value = (taskRec.parent_id as number | null) ?? null;
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
parentSearchQuery.value = parentTitle.value;
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
}
}
});
@@ -191,6 +269,9 @@ async function save() {
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
@@ -200,6 +281,9 @@ async function save() {
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
dirty.value = false;
toast.show("Task saved");
} else {
@@ -250,13 +334,19 @@ async function autoSave() {
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
});
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
} as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
dirty.value = false;
toast.show("Auto-saved");
} catch {
@@ -344,6 +434,49 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
@input="markDirty"
/>
</div>
<div class="field">
<label>Project</label>
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
</div>
<div class="field">
<label>Milestone</label>
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
</div>
</div>
<div class="field-row">
<div class="field parent-field">
<label>Parent Task</label>
<div class="parent-search-wrapper">
<div class="parent-input-row">
<input
v-model="parentSearchQuery"
type="text"
class="field-input"
placeholder="Search tasks..."
@input="onParentSearchInput"
@focus="onParentFocus"
@blur="hideParentDropdown"
/>
<button
v-if="parentId"
class="btn-clear-parent"
@click="clearParentTask"
title="Clear parent task"
>&times;</button>
</div>
<div v-if="showParentDropdown" class="parent-dropdown">
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
<div
v-for="task in parentSearchResults"
:key="task.id"
class="parent-dropdown-item"
@mousedown.prevent="selectParentTask(task)"
>{{ task.title || "Untitled" }}</div>
<div v-if="!parentSearchLoading && parentSearchResults.length === 0" class="parent-dropdown-item parent-empty">No tasks found</div>
</div>
</div>
</div>
</div>
<TagInput
@@ -551,4 +684,49 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-text);
font-size: 0.9rem;
}
/* Parent task search */
.parent-field { max-width: 360px; }
.parent-search-wrapper { position: relative; }
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
.parent-input-row .field-input { flex: 1; }
.btn-clear-parent {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 1.1rem;
line-height: 1;
padding: 0 0.2rem;
flex-shrink: 0;
}
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
.parent-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--color-shadow);
z-index: 50;
max-height: 200px;
overflow-y: auto;
}
.parent-dropdown-item {
padding: 0.45rem 0.7rem;
font-size: 0.875rem;
cursor: pointer;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
.parent-empty {
color: var(--color-text-muted);
cursor: default;
}
.parent-empty:hover { background: none; }
</style>
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
"aiosmtplib>=3.0",
"caldav>=1.3",
"icalendar>=5.0",
"pywebpush>=2.0",
]
[project.optional-dependencies]
+8 -1
View File
@@ -14,6 +14,9 @@ from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.images import images_bp
from fabledassistant.routes.milestones import milestones_bp
from fabledassistant.routes.projects import projects_bp
from fabledassistant.routes.push import push_bp
from fabledassistant.routes.quick_capture import quick_capture_bp
from fabledassistant.routes.settings import settings_bp
from fabledassistant.routes.tasks import tasks_bp
@@ -46,7 +49,10 @@ def create_app() -> Quart:
app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(push_bp)
app.register_blueprint(quick_capture_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(tasks_bp)
@@ -91,7 +97,8 @@ def create_app() -> Quart:
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
"connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self';"
"connect-src 'self'; font-src 'self' data:; object-src 'none'; "
"base-uri 'self'; worker-src 'self';"
)
return response
+5
View File
@@ -60,6 +60,11 @@ class Config:
# Maximum size of a single image to cache (default 5 MB)
IMAGE_MAX_BYTES: int = int(os.environ.get("IMAGE_MAX_BYTES", str(5 * 1024 * 1024)))
# VAPID keys for browser push notifications
VAPID_PRIVATE_KEY: str = os.environ.get("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.environ.get("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_SUB: str = os.environ.get("VAPID_CLAIMS_SUB", "mailto:admin@fabledassistant.local")
@classmethod
def oidc_enabled(cls) -> bool:
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
+4
View File
@@ -25,3 +25,7 @@ from fabledassistant.models.password_reset import PasswordResetToken # noqa: E4
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
from fabledassistant.models.image_cache import ImageCache # noqa: E402, F401
from fabledassistant.models.project import Project # noqa: E402, F401
from fabledassistant.models.push_subscription import PushSubscription # noqa: E402, F401
from fabledassistant.models.event import Event # noqa: E402, F401
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE")
)
project_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
)
# iCal UID for Radicale linkage (unique per user)
uid: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(Text, default="")
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"uid": self.uid,
"project_id": self.project_id,
"title": self.title,
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
"end_dt": self.end_dt.isoformat() if self.end_dt else None,
"all_day": self.all_day,
"description": self.description,
"location": self.location,
"recurrence": self.recurrence,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class Milestone(Base):
__tablename__ = "milestones"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(Text, default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"project_id": self.project_id,
"title": self.title,
"description": self.description,
"status": self.status,
"order_index": self.order_index,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
+11 -1
View File
@@ -32,7 +32,13 @@ class Note(Base):
body: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
parent_id: Mapped[int | None] = mapped_column(
nullable=True
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
project_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
)
milestone_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("milestones.id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -51,6 +57,8 @@ class Note(Base):
Index("ix_notes_status", "status"),
Index("ix_notes_title", "title"),
Index("ix_notes_user_id", "user_id"),
Index("ix_notes_project_id", "project_id"),
Index("ix_notes_milestone_id", "milestone_id"),
)
@property
@@ -64,6 +72,8 @@ class Note(Base):
"body": self.body,
"tags": self.tags or [],
"parent_id": self.parent_id,
"project_id": self.project_id,
"milestone_id": self.milestone_id,
"status": self.status,
"priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None,
+36
View File
@@ -0,0 +1,36 @@
import enum
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class ProjectStatus(str, enum.Enum):
active = "active"
completed = "completed"
archived = "archived"
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
title: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str] = mapped_column(Text, default="")
goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"description": self.description,
"goal": self.goal,
"status": self.status,
"color": self.color,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -0,0 +1,20 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
endpoint: Mapped[str] = mapped_column(Text)
p256dh: Mapped[str] = mapped_column(Text)
auth: Mapped[str] = mapped_column(Text)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("user_id", "endpoint", name="uq_push_subscription_user_endpoint"),
)
+2
View File
@@ -114,6 +114,7 @@ async def send_message_route(conv_id: int):
return jsonify({"error": "content is required"}), 400
context_note_id = data.get("context_note_id")
include_note_ids = data.get("include_note_ids") or []
excluded_note_ids = data.get("excluded_note_ids") or []
think = bool(data.get("think", False))
# Reject if generation already running for this conversation
@@ -143,6 +144,7 @@ async def send_message_route(conv_id: int):
uid, conv_id, conv.title, content,
context_note_id=context_note_id,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
think=think,
))
+124
View File
@@ -0,0 +1,124 @@
"""Milestone routes nested under /api/projects/<project_id>/milestones."""
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.milestones import (
create_milestone,
delete_milestone,
get_milestone,
get_milestone_progress,
list_milestones,
update_milestone,
)
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import get_project
logger = logging.getLogger(__name__)
milestones_bp = Blueprint("milestones", __name__, url_prefix="/api/projects")
@milestones_bp.route("/<int:project_id>/milestones", methods=["GET"])
@login_required
async def list_milestones_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
status = request.args.get("status")
milestones = await list_milestones(uid, project_id, status=status)
result = []
for m in milestones:
entry = m.to_dict()
progress = await get_milestone_progress(m.id)
entry.update(progress)
result.append(entry)
return jsonify({"milestones": result})
@milestones_bp.route("/<int:project_id>/milestones", methods=["POST"])
@login_required
async def create_milestone_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
milestone = await create_milestone(
uid,
project_id,
title=data["title"],
description=data.get("description"),
order_index=data.get("order_index", 0),
)
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry), 201
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["GET"])
@login_required
async def get_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
entry = milestone.to_dict()
entry.update(await get_milestone_progress(milestone.id))
return jsonify(entry)
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["PATCH"])
@login_required
async def update_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
data = await request.get_json()
allowed = {"title", "description", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed}
updated = await update_milestone(uid, milestone_id, **fields)
if updated is None:
return jsonify({"error": "Milestone not found"}), 404
entry = updated.to_dict()
entry.update(await get_milestone_progress(updated.id))
return jsonify(entry)
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>", methods=["DELETE"])
@login_required
async def delete_milestone_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
deleted = await delete_milestone(uid, milestone_id)
if not deleted:
return jsonify({"error": "Milestone not found"}), 404
return "", 204
@milestones_bp.route("/<int:project_id>/milestones/<int:milestone_id>/tasks", methods=["GET"])
@login_required
async def get_milestone_tasks_route(project_id: int, milestone_id: int):
uid = get_current_user_id()
milestone = await get_milestone(uid, milestone_id)
if milestone is None or milestone.project_id != project_id:
return jsonify({"error": "Milestone not found"}), 404
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
notes, total = await list_notes(
uid,
is_task=True,
status=status_filter,
milestone_id=milestone_id,
limit=limit,
offset=offset,
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
+3 -1
View File
@@ -84,6 +84,8 @@ async def create_note_route():
body=body,
tags=tags,
parent_id=data.get("parent_id"),
project_id=data.get("project_id"),
milestone_id=data.get("milestone_id"),
status=status,
priority=priority,
due_date=due_date,
@@ -176,7 +178,7 @@ async def update_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id", "status", "priority"):
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
if key in data:
fields[key] = data[key]
+114
View File
@@ -0,0 +1,114 @@
"""Project management routes."""
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import (
create_project,
delete_project,
get_project,
get_project_summary,
list_projects,
update_project,
)
logger = logging.getLogger(__name__)
projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
@projects_bp.route("", methods=["GET"])
@login_required
async def list_projects_route():
uid = get_current_user_id()
status = request.args.get("status")
projects = await list_projects(uid, status=status)
return jsonify({"projects": [p.to_dict() for p in projects]})
@projects_bp.route("", methods=["POST"])
@login_required
async def create_project_route():
uid = get_current_user_id()
data = await request.get_json()
if not data.get("title"):
return jsonify({"error": "title is required"}), 400
project = await create_project(
uid,
title=data["title"],
description=data.get("description", ""),
goal=data.get("goal", ""),
color=data.get("color"),
)
return jsonify(project.to_dict()), 201
@projects_bp.route("/<int:project_id>", methods=["GET"])
@login_required
async def get_project_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
summary = await get_project_summary(uid, project_id)
data = project.to_dict()
data["summary"] = summary
return jsonify(data)
@projects_bp.route("/<int:project_id>", methods=["PATCH"])
@login_required
async def update_project_route(project_id: int):
uid = get_current_user_id()
data = await request.get_json()
allowed = {"title", "description", "goal", "status", "color"}
fields = {k: v for k, v in data.items() if k in allowed}
project = await update_project(uid, project_id, **fields)
if project is None:
return jsonify({"error": "Project not found"}), 404
return jsonify(project.to_dict())
@projects_bp.route("/<int:project_id>", methods=["DELETE"])
@login_required
async def delete_project_route(project_id: int):
uid = get_current_user_id()
deleted = await delete_project(uid, project_id)
if not deleted:
return jsonify({"error": "Project not found"}), 404
return "", 204
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required
async def get_project_notes_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
return jsonify({"error": "Project not found"}), 404
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
status_filter = request.args.get("status")
limit = min(request.args.get("limit", 100, type=int), 500)
offset = request.args.get("offset", 0, type=int)
is_task: bool | None = None
if type_filter == "task":
is_task = True
elif type_filter == "note":
is_task = False
notes, total = await list_notes(
uid,
is_task=is_task,
status=status_filter,
project_id=project_id,
limit=limit,
offset=offset,
sort="updated_at",
order="desc",
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
+44
View File
@@ -0,0 +1,44 @@
"""Push notification subscription routes."""
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
from fabledassistant.services.push import delete_subscription, save_subscription, vapid_enabled
logger = logging.getLogger(__name__)
push_bp = Blueprint("push", __name__, url_prefix="/api/push")
@push_bp.route("/vapid-public-key", methods=["GET"])
@login_required
async def get_vapid_key():
if not vapid_enabled():
return jsonify({"error": "Push notifications not configured"}), 503
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY})
@push_bp.route("/subscribe", methods=["POST"])
@login_required
async def subscribe():
uid = get_current_user_id()
data = await request.get_json()
if not data or not data.get("endpoint"):
return jsonify({"error": "Invalid subscription data"}), 400
user_agent = request.headers.get("User-Agent")
sub = await save_subscription(uid, data, user_agent=user_agent)
return jsonify({"id": sub.id}), 201
@push_bp.route("/subscribe", methods=["DELETE"])
@login_required
async def unsubscribe():
uid = get_current_user_id()
data = await request.get_json()
endpoint = (data or {}).get("endpoint", "")
if not endpoint:
return jsonify({"error": "endpoint is required"}), 400
await delete_subscription(uid, endpoint)
return "", 204
+8 -8
View File
@@ -16,13 +16,13 @@ CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "cald
async def get_caldav_config(user_id: int) -> dict[str, str]:
"""Read CalDAV settings for the given user."""
"""Return the user's CalDAV config from their settings."""
all_settings = await get_all_settings(user_id)
return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS}
async def is_caldav_configured(user_id: int) -> bool:
"""Check if CalDAV URL, username, and password are all set."""
"""Check if the user has configured an external CalDAV server."""
config = await get_caldav_config(user_id)
return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password"))
@@ -55,8 +55,8 @@ def _make_client(config: dict[str, str]) -> caldav.DAVClient:
"""Create a CalDAV client from config dict."""
return caldav.DAVClient(
url=config["caldav_url"],
username=config["caldav_username"],
password=config["caldav_password"],
username=config.get("caldav_username") or None,
password=config.get("caldav_password") or None,
)
@@ -158,8 +158,8 @@ def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None:
def _check_config(config: dict[str, str]) -> None:
"""Raise if CalDAV is not configured."""
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
raise ValueError("CalDAV is not configured. Go to Settings -> Calendar to set it up.")
if not config.get("caldav_url"):
raise ValueError("CalDAV is not configured. Go to Settings Calendar to enter your server URL.")
async def create_event(
@@ -707,8 +707,8 @@ async def delete_todo(
async def test_connection(user_id: int) -> dict:
"""Test the CalDAV connection and return status."""
config = await get_caldav_config(user_id)
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
return {"success": False, "error": "CalDAV is not configured. Please fill in URL, username, and password."}
if not config.get("caldav_url"):
return {"success": False, "error": "CalDAV is not configured."}
def _test():
client = _make_client(config)
@@ -0,0 +1,304 @@
"""Calendar sync service: bridges the DB Event table with local Radicale.
Each user's calendars live at: http://radicale:5232/user_{user_id}/calendar/
The app connects without auth (Radicale runs with auth.type = none inside Docker).
External clients (iOS, macOS, Thunderbird) connect directly to Radicale on port 5232.
Their CalDAV URL is: http://<host>:5232/user_{user_id}/calendar/
"""
import asyncio
import logging
import uuid
from datetime import datetime, timezone
import httpx
import icalendar
from sqlalchemy import select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
logger = logging.getLogger(__name__)
# Internal Radicale URL (Docker service name)
_RADICALE_INTERNAL = "http://radicale:5232"
def _user_calendar_path(user_id: int) -> str:
return f"/user_{user_id}/calendar/"
def _user_calendar_url(user_id: int) -> str:
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
return f"{base}/user_{user_id}/calendar/"
async def setup_user_calendar(user_id: int) -> bool:
"""Create Radicale collection for user if it doesn't exist.
Returns True on success or if already exists, False on error.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
user_url = f"{base}/user_{user_id}/"
cal_url = f"{base}/user_{user_id}/calendar/"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Create user home collection
await client.request(
"MKCOL",
user_url,
headers={"Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<set><prop><resourcetype><collection/></resourcetype></prop></set>
</mkcol>""",
)
# Create calendar collection
resp = await client.request(
"MKCOL",
cal_url,
headers={"Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<set>
<prop>
<resourcetype><collection/><C:calendar/></resourcetype>
<displayname>Fabled Assistant</displayname>
</prop>
</set>
</mkcol>""",
)
if resp.status_code in (201, 405): # 405 = already exists
logger.info("Calendar collection ready for user %d", user_id)
return True
logger.warning("Unexpected status %d creating calendar for user %d", resp.status_code, user_id)
return False
except Exception:
logger.warning("Failed to setup Radicale calendar for user %d", user_id, exc_info=True)
return False
def _make_vcalendar(event: Event) -> str:
"""Build iCalendar string from DB Event."""
cal = icalendar.Calendar()
cal.add("prodid", "-//FabledAssistant//EN")
cal.add("version", "2.0")
vevent = icalendar.Event()
vevent.add("uid", event.uid)
vevent.add("summary", event.title)
if event.all_day:
vevent.add("dtstart", event.start_dt.date())
if event.end_dt:
vevent.add("dtend", event.end_dt.date())
else:
vevent.add("dtstart", event.start_dt)
if event.end_dt:
vevent.add("dtend", event.end_dt)
if event.description:
vevent.add("description", event.description)
if event.location:
vevent.add("location", event.location)
if event.recurrence:
rrule_parts: dict = {}
for part in event.recurrence.split(";"):
if "=" in part:
key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip()
if rrule_parts:
vevent.add("rrule", rrule_parts)
vevent.add("dtstamp", datetime.now(timezone.utc))
cal.add_component(vevent)
return cal.to_ical().decode("utf-8")
async def push_event_to_radicale(user_id: int, event: Event) -> bool:
"""Write a DB Event to Radicale. Creates or updates the resource.
Returns True on success.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{event.uid}.ics"
ical_data = _make_vcalendar(event)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.put(
resource_url,
content=ical_data.encode("utf-8"),
headers={"Content-Type": "text/calendar; charset=utf-8"},
)
if resp.status_code in (200, 201, 204):
return True
logger.warning("Radicale PUT returned %d for event %s", resp.status_code, event.uid)
return False
except Exception:
logger.warning("Failed to push event %s to Radicale", event.uid, exc_info=True)
return False
async def delete_event_from_radicale(user_id: int, uid: str) -> bool:
"""Delete an event from Radicale by UID."""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{uid}.ics"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.delete(resource_url)
return resp.status_code in (200, 204, 404) # 404 is ok (already gone)
except Exception:
logger.warning("Failed to delete event %s from Radicale", uid, exc_info=True)
return False
async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
"""Fetch an event from Radicale and upsert into the DB Event table.
Returns the upserted Event or None on error.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
resource_url = f"{base}/user_{user_id}/calendar/{ical_uid}.ics"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(resource_url)
if resp.status_code == 404:
return None
resp.raise_for_status()
ical_data = resp.text
except Exception:
logger.warning("Failed to fetch event %s from Radicale", ical_uid, exc_info=True)
return None
try:
cal = icalendar.Calendar.from_ical(ical_data)
for component in cal.walk():
if component.name != "VEVENT":
continue
title = str(component.get("SUMMARY", ""))
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
description = str(component.get("DESCRIPTION", ""))
location = str(component.get("LOCATION", ""))
rrule = component.get("RRULE")
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
all_day = False
if dtstart and isinstance(dtstart.dt, datetime):
start_dt = dtstart.dt if dtstart.dt.tzinfo else dtstart.dt.replace(tzinfo=timezone.utc)
elif dtstart:
from datetime import date
d = dtstart.dt
start_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
all_day = True
else:
continue
end_dt = None
if dtend and isinstance(dtend.dt, datetime):
end_dt = dtend.dt if dtend.dt.tzinfo else dtend.dt.replace(tzinfo=timezone.utc)
elif dtend:
from datetime import date
d = dtend.dt
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
)
existing = result.scalars().first()
if existing:
existing.title = title
existing.start_dt = start_dt
existing.end_dt = end_dt
existing.all_day = all_day
existing.description = description
existing.location = location
existing.recurrence = recurrence
existing.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
else:
event_obj = Event(
user_id=user_id,
uid=ical_uid,
title=title,
start_dt=start_dt,
end_dt=end_dt,
all_day=all_day,
description=description,
location=location,
recurrence=recurrence,
)
session.add(event_obj)
await session.commit()
await session.refresh(event_obj)
return event_obj
except Exception:
logger.warning("Failed to parse/upsert Radicale event %s", ical_uid, exc_info=True)
return None
async def sync_all_events_from_radicale(user_id: int) -> int:
"""Full sync: fetch all events from Radicale calendar and upsert into DB.
Returns number of events synced.
"""
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
cal_url = f"{base}/user_{user_id}/calendar/"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
# PROPFIND to get all .ics resources
resp = await client.request(
"PROPFIND",
cal_url,
headers={"Depth": "1", "Content-Type": "text/xml"},
content="""<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:"><prop><getetag/><getcontenttype/></prop></propfind>""",
)
if resp.status_code == 404:
logger.info("No calendar found for user %d — setting up", user_id)
await setup_user_calendar(user_id)
return 0
if resp.status_code != 207:
logger.warning("PROPFIND returned %d for user %d", resp.status_code, user_id)
return 0
# Extract UIDs from href paths
import re
hrefs = re.findall(r"<[Dd]:[Hh]ref>([^<]+\.ics)</[Dd]:[Hh]ref>", resp.text)
uids = [h.rstrip("/").split("/")[-1].removesuffix(".ics") for h in hrefs]
except Exception:
logger.warning("Failed to list Radicale events for user %d", user_id, exc_info=True)
return 0
synced = 0
for uid in uids:
if uid:
event = await sync_event_to_db(user_id, uid)
if event:
synced += 1
logger.info("Synced %d events from Radicale for user %d", synced, user_id)
return synced
def get_external_caldav_url(user_id: int) -> str:
"""Return the CalDAV URL for external clients (iOS, macOS, Thunderbird).
Uses BASE_URL from Config with port 5232 substituted, or RADICALE_EXTERNAL_URL.
"""
external = Config.RADICALE_EXTERNAL_URL
if external:
return f"{external.rstrip('/')}/user_{user_id}/calendar/"
# Fall back to BASE_URL host with port 5232
from urllib.parse import urlparse
parsed = urlparse(Config.BASE_URL)
host = parsed.hostname or "localhost"
return f"http://{host}:5232/user_{user_id}/calendar/"
+3 -2
View File
@@ -78,11 +78,12 @@ async def semantic_search_notes(
query: str,
exclude_ids: set[int] | None = None,
limit: int = 8,
threshold: float = _SIMILARITY_THRESHOLD,
) -> list[tuple[float, Note]]:
"""Return up to *limit* (score, note) pairs most relevant to *query*.
Scores are cosine similarities in [-1, 1]; only notes at or above
_SIMILARITY_THRESHOLD are returned, sorted highest-first.
*threshold* are returned, sorted highest-first.
Returns an empty list if the embedding model is unavailable or on any error.
"""
try:
@@ -114,7 +115,7 @@ async def semantic_search_notes(
sim = _cosine_similarity(query_vec, ne.embedding)
except Exception:
continue
if sim >= _SIMILARITY_THRESHOLD:
if sim >= threshold:
scored.append((sim, note))
scored.sort(key=lambda x: x[0], reverse=True)
@@ -147,6 +147,7 @@ async def run_generation(
user_content: str,
context_note_id: int | None = None,
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
think: bool = False,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
@@ -166,7 +167,7 @@ async def run_generation(
# Phase 2: Summarize long conversation history if needed.
history_to_use = history
history_summary: str | None = None
if len(history) > 20: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
if len(history) > 30: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
buf.append_event("status", {"status": "Summarizing conversation history..."})
history_to_use, history_summary = await summarize_history_for_context(history, model)
@@ -177,6 +178,7 @@ async def run_generation(
user_id, history_to_use, context_note_id, user_content,
history_summary=history_summary,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
))
messages, context_meta = await context_task
@@ -348,6 +350,22 @@ async def run_generation(
buf.finished_at = time.monotonic()
buf.append_event("done", {"done": True, "message_id": msg_id, "timing": timing})
# Fire push notification when complete (non-critical, fire-and-forget)
try:
from fabledassistant.services.push import send_push_notification, vapid_enabled
if vapid_enabled():
preview = buf.content_so_far[:120].rstrip()
if len(buf.content_so_far) > 120:
preview += ""
asyncio.create_task(send_push_notification(
user_id,
title="Response ready",
body=preview,
url=f"/chat/{conv_id}",
))
except Exception:
logger.debug("Failed to schedule push notification", exc_info=True)
# Title generation is non-critical — fire-and-forget so done fires immediately
non_system = [m for m in messages if m["role"] != "system"]
msg_count = len(non_system)
+1
View File
@@ -132,6 +132,7 @@ Rules:
- create_event: appointments, meetings, scheduled occurrences with a date/time ("dentist Friday 2pm", "team meeting next Tuesday")
- update_note: updating, editing, appending to an existing note or task ("add to my shopping list: eggs", "mark buy milk done", "append to my meeting notes", "update my project note")
- research_topic: user wants a comprehensive research note from web sources ("research X", "look up X and make a note", "find everything about X", "compile a note on X")
- create_project: user wants to create a project, initiative, or campaign ("new project X", "start a project called Y", "create a project for Z")
- create_note: everything else — ideas, observations, links, excerpts, longer text
- For create_task / create_event: extract a concise title; put any extra detail in "body"
- For create_note: use a short descriptive title (≤60 chars); put the FULL original text as "body"
+110 -19
View File
@@ -27,6 +27,10 @@ STOP_WORDS = frozenset({
"but", "if", "so", "than", "too", "very", "just", "about", "up",
})
RAG_AUTO_THRESHOLD = 0.60
RAG_AUTO_LIMIT = 3
RAG_AUTO_SNIPPET = 800
async def get_installed_models() -> set[str]:
"""Return set of installed Ollama model names (with and without :latest)."""
@@ -302,8 +306,8 @@ def _find_urls(text: str) -> list[str]:
# History summarization thresholds
_HISTORY_SUMMARY_THRESHOLD = 20 # total messages before summarizing
_HISTORY_KEEP_RECENT = 6 # verbatim tail to preserve (3 exchanges)
_HISTORY_SUMMARY_THRESHOLD = 30 # total messages before summarizing
_HISTORY_KEEP_RECENT = 8 # verbatim tail to preserve (4 exchanges)
async def summarize_history_for_context(
@@ -324,13 +328,55 @@ async def summarize_history_for_context(
to_summarize = history[:-_HISTORY_KEEP_RECENT]
recent = history[-_HISTORY_KEEP_RECENT:]
lines: list[str] = []
for m in to_summarize:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
lines.append(f"{label}: {content[:400]}")
# Two-pass for very long histories: summarize first half, combine with second half
if len(to_summarize) > 50:
mid = len(to_summarize) // 2
first_half = to_summarize[:mid]
second_half = to_summarize[mid:]
# Summarize first half
first_lines = []
for m in first_half:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
first_lines.append(f"{label}: {content[:400]}")
if first_lines:
try:
first_summary_messages = [
{"role": "system", "content": "Summarize this conversation in 3-4 sentences covering topics, notes/tasks created, and key decisions."},
{"role": "user", "content": "\n".join(first_lines)},
]
summary_a = await generate_completion(first_summary_messages, model, max_tokens=300)
summary_a = summary_a.strip()
except Exception:
summary_a = ""
else:
summary_a = ""
# Build lines for final pass from second half
second_lines = []
for m in second_half:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
second_lines.append(f"{label}: {content[:400]}")
if summary_a:
lines = [f"[Earlier summary: {summary_a}]"] + second_lines
else:
lines = second_lines
else:
lines: list[str] = []
for m in to_summarize:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
lines.append(f"{label}: {content[:400]}")
if not lines:
return history, None
@@ -339,17 +385,19 @@ async def summarize_history_for_context(
{
"role": "system",
"content": (
"Summarize this conversation history in 3-5 concise sentences. "
"Capture: topics discussed, any notes/tasks/events created or modified, "
"decisions made, and context needed to continue the conversation naturally. "
"Be specific and factual. Output only the summary, nothing else."
"Summarize this conversation history. Capture: "
"(1) All notes, tasks, and projects created or modified — include their exact names. "
"(2) Key decisions made and conclusions reached. "
"(3) Open questions and next steps mentioned. "
"(4) The overall topic arc so the conversation can continue naturally. "
"Be specific and factual. Output 4-8 concise sentences. Nothing else."
),
},
{"role": "user", "content": "\n".join(lines)},
]
try:
summary = await generate_completion(prompt_messages, model, max_tokens=200)
summary = await generate_completion(prompt_messages, model, max_tokens=400)
summary = summary.strip()
if summary:
logger.info(
@@ -371,6 +419,7 @@ async def build_context(
exclude_note_ids: list[int] | None = None,
history_summary: str | None = None,
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -426,6 +475,7 @@ async def build_context(
"context_note_id": None,
"context_note_title": None,
"auto_notes": [],
"auto_injected_notes": [],
}
# Include current note context if provided — full body, no truncation
@@ -441,10 +491,9 @@ async def build_context(
f"--- End Note ---"
)
# Search for related notes to populate sidebar candidates.
# Results are NOT injected into the system prompt automatically — this keeps
# the system prompt prefix stable so Ollama's KV cache can reuse prefill state.
# Users can explicitly include notes via the sidebar (include_note_ids).
# Search for related notes. High-confidence results (>=0.60) are auto-injected
# into the system prompt; lower-confidence results populate the sidebar only.
# Users can also explicitly include notes via the sidebar (include_note_ids).
search_exclude = set(exclude_set)
if current_note_id:
search_exclude.add(current_note_id)
@@ -473,12 +522,54 @@ async def build_context(
except Exception:
logger.warning("Failed to search notes for context", exc_info=True)
# Populate sidebar candidates (never auto-injected into system prompt).
# Separate high-confidence results for auto-injection vs sidebar display
excluded_inject_set = set(excluded_note_ids or [])
auto_inject: list[tuple[float, object]] = []
sidebar_only: list[tuple[float | None, object]] = []
for score, n in found_scored:
if (
score is not None
and score >= RAG_AUTO_THRESHOLD
and len(auto_inject) < RAG_AUTO_LIMIT
and n.id not in excluded_inject_set
):
auto_inject.append((score, n))
else:
sidebar_only.append((score, n))
# Inject high-scoring notes into system prompt
if auto_inject:
snippets = []
for score, n in auto_inject:
body_snippet = (n.body or "")[:RAG_AUTO_SNIPPET]
snippets.append(f"**{n.title}** (relevance: {round(score * 100)}%)\n{body_snippet}")
context_meta["auto_injected_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2),
})
system_parts.append(
"\n\n--- Relevant Notes ---\n"
+ "\n\n".join(snippets)
+ "\n--- End Relevant Notes ---"
)
# Populate sidebar candidates (auto-injected notes also appear here for reference,
# but sidebar_only are the ones not yet in the prompt)
for score, n in auto_inject:
context_meta["auto_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2) if score is not None else None,
"auto_injected": True,
})
for score, n in sidebar_only:
context_meta["auto_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2) if score is not None else None,
"auto_injected": False,
})
context_meta["auto_note_ids"] = [n.id for _, n in found_scored]
+145
View File
@@ -0,0 +1,145 @@
"""Milestone management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.note import Note
logger = logging.getLogger(__name__)
async def create_milestone(
user_id: int,
project_id: int,
title: str,
description: str | None = None,
order_index: int = 0,
) -> Milestone:
async with async_session() as session:
milestone = Milestone(
user_id=user_id,
project_id=project_id,
title=title,
description=description,
status="active",
order_index=order_index,
)
session.add(milestone)
await session.commit()
await session.refresh(milestone)
return milestone
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
return result.scalars().first()
async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
func.lower(Milestone.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone:
milestone = await get_milestone_by_title(user_id, project_id, title)
if milestone:
return milestone
return await create_milestone(user_id, project_id, title=title)
async def list_milestones(
user_id: int, project_id: int, status: str | None = None
) -> list[Milestone]:
async with async_session() as session:
query = select(Milestone).where(
Milestone.user_id == user_id,
Milestone.project_id == project_id,
)
if status:
query = query.where(Milestone.status == status)
query = query.order_by(Milestone.order_index.asc(), Milestone.created_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> Milestone | None:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return None
for key, value in fields.items():
if hasattr(milestone, key):
setattr(milestone, key, value)
milestone.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(milestone)
return milestone
async def delete_milestone(user_id: int, milestone_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
milestone = result.scalars().first()
if milestone is None:
return False
await session.delete(milestone)
await session.commit()
return True
async def get_milestone_progress(milestone_id: int) -> dict:
"""Return task completion stats for a milestone."""
async with async_session() as session:
rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(Note.milestone_id == milestone_id, Note.status.isnot(None))
.group_by(Note.status)
)
status_counts: dict[str, int] = {}
for status, count in rows.fetchall():
status_counts[status] = count
total = sum(status_counts.values())
completed = status_counts.get("done", 0)
pct = round(completed / total * 100, 1) if total > 0 else 0.0
return {
"total": total,
"completed": completed,
"pct": pct,
"status_counts": {
"todo": status_counts.get("todo", 0),
"in_progress": status_counts.get("in_progress", 0),
"done": status_counts.get("done", 0),
},
}
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Return ordered list of milestones with their progress stats."""
milestones = await list_milestones(user_id, project_id)
result = []
for m in milestones:
progress = await get_milestone_progress(m.id)
entry = m.to_dict()
entry.update(progress)
result.append(entry)
return result
+29 -1
View File
@@ -9,12 +9,26 @@ from fabledassistant.models.note import Note, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
def _normalize_tags(tags: list[str]) -> list[str]:
"""Lowercase, strip, deduplicate, and drop empty tags."""
seen: set[str] = set()
out: list[str] = []
for t in tags:
normalized = t.strip().lower()
if normalized and normalized not in seen:
seen.add(normalized)
out.append(normalized)
return out
async def create_note(
user_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
parent_id: int | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
status: str | None = None,
priority: str | None = None,
due_date: date | None = None,
@@ -24,8 +38,10 @@ async def create_note(
user_id=user_id,
title=title,
body=body,
tags=tags or [],
tags=_normalize_tags(tags or []),
parent_id=parent_id,
project_id=project_id,
milestone_id=milestone_id,
status=status,
priority=priority,
due_date=due_date,
@@ -53,6 +69,8 @@ async def list_notes(
priority: str | None = None,
due_before: date | None = None,
due_after: date | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
@@ -105,6 +123,14 @@ async def list_notes(
query = query.where(Note.due_date >= due_after)
count_query = count_query.where(Note.due_date >= due_after)
if project_id is not None:
query = query.where(Note.project_id == project_id)
count_query = count_query.where(Note.project_id == project_id)
if milestone_id is not None:
query = query.where(Note.milestone_id == milestone_id)
count_query = count_query.where(Note.milestone_id == milestone_id)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
query = query.order_by(sort_col.asc())
@@ -153,6 +179,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
value = TaskStatus(value).value
elif key == "priority" and isinstance(value, str):
value = TaskPriority(value).value
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)
note.updated_at = datetime.now(timezone.utc)
await session.commit()
+146
View File
@@ -0,0 +1,146 @@
"""Project management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
logger = logging.getLogger(__name__)
async def create_project(
user_id: int,
title: str,
description: str = "",
goal: str = "",
color: str | None = None,
) -> Project:
async with async_session() as session:
project = Project(
user_id=user_id,
title=title,
description=description,
goal=goal,
color=color,
status="active",
)
session.add(project)
await session.commit()
await session.refresh(project)
return project
async def get_project(user_id: int, project_id: int) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
return result.scalars().first()
async def get_project_by_title(user_id: int, title: str) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(
Project.user_id == user_id,
func.lower(Project.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def get_or_create_project(user_id: int, title: str) -> Project:
project = await get_project_by_title(user_id, title)
if project:
return project
return await create_project(user_id, title=title)
async def list_projects(user_id: int, status: str | None = None) -> list[Project]:
async with async_session() as session:
query = select(Project).where(Project.user_id == user_id)
if status:
query = query.where(Project.status == status)
query = query.order_by(Project.updated_at.desc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalars().first()
if project is None:
return None
for key, value in fields.items():
if hasattr(project, key):
setattr(project, key, value)
project.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(project)
return project
async def delete_project(user_id: int, project_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalars().first()
if project is None:
return False
# Unlink notes (cascade handled by DB ON DELETE SET NULL, but we delete project here)
await session.delete(project)
await session.commit()
return True
async def get_project_summary(user_id: int, project_id: int) -> dict:
"""Return task counts by status, note count, and last activity."""
async with async_session() as session:
# Task counts by status
task_rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.isnot(None),
)
.group_by(Note.status)
)
task_counts: dict[str, int] = {}
for status, count in task_rows.fetchall():
task_counts[status] = count
# Note count (non-tasks)
note_count_result = await session.scalar(
select(func.count(Note.id)).where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.is_(None),
)
)
note_count = note_count_result or 0
# Last activity
last_activity_result = await session.scalar(
select(func.max(Note.updated_at)).where(
Note.user_id == user_id,
Note.project_id == project_id,
)
)
from fabledassistant.services.milestones import get_project_milestone_summary
milestone_summary = await get_project_milestone_summary(user_id, project_id)
return {
"task_counts": task_counts,
"note_count": note_count,
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
"milestone_summary": milestone_summary,
}
+149
View File
@@ -0,0 +1,149 @@
"""Browser push notification service using VAPID/pywebpush."""
import asyncio
import json
import logging
from datetime import datetime, timezone
from functools import lru_cache
from sqlalchemy import delete, select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.push_subscription import PushSubscription
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def _get_webpush():
"""Lazy import to avoid startup errors if pywebpush is not installed."""
try:
from pywebpush import WebPusher
return WebPusher
except ImportError:
return None
def vapid_enabled() -> bool:
return bool(Config.VAPID_PRIVATE_KEY and Config.VAPID_PUBLIC_KEY)
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
"""Upsert a push subscription by endpoint."""
endpoint = subscription_json.get("endpoint", "")
keys = subscription_json.get("keys", {})
p256dh = keys.get("p256dh", "")
auth = keys.get("auth", "")
async with async_session() as session:
result = await session.execute(
select(PushSubscription).where(
PushSubscription.user_id == user_id,
PushSubscription.endpoint == endpoint,
)
)
existing = result.scalars().first()
if existing:
existing.p256dh = p256dh
existing.auth = auth
existing.user_agent = user_agent
existing.last_used = datetime.now(timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
else:
sub = PushSubscription(
user_id=user_id,
endpoint=endpoint,
p256dh=p256dh,
auth=auth,
user_agent=user_agent,
)
session.add(sub)
await session.commit()
await session.refresh(sub)
return sub
async def delete_subscription(user_id: int, endpoint: str) -> None:
async with async_session() as session:
await session.execute(
delete(PushSubscription).where(
PushSubscription.user_id == user_id,
PushSubscription.endpoint == endpoint,
)
)
await session.commit()
async def _remove_expired_subscription(user_id: int, endpoint: str) -> None:
"""Remove a subscription that returned 410 Gone."""
try:
await delete_subscription(user_id, endpoint)
logger.info("Removed expired push subscription for user %d", user_id)
except Exception:
logger.warning("Failed to remove expired subscription", exc_info=True)
async def send_push_notification(
user_id: int,
title: str,
body: str,
url: str = "/",
) -> None:
"""Send a push notification to all subscriptions for a user.
Fire-and-forget — wrap in asyncio.create_task() at call site.
"""
if not vapid_enabled():
logger.debug("VAPID not configured, skipping push notification")
return
WebPusher = _get_webpush()
if WebPusher is None:
logger.warning("pywebpush not installed, cannot send push notifications")
return
async with async_session() as session:
result = await session.execute(
select(PushSubscription).where(PushSubscription.user_id == user_id)
)
subscriptions = list(result.scalars().all())
if not subscriptions:
return
payload = json.dumps({"title": title, "body": body, "url": url})
vapid_claims = {
"sub": Config.VAPID_CLAIMS_SUB,
}
def _send_sync(sub: PushSubscription) -> tuple[int, str]:
"""Synchronous send — runs in executor."""
try:
subscription_info = {
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
}
response = WebPusher(subscription_info).send(
data=payload,
vapid_private_key=Config.VAPID_PRIVATE_KEY,
vapid_claims=vapid_claims,
)
return response.status_code, sub.endpoint
except Exception as e:
logger.warning("Push send failed for sub %d: %s", sub.id, e)
return 0, sub.endpoint
loop = asyncio.get_event_loop()
tasks = [loop.run_in_executor(None, _send_sync, sub) for sub in subscriptions]
results = await asyncio.gather(*tasks, return_exceptions=True)
for sub, result in zip(subscriptions, results):
if isinstance(result, Exception):
continue
status_code, endpoint = result
if status_code == 410:
asyncio.create_task(_remove_expired_subscription(user_id, endpoint))
elif status_code and status_code not in (200, 201):
logger.warning("Push returned status %d for user %d", status_code, user_id)
+276
View File
@@ -55,6 +55,18 @@ _CORE_TOOLS = [
"enum": ["none", "low", "medium", "high"],
"description": "Task priority level",
},
"project": {
"type": "string",
"description": "Optional project name to assign this task to",
},
"parent_task": {
"type": "string",
"description": "Optional title of a parent task to make this a sub-task of",
},
"milestone": {
"type": "string",
"description": "Optional milestone title within the project to assign this task to",
},
},
"required": ["title"],
},
@@ -81,6 +93,10 @@ _CORE_TOOLS = [
"items": {"type": "string"},
"description": "Tags for the note (without # prefix, hyphens for multi-word: [\"science-fiction\", \"story/idea\"]). Do NOT embed #tags in the note body.",
},
"project": {
"type": "string",
"description": "Optional project name to assign this note to",
},
},
"required": ["title"],
},
@@ -143,6 +159,10 @@ _CORE_TOOLS = [
"enum": ["replace", "add", "remove"],
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
},
"project": {
"type": "string",
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
},
},
"required": ["query"],
},
@@ -294,6 +314,14 @@ _CORE_TOOLS = [
"type": "string",
"description": "Return tasks due on or after this date (YYYY-MM-DD)",
},
"project": {
"type": "string",
"description": "Filter tasks by project name",
},
"milestone": {
"type": "string",
"description": "Filter tasks by milestone name within a project",
},
"limit": {
"type": "integer",
"description": "Maximum number of tasks to return (default 10)",
@@ -302,6 +330,100 @@ _CORE_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "create_project",
"description": "Create a new project. Use when the user asks to create a project, initiative, or campaign to organize related tasks and notes.",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Project title"},
"description": {"type": "string", "description": "Brief description"},
"goal": {"type": "string", "description": "The goal or desired outcome"},
},
"required": ["title"],
},
},
},
{
"type": "function",
"function": {
"name": "list_projects",
"description": "List the user's projects. Use when asked about projects, initiatives, or campaigns.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["active", "completed", "archived"],
"description": "Filter by status (omit for all)",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "get_project",
"description": "Get details and summary of a specific project.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Project name or keyword to find it"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "update_project",
"description": "Update a project's status, description, or goal.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Project name to find"},
"status": {"type": "string", "enum": ["active", "completed", "archived"]},
"description": {"type": "string"},
"goal": {"type": "string"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "create_milestone",
"description": "Create a new milestone inside a project for grouping and tracking related tasks.",
"parameters": {
"type": "object",
"properties": {
"project": {"type": "string", "description": "Project title (will be created if not found)"},
"title": {"type": "string", "description": "Milestone title"},
"description": {"type": "string", "description": "Optional description"},
},
"required": ["project", "title"],
},
},
},
{
"type": "function",
"function": {
"name": "list_milestones",
"description": "List milestones for a project with their progress percentages.",
"parameters": {
"type": "object",
"properties": {
"project": {"type": "string", "description": "Project name to look up"},
},
"required": ["project"],
},
},
},
]
# CalDAV tools — only included when user has CalDAV configured
@@ -604,6 +726,27 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
)
suggested = await suggest_tags(user_id, task_title, task_body)
_schedule_embedding(note.id, user_id, task_title, task_body)
# Handle optional project and parent_task assignment
project_name = arguments.get("project")
parent_task_name = arguments.get("parent_task")
update_fields: dict = {}
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
update_fields["project_id"] = proj.id
if parent_task_name:
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
if parent_notes:
update_fields["parent_id"] = parent_notes[0].id
milestone_name = arguments.get("milestone")
if milestone_name and project_name:
from fabledassistant.services.projects import get_or_create_project as _gocp
from fabledassistant.services.milestones import get_or_create_milestone
proj = await _gocp(user_id, project_name)
ms = await get_or_create_milestone(user_id, proj.id, milestone_name)
update_fields["milestone_id"] = ms.id
if update_fields:
note = await update_note(user_id, note.id, **update_fields)
return {
"success": True,
"type": "task",
@@ -613,6 +756,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"status": note.status,
"priority": note.priority,
"due_date": str(note.due_date) if note.due_date else None,
"project_id": note.project_id,
},
"suggested_tags": suggested,
}
@@ -635,12 +779,19 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
)
suggested = await suggest_tags(user_id, note_title, note_body)
_schedule_embedding(note.id, user_id, note_title, note_body)
# Handle optional project assignment
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
note = await update_note(user_id, note.id, project_id=proj.id)
return {
"success": True,
"type": "note",
"data": {
"id": note.id,
"title": note.title,
"project_id": note.project_id,
},
"suggested_tags": suggested,
}
@@ -693,6 +844,15 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
else:
update_fields["tags"] = new_tags
if "project" in arguments:
project_name = arguments["project"]
if project_name:
from fabledassistant.services.projects import get_or_create_project
proj = await get_or_create_project(user_id, project_name)
update_fields["project_id"] = proj.id
else:
update_fields["project_id"] = None
updated = await update_note(user_id, note.id, **update_fields)
if updated is None:
return {"success": False, "error": "Failed to update note."}
@@ -710,6 +870,25 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
}
elif tool_name == "list_tasks":
project_id = None
milestone_id = None
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_project_by_title, list_projects as _lp
proj = await get_project_by_title(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
if matches:
proj = matches[0]
if proj:
project_id = proj.id
milestone_name = arguments.get("milestone")
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title
ms = await get_milestone_by_title(user_id, proj.id, milestone_name)
if ms:
milestone_id = ms.id
notes, total = await list_notes(
user_id=user_id,
is_task=True,
@@ -717,6 +896,8 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
priority=arguments.get("priority"),
due_before=_parse_due_date(arguments.get("due_before")),
due_after=_parse_due_date(arguments.get("due_after")),
project_id=project_id,
milestone_id=milestone_id,
limit=int(arguments.get("limit", 10)),
sort="due_date",
order="asc",
@@ -729,6 +910,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"status": n.status,
"priority": n.priority,
"due_date": str(n.due_date) if n.due_date else None,
"project_id": n.project_id,
"preview": (n.body[:120] + "...") if n.body and len(n.body) > 120 else (n.body or ""),
})
return {
@@ -950,6 +1132,39 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"data": {"id": note.id, "title": note.title},
}
elif tool_name == "create_milestone":
from fabledassistant.services.projects import get_or_create_project as _gocp
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
project_name = arguments.get("project", "")
if not project_name:
return {"success": False, "error": "project is required"}
proj = await _gocp(user_id, project_name)
ms = await _cm(
user_id,
proj.id,
title=arguments.get("title", "Untitled Milestone"),
description=arguments.get("description"),
)
return {"success": True, "type": "milestone", "data": ms.to_dict()}
elif tool_name == "list_milestones":
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
from fabledassistant.services.milestones import get_project_milestone_summary
project_name = arguments.get("project", "")
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"No project found matching '{project_name}'"}
summary = await get_project_milestone_summary(user_id, proj.id)
return {
"success": True,
"type": "milestones_list",
"data": {"project": proj.title, "count": len(summary), "milestones": summary},
}
elif tool_name == "search_web":
from fabledassistant.services.research import _search_searxng
query = arguments.get("query", "")
@@ -976,6 +1191,67 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"data": {"topic": topic},
}
elif tool_name == "create_project":
from fabledassistant.services.projects import create_project as _create_project
project = await _create_project(
user_id,
title=arguments.get("title", "New Project"),
description=arguments.get("description", ""),
goal=arguments.get("goal", ""),
)
return {"success": True, "type": "project", "data": project.to_dict()}
elif tool_name == "list_projects":
from fabledassistant.services.projects import list_projects as _list_projects
projects = await _list_projects(user_id, status=arguments.get("status"))
return {
"success": True,
"type": "projects_list",
"data": {"count": len(projects), "projects": [p.to_dict() for p in projects]},
}
elif tool_name == "get_project":
from fabledassistant.services.projects import (
get_project_by_title as _gpbt,
get_project_summary as _gps,
list_projects as _lp,
)
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
summary = await _gps(user_id, project.id)
data = project.to_dict()
data["summary"] = summary
return {"success": True, "type": "project_detail", "data": data}
elif tool_name == "update_project":
from fabledassistant.services.projects import (
get_project_by_title as _gpbt,
update_project as _up,
list_projects as _lp,
)
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
fields = {}
for k in ("status", "description", "goal"):
if k in arguments:
fields[k] = arguments[k]
updated = await _up(user_id, project.id, **fields)
return {"success": True, "type": "project_updated", "data": updated.to_dict()}
elif tool_name == "search_images":
from fabledassistant.services.images import fetch_and_store_image
from fabledassistant.services.research import _search_searxng_images
+124 -2
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-03-01Quick-capture overhaul (dedicated classifier, research + update_note support); CalDAV todo tools removed; run_research_pipeline buf optional
2026-03-02Projects + Milestones hierarchy; RAG auto-injection; summarization improvements; browser push notifications; PWA manifest; Radicale CalDAV reverted to external; Flutter Android companion app updated
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -757,6 +757,47 @@ When adding a new migration, follow these conventions:
pills in ToolCallCard with one-click apply via append-tag API.
LLM instructed to use the `tags` parameter, not embed `#tag` text in note body.
### Projects & Milestones (Phases A + G)
- **Project model** (`models/project.py`): id, user_id (FK CASCADE), title, description, goal, status ("active"/"completed"/"archived"), color, timestamps.
- **Milestone model** (`models/milestone.py`): id, user_id, project_id (FK CASCADE), title, description, status, order_index, timestamps. Hierarchy: **Project → Milestone → Task**.
- **`notes` table**: `project_id` FK (SET NULL) + `milestone_id` FK (SET NULL) columns. `parent_id` column now has a FK constraint to `notes.id` (SET NULL) to enable sub-tasks.
- **Migrations**: `0017_add_projects.py`, `0020_add_milestones.py` (milestone_id on notes).
- **Services**: `services/projects.py` (CRUD + `get_or_create_project` + `get_project_summary`); `services/milestones.py` (CRUD + `get_milestone_progress` + `get_project_milestone_summary`).
- **Routes**: `routes/projects.py` under `/api/projects`; `routes/milestones.py` under `/api/projects/<pid>/milestones`.
- **LLM tools**: `create_project`, `list_projects`, `get_project`, `update_project`; `create_milestone`, `list_milestones`; `create_note`/`create_task`/`update_note` accept optional `project` + `milestone` + `parent_task` params.
- **Frontend**: `ProjectListView.vue` (card grid, stacked milestone progress bars per card), `ProjectView.vue` (milestone-grouped kanban + inline milestone management), `ProjectSelector.vue` (dropdown used in NoteEditorView + TaskEditorView), `MilestoneSelector.vue` (used in TaskEditorView).
- **Types**: `frontend/src/types/note.ts` Note interface now includes `project_id: number | null` and `milestone_id: number | null`.
- `app.py` registers `projects_bp` and `milestones_bp`; router adds `/projects` and `/projects/:id`.
### RAG Auto-injection (Phase B)
- Notes scoring ≥0.60 cosine similarity are injected automatically into every chat system prompt (max 3, up to 800 chars each) under `--- Relevant Notes ---` heading.
- `semantic_search_notes()` accepts `threshold` param (default 0.45 for sidebar suggestions; 0.60 for auto-inject).
- `build_context()` constants: `RAG_AUTO_THRESHOLD=0.60`, `RAG_AUTO_LIMIT=3`, `RAG_AUTO_SNIPPET=800`.
- `context_meta` adds `auto_injected_notes` list + `auto_injected: bool` flag on each `auto_notes` item.
- `excluded_note_ids` param in `build_context()` + POST body lets users remove a note from auto-injection.
- ChatView sidebar: "Auto-included" section (green border) above "Suggested" and "In Context".
### Summarization Improvements (Phase C)
- `_HISTORY_SUMMARY_THRESHOLD` raised 20 → 30; `_HISTORY_KEEP_RECENT` raised 6 → 8; summary `max_tokens` raised 200 → 400.
- Improved summary prompt captures: note/task/project names, decisions, open questions, topic arc.
- Two-pass summarization for histories >50 messages (first half → summary_a, combined → final).
### Browser Push Notifications (Phase E)
- **`models/push_subscription.py`**: PushSubscription — endpoint, p256dh, auth, user_agent; UNIQUE(user_id, endpoint).
- **Migration**: `0018_add_push_subscriptions.py` (down_revision="0017").
- **`services/push.py`**: `vapid_enabled()`, `save_subscription()`, `delete_subscription()`, `send_push_notification()` (lazy-imports pywebpush, fire-and-forget, removes 410 Gone subs).
- **`routes/push.py`**: `GET /api/push/vapid-public-key`, `POST/DELETE /api/push/subscribe`.
- Config: `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CLAIMS_SUB` env vars.
- `generation_task.py`: fires push after `buf.state = COMPLETED` (guarded by `vapid_enabled()`).
- `pywebpush>=2.0` added to `pyproject.toml`.
- **Frontend**: `frontend/public/sw.js` (push + notificationclick handlers); `frontend/src/stores/push.ts` (isSupported, permission, isSubscribed, subscribe/unsubscribe); SettingsView "Push Notifications" toggle.
### PWA Manifest (Phase F)
- `frontend/public/manifest.json`: name, short_name, start_url, display: standalone, icons.
- `index.html`: `<link rel="manifest">`, `<meta name="theme-color">`, Apple mobile meta tags.
- `main.ts`: service worker registration.
- Installable on iOS (Share → Add to Home Screen) and Android. Push works on Android (iOS 16.4+ partial).
### Authentication & User Management
- Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming
- Per-user data isolation across all resources
@@ -813,7 +854,7 @@ When adding a new migration, follow these conventions:
- Security headers applied in `after_request`: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Content-Security-Policy`
- In-memory sliding-window rate limiter on all auth endpoints (login, register, forgot/reset password); proxy-aware client IP with `TRUST_PROXY_HEADERS`
- SQLAlchemy async engine configured with `pool_pre_ping=True` (tests connections before use, discards stale ones after Postgres restart) and `pool_recycle=1800` (recycles idle connections every 30 min to prevent TCP/firewall staleness)
- Alembic migrations: 15 migrations, all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
- Alembic migrations: 20 migrations (00010020), all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
- Config from env vars + Docker secrets file support (`_read_secret`)
## Development Workflow
@@ -824,6 +865,83 @@ When adding a new migration, follow these conventions:
Quart serves them
- To reset database: `docker compose down -v && docker compose up --build`
## Android Companion App
A native Flutter app at `/home/bvandeusen/Nextcloud/Projects/fabled_app` (separate repository).
### Stack
- Flutter + Dart, Riverpod (state), GoRouter (navigation), Dio (HTTP), PersistCookieJar (session)
- SSE streaming for chat using `fetch` + `ReadableStream` bridge
- Self-update support via Forgejo release API (`update_provider.dart`)
### Architecture
- **Data layer**: `lib/data/models/`, `lib/data/api/`, `lib/data/repositories/`
- **State**: Riverpod `AsyncNotifierProvider` per resource in `lib/providers/`
- **Navigation**: GoRouter with `ShellRoute` for tabbed main shell; `_RouterNotifier` refreshes on auth change
### Models & API Coverage
| Resource | Model fields | API endpoints |
|----------|-------------|---------------|
| Note | id, title, body, tags, project_id, milestone_id, created_at, updated_at | GET/POST /api/notes, GET/PUT/DELETE /api/notes/:id |
| Task | id, title, body, status, priority, due_date, project_id, milestone_id, parent_id, created_at, updated_at | GET/POST /api/tasks, GET/PUT/DELETE /api/tasks/:id |
| Project | id, title, description, goal, status, color, created_at, updated_at | GET/POST /api/projects, GET/PATCH/DELETE /api/projects/:id |
| Conversation | standard chat fields | GET/POST conversations, SSE stream |
| QuickCapture | text → type+title+message | POST /api/quick-capture |
### Navigation Shell (4 tabs)
Notes · Tasks · Projects · Chat — bottom `NavigationBar` on phone; `NavigationRail` on tablet/landscape.
Quick Capture bar at the top of the shell (persists across all tabs); Settings accessible from top-right icon.
### Feature Status
| Feature | Status | Notes |
|---------|--------|-------|
| Notes CRUD | ✅ | tags chip input; project selector in editor |
| Tasks CRUD | ✅ | project selector in editor |
| Projects list | ✅ | active/archived sections; long-press status change; create dialog |
| Chat + SSE | ✅ | full streaming |
| Quick Capture | ✅ | offline queue with retry |
| Tags | ✅ (model + API) | Chip input in NoteEditScreen; typed as `List<String>` |
| Project assignment | ✅ | `ProjectSelector` dropdown in Note + Task editors |
| Milestones | ❌ deferred | Too granular for mobile; web UI handles it |
| Push notifications | ❌ incompatible | Backend uses browser VAPID; Flutter needs FCM/APNs — separate implementation required |
| CalDAV settings | ❌ intentional | Server-side config only; not exposed in mobile app |
### Key Files
```
lib/
app.dart # GoRouter + _Shell + _QuickCaptureBar
core/constants.dart # Routes.*
data/
models/note.dart # Note (tags, project_id, milestone_id)
models/task.dart # Task (project_id, milestone_id, parent_id)
models/project.dart # Project
api/notes_api.dart # create/update accept tags, project_id
api/tasks_api.dart # create accepts project_id
api/projects_api.dart # full CRUD
repositories/notes_repository.dart
repositories/tasks_repository.dart
repositories/projects_repository.dart
providers/
api_client_provider.dart # all API + repository providers
notes_provider.dart # NotesNotifier
tasks_provider.dart # TasksNotifier
projects_provider.dart # ProjectsNotifier
screens/
notes/note_edit_screen.dart # chip tag input + ProjectSelector
tasks/task_edit_screen.dart # ProjectSelector
projects/project_list_screen.dart
widgets/
project_selector.dart # reusable DropdownButtonFormField
```
### API Compatibility Notes
- `GET /api/projects/:id` returns a flat JSON object (not `{project: ...}` wrapper); includes `summary` field.
- `POST /api/projects` returns project dict directly (201).
- `PATCH /api/projects/:id` returns updated project dict.
- Task body field name is `body` (not `description`) — Flutter maps `description` → `body` on serialize.
## Backlog
- Calendar/timeline view for tasks
- Import/export (Markdown files, JSON)
@@ -833,3 +951,7 @@ When adding a new migration, follow these conventions:
notes via wikilinks (`[[Title]]`) and shared tags. Nodes = notes/tasks; edges = wikilink references or
tag co-occurrence. Clicking a node navigates to that note. Filter by tag or depth. Useful for discovering
clusters of related notes and orphaned notes with no connections.
- **Flutter push notifications:** Requires FCM/APNs integration (separate from web VAPID). Would need
a FCM server key in Config and a new notification pathway in `generation_task.py`.
- **Flutter milestone support:** Milestone grouping in project view deferred; could add as a filtered
task list view once the feature is well-established on web.