diff --git a/alembic/versions/0012_add_invitation_tokens.py b/alembic/versions/0012_add_invitation_tokens.py new file mode 100644 index 0000000..5fcf9f9 --- /dev/null +++ b/alembic/versions/0012_add_invitation_tokens.py @@ -0,0 +1,26 @@ +"""Add invitation_tokens table.""" + +from alembic import op + +revision = "0012" +down_revision = "0011" + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE invitation_tokens ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + invited_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX ix_invitation_tokens_token_hash ON invitation_tokens (token_hash)") + op.execute("CREATE INDEX ix_invitation_tokens_email ON invitation_tokens (email)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS invitation_tokens") diff --git a/frontend/src/components/TableOfContents.vue b/frontend/src/components/TableOfContents.vue new file mode 100644 index 0000000..8981dda --- /dev/null +++ b/frontend/src/components/TableOfContents.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e76f7b8..e2ac599 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -34,6 +34,12 @@ const router = createRouter({ component: () => import("@/views/ResetPasswordView.vue"), meta: { public: true }, }, + { + path: "/register-invite", + name: "register-invite", + component: () => import("@/views/RegisterInviteView.vue"), + meta: { public: true }, + }, { path: "/notes", name: "notes", diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index 1a23619..8cc174e 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -8,8 +8,37 @@ function decodeEntities(text: string): string { return textarea.value; } +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/<[^>]+>/g, "") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-"); +} + +export function stripFirstLineTags(text: string): string { + const match = text.match(/^[ \t]*((?:(? /^#[\w]+(?:\/[\w]+)*$/.test(t)); + if (!allTags) return text; + return text.slice(match[0].length); +} + +const headingRenderer = { + heading({ text, depth }: { text: string; depth: number }): string { + const id = slugify(text); + return `${text}`; + }, +}; + +marked.use({ renderer: headingRenderer }); + export function renderMarkdown(text: string): string { - const decoded = decodeEntities(text); + const stripped = stripFirstLineTags(text); + const decoded = decodeEntities(stripped); const html = marked(decoded) as string; const withTags = linkifyTags(html); const withLinks = linkifyWikilinks(withTags); @@ -21,7 +50,8 @@ export function renderMarkdown(text: string): string { } export function renderPreview(text: string): string { - const decoded = decodeEntities(text); + const stripped = stripFirstLineTags(text); + const decoded = decodeEntities(stripped); const html = marked(decoded) as string; const withTags = linkifyTags(html); const withLinks = linkifyWikilinks(withTags); diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 19e64fe..7fbd06b 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -13,47 +13,96 @@ import { useChatStore } from "@/stores/chat"; const router = useRouter(); const recentNotes = ref([]); -const recentTasks = ref([]); +const overdueTasks = ref([]); +const dueTodayTasks = ref([]); +const inProgressTasks = ref([]); const recentChats = ref([]); const loading = ref(true); const tasksStore = useTasksStore(); +function todayStr(): string { + const d = new Date(); + return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); +} + +function tomorrowStr(): string { + const d = new Date(); + d.setDate(d.getDate() + 1); + return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); +} + onMounted(async () => { - try { - const notesData = await apiGet( - "/api/notes?sort=updated_at&order=desc&limit=5" - ); - recentNotes.value = notesData.notes; - } catch (e) { - console.error("Failed to load recent notes:", e); + const today = todayStr(); + const tomorrow = tomorrowStr(); + + const [notesRes, overdueRes, dueTodayRes, inProgressRes, chatsRes] = + await Promise.allSettled([ + apiGet("/api/notes?sort=updated_at&order=desc&limit=5"), + apiGet( + `/api/tasks?due_before=${today}&sort=due_date&order=asc&limit=15` + ), + apiGet( + `/api/tasks?due_after=${today}&due_before=${tomorrow}&sort=priority&order=desc&limit=10` + ), + apiGet( + `/api/tasks?status=in_progress&sort=updated_at&order=desc&limit=5` + ), + apiGet<{ conversations: Conversation[]; total: number }>( + "/api/chat/conversations?limit=3&offset=0" + ), + ]); + + if (notesRes.status === "fulfilled") { + recentNotes.value = notesRes.value.notes; + } + if (chatsRes.status === "fulfilled") { + recentChats.value = chatsRes.value.conversations; } - try { - const tasksData = await apiGet( - "/api/tasks?sort=updated_at&order=desc&limit=5" + // Filter out done tasks from overdue and due-today + if (overdueRes.status === "fulfilled") { + overdueTasks.value = overdueRes.value.tasks.filter( + (t) => t.status !== "done" + ); + } + if (dueTodayRes.status === "fulfilled") { + dueTodayTasks.value = dueTodayRes.value.tasks.filter( + (t) => t.status !== "done" ); - recentTasks.value = tasksData.tasks; - } catch (e) { - console.error("Failed to load recent tasks:", e); } - try { - const chatData = await apiGet<{ conversations: Conversation[]; total: number }>( - "/api/chat/conversations?limit=3&offset=0" + // Deduplicate in-progress against overdue and due-today + if (inProgressRes.status === "fulfilled") { + const seen = new Set(); + for (const t of overdueTasks.value) seen.add(t.id); + for (const t of dueTodayTasks.value) seen.add(t.id); + inProgressTasks.value = inProgressRes.value.tasks.filter( + (t) => !seen.has(t.id) ); - recentChats.value = chatData.conversations; - } catch (e) { - console.error("Failed to load recent chats:", e); } loading.value = false; }); +function removeFromAllLists(id: number) { + overdueTasks.value = overdueTasks.value.filter((t) => t.id !== id); + dueTodayTasks.value = dueTodayTasks.value.filter((t) => t.id !== id); + inProgressTasks.value = inProgressTasks.value.filter((t) => t.id !== id); +} + function onStatusToggle(id: number, status: TaskStatus) { tasksStore.patchStatus(id, status).then((updated) => { - const idx = recentTasks.value.findIndex((t) => t.id === updated.id); - if (idx !== -1) { - recentTasks.value[idx] = updated; + if (updated.status === "done") { + removeFromAllLists(updated.id); + } else { + // Update in whichever list contains it + for (const list of [overdueTasks, dueTodayTasks, inProgressTasks]) { + const idx = list.value.findIndex((t) => t.id === updated.id); + if (idx !== -1) { + list.value[idx] = updated; + break; + } + } } }); } @@ -73,6 +122,51 @@ async function newChat() {

Loading...

@@ -150,6 +225,10 @@ async function newChat() { .section { margin-bottom: 2rem; } +.section-overdue { + border-left: 3px solid var(--color-overdue, #e74c3c); + padding-left: 0.75rem; +} .section-header { display: flex; justify-content: space-between; diff --git a/frontend/src/views/NoteViewerView.vue b/frontend/src/views/NoteViewerView.vue index 803bf97..b642578 100644 --- a/frontend/src/views/NoteViewerView.vue +++ b/frontend/src/views/NoteViewerView.vue @@ -7,6 +7,7 @@ import { relativeTime } from "@/composables/useRelativeTime"; import { apiPost } from "@/api/client"; import type { Note } from "@/types/note"; import TagPill from "@/components/TagPill.vue"; +import TableOfContents from "@/components/TableOfContents.vue"; const route = useRoute(); const router = useRouter(); @@ -100,72 +101,95 @@ async function convertToTask() { diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index eb88391..7d52a48 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -46,88 +46,110 @@ const sendingTest = ref(false); const MODEL_CATALOG: ModelInfo[] = [ // — General Purpose — + { + name: "llama3.2", + description: "Meta's Llama 3.2 — lightweight models (1B/3B) with 128K context. Great for low-resource setups.", + size: "2.0 GB", + bestFor: "Light tasks, fast responses, low RAM", + category: "General Purpose", + }, { name: "llama3.1", - description: "Meta's Llama 3.1 8B — strong general-purpose model with good instruction following.", - size: "4.7 GB", + description: "Meta's Llama 3.1 8B — strong general-purpose model with good instruction following. 128K context.", + size: "4.9 GB", bestFor: "General chat, writing, Q&A", category: "General Purpose", }, { - name: "llama3.1:70b", - description: "Meta's Llama 3.1 70B — significantly more capable, better reasoning and nuance.", - size: "40 GB", + name: "llama3.3", + description: "Meta's Llama 3.3 70B — 405B-level performance in a 70B model. 128K context. Top tier open model.", + size: "43 GB", bestFor: "Complex reasoning, detailed analysis", category: "General Purpose", }, { - name: "mistral", - description: "Mistral 7B — fast and efficient with strong performance for its size.", - size: "4.1 GB", - bestFor: "Fast responses, general tasks", + name: "gemma3", + description: "Google's Gemma 3 — multimodal (vision + text), 1B to 27B. 128K context, 140+ languages.", + size: "5.2 GB", + bestFor: "Multilingual, vision, general tasks", + category: "General Purpose", + }, + { + name: "qwen3", + description: "Alibaba's Qwen 3 — latest generation, 0.6B to 235B. Up to 256K context. Hybrid thinking modes.", + size: "4.7 GB", + bestFor: "Multilingual, reasoning, code, math", category: "General Purpose", }, { name: "qwen2.5", - description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills.", + description: "Alibaba's Qwen 2.5 7B — multilingual model with strong coding and math skills. 32K context.", size: "4.7 GB", bestFor: "Multilingual, code, math", category: "General Purpose", }, { - name: "phi3", - description: "Microsoft Phi-3 Mini — compact model with surprising capability for its size.", - size: "2.3 GB", - bestFor: "Light tasks, low resource usage", - category: "General Purpose", - }, - { - name: "neural-chat", - description: "Intel's fine-tune optimized for natural conversation. Lighter filtering than base models.", + name: "mistral", + description: "Mistral 7B v0.3 — fast and efficient with function calling support. 32K context.", size: "4.1 GB", - bestFor: "Natural conversation, general tasks", + bestFor: "Fast responses, general tasks", category: "General Purpose", }, { - name: "yi", - description: "01.AI's Yi 6B — Chinese-developed model, more permissive on creative content.", - size: "3.5 GB", - bestFor: "Creative content, multilingual", + name: "phi4", + description: "Microsoft Phi-4 14B — strong reasoning and math for its size. Successor to Phi-3.", + size: "9.1 GB", + bestFor: "Reasoning, math, structured tasks", category: "General Purpose", }, { name: "command-r", - description: "Cohere's Command R 35B — enterprise-grade model with light content filtering.", + description: "Cohere's Command R 35B — enterprise-grade model with 128K context and light content filtering.", size: "20 GB", bestFor: "RAG, conversation, creative tasks", category: "General Purpose", }, + // — Reasoning — + { + name: "deepseek-r1", + description: "DeepSeek R1 — chain-of-thought reasoning model. Distilled versions from 1.5B to 70B run locally.", + size: "4.7 GB", + bestFor: "Step-by-step reasoning, math, logic", + category: "Reasoning", + }, // — Coding — { - name: "codellama", - description: "Meta's Code Llama — specialized for code generation and understanding.", - size: "3.8 GB", - bestFor: "Code generation, debugging, technical docs", + name: "qwen2.5-coder", + description: "Alibaba's Qwen 2.5 Coder — 0.5B to 32B. 32B version rivals GPT-4o on coding benchmarks.", + size: "4.7 GB", + bestFor: "Code generation, refactoring, debugging", category: "Coding", }, { name: "deepseek-coder-v2", - description: "DeepSeek Coder V2 — state-of-the-art coding model with strong math ability.", + description: "DeepSeek Coder V2 16B — strong coding model with 160K context and math ability.", size: "8.9 GB", bestFor: "Code, math, technical problem solving", category: "Coding", }, // — Uncensored / Creative Writing — + { + name: "dolphin3", + description: "Eric Hartford's Dolphin 3 — next-gen uncensored model based on Llama 3.1 8B. 128K context.", + size: "4.9 GB", + bestFor: "Uncensored chat, coding, creative writing", + category: "Uncensored / Creative Writing", + }, { name: "dolphin-mistral", - description: "Eric Hartford's Dolphin fine-tune of Mistral 7B. Safety/refusal data removed from training.", + description: "Dolphin fine-tune of Mistral 7B. Safety/refusal data removed from training. 32K context.", size: "4.1 GB", bestFor: "Uncensored general chat, creative writing", category: "Uncensored / Creative Writing", }, { name: "dolphin-llama3", - description: "Dolphin fine-tune of Llama 3 8B. Uncensored training on a stronger base model.", + description: "Dolphin fine-tune of Llama 3 8B. Uncensored training on a strong base model.", size: "4.7 GB", bestFor: "Uncensored chat, strong reasoning", category: "Uncensored / Creative Writing", @@ -153,30 +175,15 @@ const MODEL_CATALOG: ModelInfo[] = [ bestFor: "Helpful assistant, minimal filtering", category: "Uncensored / Creative Writing", }, - { - name: "nollama/mythomax-l2-13b", - description: "MythoMax L2 13B — a merge of multiple fine-tunes for creative and narrative writing with strong coherence.", - size: "7.4 GB", - bestFor: "Creative fiction, roleplay, narrative writing", - category: "Uncensored / Creative Writing", - }, - { - name: "mattw/mythalion", - description: "Mythalion 13B — a Gryphe merge combining Mythologic and Pygmalion for expressive creative output.", - size: "7.4 GB", - bestFor: "Creative writing, character dialogue", - category: "Uncensored / Creative Writing", - }, - { - name: "samantha-mistral", - description: "Eric Hartford's Samantha personality model. Designed as a helpful companion without refusals.", - size: "4.1 GB", - bestFor: "Companion chat, unrestricted conversation", - category: "Uncensored / Creative Writing", - }, ]; const selectedModel = ref(""); +const modelTab = ref<"installed" | "available">("installed"); + +// Base URL setting (admin only) +const baseUrl = ref(""); +const savingBaseUrl = ref(false); +const baseUrlSaved = ref(false); const modelStatuses = computed(() => { const installed = new Set( @@ -189,6 +196,10 @@ const modelStatuses = computed(() => { })); }); +const installedModels = computed(() => + modelStatuses.value.filter((m) => m.installed) +); + const categories = computed(() => { const cats: string[] = []; for (const m of modelStatuses.value) { @@ -224,7 +235,7 @@ onMounted(async () => { notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false"; } - // Load SMTP config if admin + // Load admin settings if (authStore.isAdmin) { try { const smtpConfig = await apiGet>("/api/admin/smtp"); @@ -232,6 +243,12 @@ onMounted(async () => { } catch { // SMTP not configured yet } + try { + const urlConfig = await apiGet<{ base_url: string }>("/api/admin/base-url"); + baseUrl.value = urlConfig.base_url; + } catch { + // base URL not configured yet + } } }); @@ -411,6 +428,20 @@ async function sendTestEmail() { } } +async function saveBaseUrl() { + savingBaseUrl.value = true; + baseUrlSaved.value = false; + try { + await apiPut("/api/admin/base-url", { base_url: baseUrl.value.trim() }); + baseUrlSaved.value = true; + setTimeout(() => (baseUrlSaved.value = false), 2000); + } catch { + toastStore.show("Failed to save application URL", "error"); + } finally { + savingBaseUrl.value = false; + } +} + async function handleRestoreFile(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; @@ -545,6 +576,29 @@ async function handleRestoreFile(event: Event) { +
+

Application URL

+

+ The public URL used in email links (invitations, password resets). Example: https://notes.example.com +

+
+ + +
+
+ + Saved! +
+
+

Email / SMTP

@@ -612,15 +666,35 @@ async function handleRestoreFile(event: Event) {

Model

-

- Choose which LLM model to use for chat. Models need to be downloaded before use. -

-
-

{{ cat }}

-
+
+ + +
+ + +