Add model selection, dashboard chat input, and model warming
- Add GET /api/chat/ps and POST /api/chat/warm endpoints for hot model visibility and pre-loading - Extend PATCH /api/chat/conversations/:id to accept model in addition to title - Add ModelSelector component with hot/cold indicators from Ollama /api/ps - Add DashboardChatInput component (model selector + note picker + textarea) replacing the simple "New Chat" button on the dashboard - Add model selector dropdown to ChatView header, persisted per-conversation - Warm default model on dashboard mount via fire-and-forget background task - Configure Ollama with OLLAMA_MAX_LOADED_MODELS=2 and OLLAMA_KEEP_ALIVE=30m - Always-visible edit buttons on NoteCard/TaskCard (remove hover-only behavior) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,9 @@ services:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
environment:
|
||||
OLLAMA_MAX_LOADED_MODELS: "2"
|
||||
OLLAMA_KEEP_ALIVE: "30m"
|
||||
# To enable GPU support, uncomment the deploy section below
|
||||
# (requires nvidia-container-toolkit)
|
||||
# deploy:
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import ModelSelector from "@/components/ModelSelector.vue";
|
||||
import type { Note } from "@/types/note";
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { content: string; model: string; contextNoteId?: number }];
|
||||
}>();
|
||||
|
||||
const store = useChatStore();
|
||||
|
||||
const selectedModel = ref(store.defaultModel || "");
|
||||
const messageInput = ref("");
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Note picker state
|
||||
const attachedNote = ref<{ id: number; title: string } | null>(null);
|
||||
const showNotePicker = ref(false);
|
||||
const noteSearchQuery = ref("");
|
||||
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||
const noteSearchLoading = ref(false);
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Sync default model when store updates
|
||||
watch(
|
||||
() => store.defaultModel,
|
||||
(v) => {
|
||||
if (!selectedModel.value && v) selectedModel.value = v;
|
||||
}
|
||||
);
|
||||
|
||||
function onSubmit() {
|
||||
const content = messageInput.value.trim();
|
||||
if (!content) return;
|
||||
emit("submit", {
|
||||
content,
|
||||
model: selectedModel.value,
|
||||
contextNoteId: attachedNote.value?.id,
|
||||
});
|
||||
messageInput.value = "";
|
||||
attachedNote.value = null;
|
||||
resetTextareaHeight();
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
||||
}
|
||||
|
||||
function resetTextareaHeight() {
|
||||
const el = inputEl.value;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
}
|
||||
|
||||
// Note picker
|
||||
function toggleNotePicker() {
|
||||
showNotePicker.value = !showNotePicker.value;
|
||||
if (showNotePicker.value) {
|
||||
noteSearchQuery.value = "";
|
||||
noteSearchResults.value = [];
|
||||
nextTick(() => {
|
||||
const el = document.querySelector(
|
||||
".dashboard-chat .note-picker-search"
|
||||
) as HTMLInputElement;
|
||||
el?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onNoteSearchInput() {
|
||||
if (noteSearchTimer) clearTimeout(noteSearchTimer);
|
||||
noteSearchTimer = setTimeout(async () => {
|
||||
const q = noteSearchQuery.value.trim();
|
||||
if (!q) {
|
||||
noteSearchResults.value = [];
|
||||
return;
|
||||
}
|
||||
noteSearchLoading.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ notes: Note[] }>(
|
||||
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
|
||||
);
|
||||
noteSearchResults.value = data.notes.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
}));
|
||||
} catch {
|
||||
noteSearchResults.value = [];
|
||||
} finally {
|
||||
noteSearchLoading.value = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function selectNote(note: { id: number; title: string }) {
|
||||
attachedNote.value = note;
|
||||
showNotePicker.value = false;
|
||||
}
|
||||
|
||||
function removeAttachedNote() {
|
||||
attachedNote.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-chat">
|
||||
<!-- Attached note pill -->
|
||||
<div v-if="attachedNote" class="attached-note">
|
||||
<span class="attached-note-pill">
|
||||
{{ attachedNote.title }}
|
||||
<button class="attached-note-remove" @click="removeAttachedNote">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-bar">
|
||||
<ModelSelector v-model="selectedModel" :disabled="!store.chatReady" />
|
||||
|
||||
<div class="note-picker-wrapper">
|
||||
<button
|
||||
class="btn-attach"
|
||||
@click="toggleNotePicker"
|
||||
:disabled="!store.chatReady"
|
||||
title="Attach a note"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="showNotePicker" class="note-picker-dropdown">
|
||||
<input
|
||||
class="note-picker-search"
|
||||
v-model="noteSearchQuery"
|
||||
@input="onNoteSearchInput"
|
||||
placeholder="Search notes..."
|
||||
/>
|
||||
<div class="note-picker-results">
|
||||
<div
|
||||
v-for="note in noteSearchResults"
|
||||
:key="note.id"
|
||||
class="note-picker-item"
|
||||
@click="selectNote(note)"
|
||||
>
|
||||
{{ note.title || "Untitled" }}
|
||||
</div>
|
||||
<div v-if="noteSearchLoading" class="note-picker-empty">
|
||||
Searching...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="noteSearchQuery && !noteSearchResults.length"
|
||||
class="note-picker-empty"
|
||||
>
|
||||
No notes found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="messageInput"
|
||||
@keydown="onInputKeydown"
|
||||
@input="autoResize"
|
||||
:placeholder="
|
||||
store.chatReady
|
||||
? 'Start a new chat... (Enter to send)'
|
||||
: 'Chat unavailable'
|
||||
"
|
||||
:disabled="!store.chatReady"
|
||||
rows="1"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="onSubmit"
|
||||
:disabled="!messageInput.trim() || !store.chatReady"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-chat {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.attached-note {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.attached-note-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.attached-note-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.attached-note-remove:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
background: var(--color-input-bar-bg);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 12px var(--color-shadow);
|
||||
}
|
||||
|
||||
.chat-input-bar textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
background: transparent;
|
||||
color: var(--color-input-bar-text);
|
||||
outline: none;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.chat-input-bar textarea::placeholder {
|
||||
color: var(--color-input-bar-placeholder);
|
||||
}
|
||||
.chat-input-bar textarea:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Note picker */
|
||||
.note-picker-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.btn-attach {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-input-bar-text);
|
||||
opacity: 0.6;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-attach:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.btn-attach:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
.note-picker-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 280px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
.note-picker-search {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.note-picker-results {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.note-picker-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.note-picker-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.note-picker-empty {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from "vue";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
}>();
|
||||
|
||||
const store = useChatStore();
|
||||
|
||||
function normalize(name: string): string {
|
||||
return name.replace(/:latest$/, "");
|
||||
}
|
||||
|
||||
const hotNames = computed(() => {
|
||||
return new Set(store.runningModels.map((m) => normalize(m.name)));
|
||||
});
|
||||
|
||||
function isHot(modelName: string): boolean {
|
||||
return hotNames.value.has(normalize(modelName));
|
||||
}
|
||||
|
||||
function onChange(e: Event) {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.models.length) store.fetchModels();
|
||||
store.fetchRunningModels();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
class="model-selector"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-if="!modelValue" value="" disabled>Select model</option>
|
||||
<option
|
||||
v-for="m in store.models"
|
||||
:key="m.name"
|
||||
:value="m.name"
|
||||
>
|
||||
{{ isHot(m.name) ? "\u2B24 " : "\u25CB " }}{{ m.name }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-selector {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.model-selector:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.model-selector:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -68,28 +68,19 @@ function goEdit() {
|
||||
}
|
||||
.btn-edit {
|
||||
flex-shrink: 0;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.note-card:hover .btn-edit {
|
||||
opacity: 1;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.btn-edit {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.note-preview {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -93,28 +93,19 @@ function isOverdue(): boolean {
|
||||
}
|
||||
.btn-edit {
|
||||
flex-shrink: 0;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.task-card:hover .btn-edit {
|
||||
opacity: 1;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.btn-edit {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.task-preview {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
Message,
|
||||
OllamaModel,
|
||||
OllamaStatus,
|
||||
RunningModel,
|
||||
SendMessageResponse,
|
||||
} from "@/types/chat";
|
||||
|
||||
@@ -27,6 +28,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
const streamingContent = ref("");
|
||||
const lastContextMeta = ref<ContextMeta | null>(null);
|
||||
const models = ref<OllamaModel[]>([]);
|
||||
const runningModels = ref<RunningModel[]>([]);
|
||||
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
|
||||
const modelStatus = ref<"checking" | "ready" | "not_found">("checking");
|
||||
const defaultModel = ref("");
|
||||
@@ -308,6 +310,42 @@ export const useChatStore = defineStore("chat", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRunningModels() {
|
||||
try {
|
||||
const data = await apiGet<{ models: RunningModel[] }>("/api/chat/ps");
|
||||
runningModels.value = data.models;
|
||||
} catch {
|
||||
runningModels.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function warmModel(model: string) {
|
||||
try {
|
||||
await apiPost("/api/chat/warm", { model });
|
||||
} catch {
|
||||
// Warming is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
async function updateConversationModel(id: number, model: string) {
|
||||
try {
|
||||
const updated = await apiPatch<Conversation>(
|
||||
`/api/chat/conversations/${id}`,
|
||||
{ model }
|
||||
);
|
||||
const idx = conversations.value.findIndex((c) => c.id === id);
|
||||
if (idx !== -1) {
|
||||
conversations.value[idx] = { ...conversations.value[idx], ...updated };
|
||||
}
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value.model = updated.model;
|
||||
}
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to update model", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
currentConversation,
|
||||
@@ -317,6 +355,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
streamingContent,
|
||||
lastContextMeta,
|
||||
models,
|
||||
runningModels,
|
||||
ollamaStatus,
|
||||
modelStatus,
|
||||
defaultModel,
|
||||
@@ -331,6 +370,9 @@ export const useChatStore = defineStore("chat", () => {
|
||||
saveMessageAsNote,
|
||||
summarizeAsNote,
|
||||
fetchModels,
|
||||
fetchRunningModels,
|
||||
warmModel,
|
||||
updateConversationModel,
|
||||
fetchStatus,
|
||||
startStatusPolling,
|
||||
stopStatusPolling,
|
||||
|
||||
@@ -31,6 +31,13 @@ export interface OllamaModel {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface RunningModel {
|
||||
name: string;
|
||||
size: number;
|
||||
size_vram: number;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface ContextMeta {
|
||||
context_note_id: number | null;
|
||||
context_note_title: string | null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import ChatMessage from "@/components/ChatMessage.vue";
|
||||
import ModelSelector from "@/components/ModelSelector.vue";
|
||||
import type { Note } from "@/types/note";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -28,6 +29,9 @@ const noteSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||
const noteSearchLoading = ref(false);
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Model selection
|
||||
const selectedModel = ref("");
|
||||
|
||||
// Exclude tracking (session-scoped per conversation)
|
||||
const excludedNoteIds = ref<Set<number>>(new Set());
|
||||
|
||||
@@ -71,6 +75,12 @@ onMounted(async () => {
|
||||
await store.fetchConversations();
|
||||
if (convId.value) {
|
||||
await store.fetchConversation(convId.value);
|
||||
if (store.currentConversation?.model) {
|
||||
selectedModel.value = store.currentConversation.model;
|
||||
}
|
||||
}
|
||||
if (!selectedModel.value) {
|
||||
selectedModel.value = store.defaultModel;
|
||||
}
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
});
|
||||
@@ -101,6 +111,26 @@ watch(convId, async (newId) => {
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
});
|
||||
|
||||
// Sync selectedModel from loaded conversation
|
||||
watch(
|
||||
() => store.currentConversation?.model,
|
||||
(model) => {
|
||||
if (model) selectedModel.value = model;
|
||||
}
|
||||
);
|
||||
|
||||
// Persist model changes to backend
|
||||
watch(selectedModel, (newModel, oldModel) => {
|
||||
if (
|
||||
newModel &&
|
||||
oldModel &&
|
||||
newModel !== oldModel &&
|
||||
store.currentConversation
|
||||
) {
|
||||
store.updateConversationModel(store.currentConversation.id, newModel);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.streamingContent,
|
||||
() => scrollToBottom()
|
||||
@@ -124,7 +154,7 @@ async function selectConversation(id: number) {
|
||||
}
|
||||
|
||||
async function newConversation() {
|
||||
const conv = await store.createConversation();
|
||||
const conv = await store.createConversation("", selectedModel.value);
|
||||
await store.fetchConversation(conv.id);
|
||||
router.push(`/chat/${conv.id}`);
|
||||
}
|
||||
@@ -313,6 +343,10 @@ function excludeAutoNote(noteId: number) {
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ModelSelector
|
||||
v-model="selectedModel"
|
||||
:disabled="store.streaming"
|
||||
/>
|
||||
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
|
||||
<button
|
||||
v-if="store.currentConversation.messages.length"
|
||||
@@ -584,6 +618,8 @@ function excludeAutoNote(noteId: number) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-summarize {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Task, TaskListResponse } from "@/types/task";
|
||||
import type { Conversation } from "@/types/chat";
|
||||
import NoteCard from "@/components/NoteCard.vue";
|
||||
import TaskCard from "@/components/TaskCard.vue";
|
||||
import DashboardChatInput from "@/components/DashboardChatInput.vue";
|
||||
import type { TaskStatus } from "@/types/task";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
@@ -166,11 +167,24 @@ function onStatusToggle(id: number, status: TaskStatus) {
|
||||
});
|
||||
}
|
||||
|
||||
async function newChat() {
|
||||
const chatStore = useChatStore();
|
||||
const conv = await chatStore.createConversation();
|
||||
const chatStore = useChatStore();
|
||||
|
||||
// Warm default model after status fetch
|
||||
chatStore.fetchStatus().then(() => {
|
||||
if (chatStore.defaultModel) {
|
||||
chatStore.warmModel(chatStore.defaultModel);
|
||||
}
|
||||
});
|
||||
|
||||
async function onChatSubmit(payload: {
|
||||
content: string;
|
||||
model: string;
|
||||
contextNoteId?: number;
|
||||
}) {
|
||||
const conv = await chatStore.createConversation("", payload.model);
|
||||
await chatStore.fetchConversation(conv.id);
|
||||
router.push(`/chat/${conv.id}`);
|
||||
await chatStore.sendMessage(payload.content, payload.contextNoteId);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -275,7 +289,7 @@ async function newChat() {
|
||||
<div v-else class="empty-state">
|
||||
<p class="empty-text">No chats yet.</p>
|
||||
</div>
|
||||
<button class="btn-cta btn-new-chat" @click="newChat">+ New Chat</button>
|
||||
<DashboardChatInput @submit="onChatSubmit" />
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
@@ -374,9 +388,6 @@ async function newChat() {
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-new-chat {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
|
||||
@@ -15,7 +15,7 @@ from fabledassistant.services.chat import (
|
||||
list_conversations,
|
||||
save_response_as_note,
|
||||
summarize_conversation_as_note,
|
||||
update_conversation_title,
|
||||
update_conversation,
|
||||
)
|
||||
from fabledassistant.services.generation_buffer import (
|
||||
GenerationState,
|
||||
@@ -83,9 +83,10 @@ async def update_conversation_route(conv_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
title = data.get("title")
|
||||
if title is None:
|
||||
return jsonify({"error": "title is required"}), 400
|
||||
conv = await update_conversation_title(uid, conv_id, title)
|
||||
model = data.get("model")
|
||||
if title is None and model is None:
|
||||
return jsonify({"error": "title or model is required"}), 400
|
||||
conv = await update_conversation(uid, conv_id, title=title, model=model)
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
return jsonify(conv.to_dict())
|
||||
@@ -233,6 +234,54 @@ async def summarize_conversation_route(conv_id: int):
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@chat_bp.route("/ps", methods=["GET"])
|
||||
@login_required
|
||||
async def running_models_route():
|
||||
"""Return currently loaded (hot) models from Ollama."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(f"{Config.OLLAMA_URL}/api/ps")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = [
|
||||
{
|
||||
"name": m["name"],
|
||||
"size": m.get("size", 0),
|
||||
"size_vram": m.get("size_vram", 0),
|
||||
"expires_at": m.get("expires_at", ""),
|
||||
}
|
||||
for m in data.get("models", [])
|
||||
]
|
||||
return jsonify({"models": models})
|
||||
except Exception as e:
|
||||
logger.debug("Failed to query Ollama /api/ps: %s", e)
|
||||
return jsonify({"models": []})
|
||||
|
||||
|
||||
@chat_bp.route("/warm", methods=["POST"])
|
||||
@login_required
|
||||
async def warm_model_route():
|
||||
"""Pre-load a model into Ollama memory."""
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
model = data.get("model", "").strip()
|
||||
if not model:
|
||||
return jsonify({"error": "model is required"}), 400
|
||||
|
||||
async def _warm():
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/generate",
|
||||
json={"model": model, "prompt": "", "keep_alive": "30m"},
|
||||
)
|
||||
logger.info("Warmed model %s", model)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to warm model %s: %s", model, e)
|
||||
|
||||
asyncio.create_task(_warm())
|
||||
return jsonify({"status": "warming"}), 202
|
||||
|
||||
|
||||
@chat_bp.route("/status", methods=["GET"])
|
||||
@login_required
|
||||
async def chat_status_route():
|
||||
|
||||
@@ -103,8 +103,11 @@ async def delete_conversation(user_id: int, conversation_id: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def update_conversation_title(
|
||||
user_id: int, conversation_id: int, title: str
|
||||
async def update_conversation(
|
||||
user_id: int,
|
||||
conversation_id: int,
|
||||
title: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> Conversation | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
@@ -116,13 +119,22 @@ async def update_conversation_title(
|
||||
conv = result.scalars().first()
|
||||
if conv is None:
|
||||
return None
|
||||
conv.title = title
|
||||
if title is not None:
|
||||
conv.title = title
|
||||
if model is not None:
|
||||
conv.model = model
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(conv)
|
||||
return conv
|
||||
|
||||
|
||||
async def update_conversation_title(
|
||||
user_id: int, conversation_id: int, title: str
|
||||
) -> Conversation | None:
|
||||
return await update_conversation(user_id, conversation_id, title=title)
|
||||
|
||||
|
||||
async def add_message(
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
|
||||
@@ -80,7 +80,7 @@ async def stream_chat(
|
||||
payload: dict = {"model": model, "messages": messages, "stream": True}
|
||||
if options:
|
||||
payload["options"] = options
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=10.0, read=300.0)) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{Config.OLLAMA_URL}/api/chat",
|
||||
@@ -100,7 +100,7 @@ async def stream_chat(
|
||||
|
||||
async def generate_completion(messages: list[dict], model: str) -> str:
|
||||
"""Non-streaming chat completion, returns full response text."""
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=10.0, read=300.0)) as client:
|
||||
resp = await client.post(
|
||||
f"{Config.OLLAMA_URL}/api/chat",
|
||||
json={"model": model, "messages": messages, "stream": False},
|
||||
|
||||
+86
-261
@@ -12,7 +12,7 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## Last Updated
|
||||
2026-02-13 — Phase 5.9: Invitation system, table of contents, actionable dashboard, settings improvements
|
||||
2026-02-14 — Phase 6.0: Model selection, dashboard chat input, model warming
|
||||
|
||||
## Project Overview
|
||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||
@@ -289,13 +289,13 @@ fabledassistant/
|
||||
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, login/register/logout/checkAuth
|
||||
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
|
||||
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (with toast errors)
|
||||
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling (with toast errors)
|
||||
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling, running models, model warming, updateConversationModel (with toast errors)
|
||||
│ │ ├── settings.ts # App settings: assistantName, defaultModel, pullModel, deleteModel (with toast errors)
|
||||
│ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id)
|
||||
│ ├── types/
|
||||
│ │ ├── auth.ts # User interface, AuthStatus interface
|
||||
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
|
||||
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, OllamaStatus interfaces
|
||||
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, RunningModel, OllamaStatus interfaces
|
||||
│ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
|
||||
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
|
||||
│ ├── extensions/
|
||||
@@ -313,8 +313,8 @@ fabledassistant/
|
||||
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
|
||||
│ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email
|
||||
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete
|
||||
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills
|
||||
│ │ ├── HomeView.vue # Actionable dashboard: overdue, due today, due this week, high priority, in progress tasks; recent chats; recently edited notes
|
||||
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills, model selector in header
|
||||
│ │ ├── HomeView.vue # Actionable dashboard: overdue, due today, due this week, high priority, in progress tasks; recent chats with inline chat input; recently edited notes; model warming on mount
|
||||
│ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
|
||||
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
|
||||
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, AI Assist panel (bottom 1/3), Ctrl+S, unsaved guard
|
||||
@@ -326,6 +326,8 @@ fabledassistant/
|
||||
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with expandable detail rows
|
||||
│ │ ├── AppHeader.vue # Nav bar: brand, nav links (incl. admin Logs), status indicator, theme toggle, user info + logout, hamburger menu (mobile)
|
||||
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote/exclude
|
||||
│ │ ├── ModelSelector.vue # Model dropdown (v-model pattern): fetches installed + running models, hot/cold indicators
|
||||
│ │ ├── DashboardChatInput.vue # Inline chat bar: ModelSelector + note picker + textarea + send button; emits submit event
|
||||
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, configurable assistant name label, "Save as Note" action on assistant messages
|
||||
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit, hover edit button
|
||||
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags, hover edit button
|
||||
@@ -393,12 +395,14 @@ fabledassistant/
|
||||
| POST | `/api/chat/conversations` | Create conversation (body: `{title?, model?}`) |
|
||||
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
|
||||
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
|
||||
| PATCH | `/api/chat/conversations/:id` | Update conversation title (body: `{title}`) |
|
||||
| PATCH | `/api/chat/conversations/:id` | Update conversation title or model (body: `{title?, model?}`) |
|
||||
| POST | `/api/chat/conversations/:id/messages` | Start generation: save user message, launch background task, return 202 (body: `{content, context_note_id?, exclude_note_ids?}`) |
|
||||
| GET | `/api/chat/conversations/:id/generation/stream` | SSE endpoint tailing generation buffer; supports `Last-Event-ID` reconnection; emits `context`, `chunk`, `done`, `error` events |
|
||||
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation (sets cancel_event, saves partial content) |
|
||||
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note (LLM-generated title, tagged `chat`) |
|
||||
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note |
|
||||
| GET | `/api/chat/ps` | List currently loaded (hot) Ollama models (`{models: [{name, size, size_vram, expires_at}]}`) |
|
||||
| POST | `/api/chat/warm` | Pre-load a model into Ollama memory (body: `{model}`, returns 202) |
|
||||
| GET | `/api/chat/status` | Check Ollama availability and model readiness (`{ollama, model, default_model}`) |
|
||||
| GET | `/api/chat/models` | List available Ollama models |
|
||||
| POST | `/api/chat/models/pull` | Pull/download a model from Ollama via SSE streaming progress (body: `{model}`, response: SSE with `{status, completed, total}` events) |
|
||||
@@ -489,236 +493,84 @@ When adding a new migration, follow these conventions:
|
||||
- Set `down_revision` to chain from the previous migration's `revision`
|
||||
- Data migrations (like 0003) should use `op.get_bind()` + `sa.text()` for queries
|
||||
|
||||
## Phased Roadmap
|
||||
## Implemented Features
|
||||
|
||||
### Phase 1 — Skeleton & Dev Environment ✓
|
||||
- [x] Initialize Python project (pyproject.toml, Quart app scaffold)
|
||||
- [x] Initialize Vue.js project (Vite-based, inside `frontend/`)
|
||||
- [x] Set up Dockerfile (multi-stage: build Vue, serve with Quart)
|
||||
- [x] Docker Compose stack with Ollama service
|
||||
- [x] Quart serves Vue static build + `/api/health` endpoint
|
||||
- [x] Database setup (PostgreSQL 16, asyncpg, SQLAlchemy 2.0, Alembic)
|
||||
### Notes & Tasks
|
||||
- Full CRUD with markdown bodies, Obsidian-style `#tag` extraction (skips code fences), hierarchical tag filtering
|
||||
- Unified data model: a task is a note with `status IS NOT NULL` — convert freely between note ↔ task
|
||||
- Task attributes: status (todo/in_progress/done), priority (none/low/medium/high), due_date
|
||||
- Obsidian-style `[[wikilinks]]` with auto-create on click, backlinks ("what links here")
|
||||
- Tiptap WYSIWYG editor with markdown round-trip, tag/wikilink autocomplete and decorations, sticky toolbar
|
||||
- AI Assist panel in editor: background LLM generation via SSE with section targeting, accept/reject
|
||||
- Table of contents sidebar on note/task viewers (auto-generated from headings, hidden ≤1200px)
|
||||
- Inline edit buttons on NoteCard/TaskCard (hover on desktop, always visible on touch)
|
||||
- Search (ILIKE), sort, tag filter pills, pagination on list views
|
||||
|
||||
### Phase 2 — Notes CRUD + UX ✓
|
||||
- [x] Database model for notes (title, body, tags[], parent_id, timestamps)
|
||||
- [x] REST API: create, read, update, delete, list notes with pagination
|
||||
- [x] Vue views: note list, note editor (markdown), note viewer (rendered)
|
||||
- [x] Search (ILIKE on title/body) and tag filtering (hierarchical via unnest)
|
||||
- [x] Inline `#tag` extraction from body text (backend regex, skips code fences)
|
||||
- [x] Hierarchical tag filtering (`#project` matches `project` and `project/*`)
|
||||
- [x] Dark/light theming with CSS custom properties + toggle + localStorage
|
||||
- [x] App header with navigation and theme toggle
|
||||
- [x] Tag pills (clickable + dismissible) on cards, viewer, and list filter bar
|
||||
- [x] Pagination bar with prev/next and page numbers
|
||||
- [x] Sort controls (field + asc/desc)
|
||||
- [x] Toast notifications (success/error, 3s auto-dismiss)
|
||||
- [x] Ctrl+S save shortcut in editor
|
||||
- [x] Unsaved changes guard (route leave + beforeunload)
|
||||
- [x] DOMPurify sanitization on rendered markdown
|
||||
- [x] Inline tag linkification in rendered markdown (clickable `#tag` links)
|
||||
### Dashboard
|
||||
- Actionable home page with 5 task sections: Overdue, Due Today, Due This Week, High Priority, In Progress
|
||||
- Priority-aware sorting (priority desc → due date asc) within each section
|
||||
- Cascading deduplication — tasks appear only in their highest-priority section
|
||||
- `due_before`/`due_after` query params on `/api/tasks` for date-range filtering
|
||||
- Recent chats and recently edited notes sections
|
||||
- All task sections hidden when empty; marking done removes from all lists
|
||||
|
||||
### Phase 3 — Tasks CRUD + Wikilinks ✓
|
||||
- [x] Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums
|
||||
- [x] Alembic migration for tasks table with PG enums and indexes
|
||||
- [x] REST API: full CRUD + PATCH status toggle + filter by status/priority/tags
|
||||
- [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (Ctrl+S, dirty guard), viewer (rendered markdown)
|
||||
- [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components
|
||||
- [x] Obsidian-style wikilinks: `[[Title]]` and `[[Title|Display]]` in rendered markdown
|
||||
- [x] Wikilink click handling resolves notes by title via `/api/notes/by-title`
|
||||
### LLM Chat
|
||||
- Ollama integration via async HTTP (httpx), auto-pull default model on startup
|
||||
- Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup)
|
||||
- Stop generation with partial content preservation
|
||||
- Note-aware context building: current note + keyword search for related notes + URL fetching
|
||||
- Context pills with promote/exclude controls; note picker (paperclip) in chat input
|
||||
- LLM-generated conversation titles (re-generated every 10th message)
|
||||
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations
|
||||
- Dedicated `/chat` page with responsive sidebar + slide-out chat panel from header
|
||||
- Model catalog with installed/available tabs, download progress, select, remove
|
||||
- Per-conversation model selection via ModelSelector dropdown in chat header (persisted via PATCH)
|
||||
- Dashboard inline chat input: model selector + note picker + textarea; creates conversation and navigates
|
||||
- Model warming: default model pre-loaded into Ollama on dashboard mount via fire-and-forget POST
|
||||
- Hot/cold model indicators: `/api/chat/ps` proxies Ollama `/api/ps`; ModelSelector shows filled/empty circles
|
||||
- Ollama configured with `OLLAMA_MAX_LOADED_MODELS=2` and `OLLAMA_KEEP_ALIVE=30m`
|
||||
|
||||
### Phase 3.5 — Note-Task Integration + Bug Fixes ✓
|
||||
- [x] **Wikilink auto-create:** Clicking `[[New Page]]` creates the note if it doesn't exist
|
||||
- [x] **Backlinks system:** "What links here" section on note and task viewers
|
||||
- [x] **Rendered markdown previews:** NoteCard/TaskCard show rendered markdown (links/images stripped)
|
||||
- [x] **Tag autocomplete Tab cycling:** Tab cycles through suggestions, single match accepts immediately
|
||||
- [x] **HomeView error isolation:** Notes and tasks load independently (one failure doesn't break both)
|
||||
- [x] **SPA routing fix:** Replaced catch-all route with 404 error handler to prevent API interception
|
||||
- [x] **500 error handler:** JSON error responses for API routes, traceback logging
|
||||
- [x] **Idempotent migrations:** All migrations rewritten to raw SQL with IF NOT EXISTS guards
|
||||
- [x] **Auto-migration on startup:** Dockerfile runs `alembic upgrade head` before starting app
|
||||
### Authentication & User Management
|
||||
- Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming
|
||||
- Per-user data isolation across all resources
|
||||
- Registration auto-closes after first user; admin toggle; invitation-based registration
|
||||
- Email invitation system: admin sends invite → branded email → `/register-invite` token flow
|
||||
- Password reset: email-based, SHA256-hashed tokens, 1-hour expiry
|
||||
- Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag
|
||||
- Admin user management: list, delete, invite, revoke
|
||||
|
||||
### Phase 3.6 — Merge Tasks into Notes ✓
|
||||
- [x] **Unified note/task model:** Task is just a note with `status IS NOT NULL`
|
||||
- [x] **Migration 0004:** Added `status`, `priority`, `due_date` columns to notes table, migrated task data from companion notes and orphan tasks, dropped `tasks` table
|
||||
- [x] **Eliminated companion note system:** No more companion note creation, title sync, cascade deletes, or `_skip_cascade` flags
|
||||
- [x] **Standardized on `body`:** Tasks use `body` everywhere (not `description`); API accepts `description` as fallback
|
||||
- [x] **Convert to task:** Simple `update_note(id, status='todo', priority='none')` — same ID preserved
|
||||
- [x] **Convert to note:** New `POST /api/notes/:id/convert-to-note` endpoint clears task attributes
|
||||
- [x] **Tasks service as thin wrappers:** `services/tasks.py` delegates entirely to `services/notes.py`
|
||||
- [x] **Frontend unified types:** `Task` is a re-export alias for `Note`; `note.ts` defines `TaskStatus`, `TaskPriority`
|
||||
- [x] **Simplified views:** Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView
|
||||
### Notifications & Email
|
||||
- SMTP email service (aiosmtplib): STARTTLS (587) and implicit TLS (465)
|
||||
- Security alerts: login, failed login, logout, password change (fire-and-forget)
|
||||
- Task due date reminders: hourly background check, grouped per user, dedup via logs
|
||||
- Invitation and password reset emails with branded HTML templates
|
||||
- Per-user notification preferences (task reminders, security alerts)
|
||||
- Admin SMTP configuration via Settings UI with test email
|
||||
|
||||
### Phase 4 — LLM Chat Integration ✓
|
||||
- [x] **Ollama client:** async HTTP via httpx — `ensure_model()` (auto-pull on startup), `stream_chat()` (streaming NDJSON), `generate_completion()` (non-streaming)
|
||||
- [x] **Context building:** System prompt with note-aware context — includes current note (via `context_note_id`), keyword-extracted related note search (top 3, 5 keywords, 2000-char previews), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars), returns `(messages, context_meta)` tuple with auto-found note IDs/titles, accepts `exclude_note_ids` to skip notes during auto-search
|
||||
- [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD
|
||||
- [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish
|
||||
- [x] **Save as note:** Save any assistant message as a new note (LLM-generated title, tagged `chat`)
|
||||
- [x] **Summarize conversation:** Send full conversation to LLM with summarize prompt, save result as note
|
||||
- [x] **Model management:** `GET /api/chat/models` lists available Ollama models; auto-pull default model (`llama3.1`) on app startup
|
||||
- [x] **Dedicated chat page:** `/chat` with sidebar (conversation list, create/delete) + message thread + markdown rendering + streaming indicator
|
||||
- [x] **Slide-out chat panel:** Toggle from header, receives `contextNoteId` from current route (`/notes/:id` or `/tasks/:id`), auto-creates conversation on first message
|
||||
- [x] **Frontend SSE client:** `apiStreamPost()` uses fetch + ReadableStream to parse SSE data lines
|
||||
- [x] **Auto-title:** Conversation title generated by LLM (concise 3-8 words)
|
||||
### Logging & Observability
|
||||
- Single `app_logs` table: audit (security events), usage (API requests), error (unhandled exceptions)
|
||||
- Request logging middleware with timing (skips log endpoints to avoid recursion)
|
||||
- Admin log viewer: stats summary, category/search/date filters, paginated table, expandable JSON details
|
||||
- Configurable retention via `LOG_RETENTION_DAYS` (default 90), hourly cleanup
|
||||
|
||||
### Phase 4.5 — Chat UX + Settings + Model Management ✓
|
||||
- [x] **Settings infrastructure:** Key-value `settings` table, `GET/PUT /api/settings`, Pinia store with defaults
|
||||
- [x] **Configurable assistant name:** Default "Fable", editable in settings, injected into LLM system prompt and chat message labels
|
||||
- [x] **Settings page:** `/settings` route with assistant name form + model catalog
|
||||
- [x] **Model catalog:** 18 models across 3 categories (General Purpose, Coding, Uncensored / Creative Writing) with descriptions, sizes, and best-for labels
|
||||
- [x] **Model management:** Download (pull with SSE progress streaming + live percentage in UI), select (set as default), and remove (delete from Ollama) with confirmation UI
|
||||
- [x] **Status indicator in nav bar:** Moved from per-component (ChatView/ChatPanel) to global AppHeader; green/yellow/red dot with label
|
||||
- [x] **Chat bubble layout:** User messages right-aligned (primary color bg), assistant messages left-aligned (card bg), speech bubble tails
|
||||
- [x] **Floating dark input bar:** `#1c1c1e` background, rounded corners, circular send button with arrow
|
||||
- [x] **Auto-focus input:** Chat input auto-focuses on mount, conversation switch, and after sending
|
||||
- [x] **HTML entity fix:** `linkifyTags` regex now uses `(?<!&)` lookbehind to avoid matching `#39` inside `'` as a tag. Additional layers: decode entities before marked, DOMPurify sanitization, explicit `'` → `'` replacement after sanitization
|
||||
- [x] **New chat navigation fix:** `fetchConversation()` called before `router.push()` so `currentConversation` is set immediately
|
||||
- [x] **Recent chats on home page:** 3 most recent conversations shown as clickable cards + "New Chat" button
|
||||
### Settings & Admin
|
||||
- Per-user key-value settings store (assistant name, default model, notification prefs)
|
||||
- Admin: backup/restore (full or per-user JSON), SMTP config, base URL, registration toggle, user management, log viewer
|
||||
- Configurable base URL for email links (admin setting, env var fallback)
|
||||
|
||||
### Phase 4.6 — Improved Note Context in Chat ✓
|
||||
- [x] **Multi-word search:** `list_notes()` splits multi-word queries into per-term ILIKE filters with AND logic (each word matched independently, not adjacent)
|
||||
- [x] **Context metadata:** `build_context()` returns `(messages, context_meta)` tuple with auto-found note IDs/titles and attached note info
|
||||
- [x] **Fuller auto-context:** Auto-found note previews increased from 300 to 2000 chars; uses all 5 extracted keywords instead of 3
|
||||
- [x] **Context SSE event:** Backend emits `{context: ContextMeta}` SSE event before streaming LLM response chunks
|
||||
- [x] **Exclude note IDs:** Backend accepts `exclude_note_ids` in request body, skips those notes during auto-search
|
||||
- [x] **Context pills:** ChatView and ChatPanel show auto-found notes as pills above streaming response with note title as router-link
|
||||
- [x] **Promote/exclude controls:** "+" button on context pills promotes note to attached context for next message; "x" button excludes note from future auto-searches in the session
|
||||
- [x] **Note picker:** Paperclip button in chat input opens dropdown with debounced search, selecting a note attaches it as context (shown as pill above input)
|
||||
- [x] **ContextMeta type:** New TypeScript interface for context metadata (`context_note_id`, `context_note_title`, `auto_notes`)
|
||||
- [x] **Session-scoped excludes:** Excluded note IDs tracked per conversation session, cleared on conversation switch
|
||||
### UI & Theming
|
||||
- Dark/light theme with CSS custom properties, design tokens, `prefers-color-scheme` detection
|
||||
- Responsive design: breakpoints (480/768/1024), hamburger menu, mobile touch targets (44px), sidebar overlays
|
||||
- App shell: navbar always visible, 100dvh flex layout, all views fit within viewport
|
||||
- Toast notifications (success/error/warning, 4s auto-dismiss)
|
||||
- DOMPurify sanitization on all rendered markdown
|
||||
|
||||
### Phase 4.7 — Styling Consistency Pass ✓
|
||||
- [x] **Design tokens:** Added `--radius-sm/md/lg/pill`, `--color-success/warning/overlay`, `--focus-ring` to theme.css
|
||||
- [x] **Header alignment fix:** Removed `max-width: 960px` from header nav — now full-width with consistent padding, matching modern app layout
|
||||
- [x] **Active nav link state:** Added `router-link-active` styling with primary color + card background
|
||||
- [x] **Hardcoded colors replaced:** Status indicator dots, settings saved message, remove/confirm-delete buttons now use CSS variables
|
||||
- [x] **Border-radius standardized:** All components migrated from mixed 3px/4px/6px/8px to `var(--radius-sm)` (6px) for controls, `var(--radius-md)` (8px) for cards/containers
|
||||
- [x] **Button padding standardized:** All primary/CTA buttons use `0.45rem 1rem`; small buttons use `0.3rem 0.75rem`
|
||||
- [x] **Settings max-width fixed:** Changed from 700px to 720px to match all other views
|
||||
- [x] **Focus-visible states:** Global `focus-visible` rule with `--focus-ring` shadow on inputs, textareas, selects, buttons
|
||||
- [x] **Modal overlays:** Replaced hardcoded `rgba(0,0,0,0.5)` with `var(--color-overlay)` across all components
|
||||
|
||||
### Phase 4.8 — Backend Efficiency & Consistency Pass ✓
|
||||
- [x] **`get_all_tags()` SQL rewrite:** Replaced O(n) Python-side tag extraction with single `SELECT DISTINCT unnest(tags)` query
|
||||
- [x] **`list_conversations()` subquery count:** Replaced `selectinload(Conversation.messages)` with correlated subquery for `message_count`, avoiding loading all messages for list view
|
||||
- [x] **Removed `services/tasks.py`:** Eliminated 64-line pass-through service; `routes/tasks.py` imports directly from `services.notes`
|
||||
- [x] **Consolidated convert functions:** `convert_note_to_task()` and `convert_task_to_note()` now use single session (was 3 DB round trips)
|
||||
- [x] **Batch settings updates:** New `set_settings_batch()` does all upserts in single transaction (was one transaction per setting)
|
||||
- [x] **Shared `get_installed_models()`:** Extracted duplicate Ollama model-listing logic from 3 locations into `services/llm.py`
|
||||
- [x] **`search_notes_for_context()`:** New function does single OR-keyword query (was up to 5 sequential `list_notes()` calls each with count query)
|
||||
- [x] **Logging:** Added `logger = logging.getLogger(__name__)` to `services/notes.py`, `services/chat.py`, `services/settings.py`
|
||||
- [x] **Database indexes:** Added B-tree indexes on `notes.title` (for `get_note_by_title` + backlinks) and `conversations.updated_at` (for list ordering)
|
||||
- [x] **Safe `Conversation.to_dict()`:** Handles case where messages relationship is not loaded
|
||||
|
||||
### Phase 5 — Polish & Production Hardening ✓
|
||||
- [x] **Multi-user authentication:** Session cookie auth with bcrypt, first-user-is-admin pattern, orphaned data claiming
|
||||
- [x] **Per-user data isolation:** All notes, conversations, settings filtered by `user_id`
|
||||
- [x] **Auth decorators:** `@login_required`, `@admin_required` on all API routes (except health + auth status)
|
||||
- [x] **Frontend auth flow:** Login/register views, router beforeEach guard, ApiError with auto-redirect on 401
|
||||
- [x] **Error handling & logging:** Request logging with timing, sanitized 500 errors, LOG_LEVEL config, toast errors on all store actions
|
||||
- [x] **Toast improvements:** Warning type, dismiss button, 4s auto-dismiss
|
||||
- [x] **Responsive design:** Breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), 44px mobile touch targets
|
||||
- [x] **Responsive header:** Hamburger menu at ≤768px, vertical nav dropdown, close on route change
|
||||
- [x] **Responsive chat:** Sidebar overlay on mobile with toggle button, slides in/out
|
||||
- [x] **Backup/restore:** Export user data (any user), full backup (admin), restore from JSON (admin)
|
||||
- [x] **Docker Swarm production stack:** `docker-compose.prod.yml` with Docker secrets, internal network isolation, health checks, resource limits, restart policies
|
||||
- [x] **Docker secrets support:** `_read_secret()` helper in config.py for SECRET_KEY_FILE, DATABASE_URL_FILE
|
||||
|
||||
### Phase 5.1 — Chat UX Improvements ✓
|
||||
- [x] **LLM-generated titles:** Conversation titles generated by LLM (3-8 words) on first exchange and re-generated every 10th message to reflect evolved topics; falls back to truncation on failure
|
||||
- [x] **Background generation architecture:** `GenerationBuffer` (in-memory event buffer with SSE fan-out, reconnect via `Last-Event-ID`, auto-cleanup after 60s) + `run_generation()` asyncio task (streams from Ollama, periodic DB flushes every 5s)
|
||||
- [x] **Stop generation:** `cancel_event` on `GenerationBuffer`; `POST .../generation/cancel` endpoint; red stop button replaces send button during streaming; partial content saved as complete
|
||||
- [x] **Date/time in sidebar:** Relative time for recent (`Just now`, `5m ago`, `3h ago` up to 10h), then date (`Jan 15` this year, `Jan 15, 2025` older)
|
||||
- [x] **Empty chat cleanup:** Navigating away from an empty conversation (message_count === 0) auto-deletes it
|
||||
- [x] **Save-as-note LLM titles:** `save_response_as_note` generates title via LLM (same pattern as chat titles); falls back to first-line extraction on failure
|
||||
- [x] **Chat tag on saved notes:** Both `save_response_as_note` and `summarize_conversation_as_note` tag created notes with `chat`
|
||||
|
||||
### Phase 5.2 — Tiptap Inline-Preview Editor + Layout Improvements ✓
|
||||
- [x] **Tiptap WYSIWYG editor:** Replaced plain textarea with Tiptap (ProseMirror-based) inline-preview editor — headings, bold, italic, lists, code blocks render inline while editing
|
||||
- [x] **Markdown round-trip:** Load markdown → HTML (via `marked`) → Tiptap editing → markdown (via custom `serializeToMarkdown()`) on every change
|
||||
- [x] **Tag/wikilink decorations:** ProseMirror decoration plugins visually highlight `#tag` and `[[wikilink]]` as colored badges without custom node types
|
||||
- [x] **Autocomplete migration:** Moved from textarea-based `useAutocomplete` to `@tiptap/suggestion` framework with shared `SuggestionDropdown` component
|
||||
- [x] **Heading vs tag disambiguation:** Tag suggestion suppressed at start of paragraphs (where `#` is likely a heading prefix)
|
||||
- [x] **Markdown paste handling:** Pasted markdown text auto-detected and converted to formatted content
|
||||
- [x] **Toolbar refactor:** `MarkdownToolbar` uses Tiptap commands with active state highlighting instead of textarea text insertion
|
||||
- [x] **Sticky toolbar:** Toolbar, title, tabs pinned above scrolling editor content
|
||||
- [x] **App shell layout:** Navbar always visible — `App.vue` uses flex column at 100dvh with header + content areas; all views fit below header
|
||||
- [x] **AI Assist panel:** Fixed at bottom 1/3 of viewport; section selector and prompt fill the panel; controls hidden during streaming/review (only output + accept/reject shown)
|
||||
- [x] **Auto-syncing sections:** `useAssist` watches `body` ref to keep section list in sync on load, typing, and AI accept
|
||||
- [x] **Accept trailing newline:** Accepted AI suggestions always end with a newline for clean separation
|
||||
- [x] **Wider content area:** Max-width increased from 720px to 960px across all views
|
||||
|
||||
### Phase 5.3 — Registration Control, User Management, Security Hardening ✓
|
||||
- [x] **Registration auto-closes:** After the first user registers (admin), registration is closed by default
|
||||
- [x] **Registration toggle:** Admin can open/close registration from `/admin/users`
|
||||
- [x] **Password confirmation:** Register form requires password confirmation with inline validation
|
||||
- [x] **Admin user management:** `/admin/users` view with user list and delete (admin only)
|
||||
- [x] **Session cookie hardening:** `HttpOnly`, `SameSite=Lax` always on; `Secure` via `SECURE_COOKIES` env var
|
||||
- [x] **Default SECRET_KEY warning:** Logs WARNING on startup if default key is in use
|
||||
- [x] **Password change:** `PUT /api/auth/password` endpoint + Settings UI section
|
||||
- [x] **Production deployment docs:** README documents reverse proxy, rate limiting, CSP headers, SECRET_KEY, registration behavior
|
||||
|
||||
### Phase 5.4 — Application Logging System ✓
|
||||
- [x] **Single `app_logs` table:** Unified audit/usage/error logging with `category` field
|
||||
- [x] **Usage logging middleware:** `after_request` logs all `/api/*` requests with user, endpoint, method, status, duration (skips `/api/admin/logs` to avoid recursion)
|
||||
- [x] **Error logging:** `handle_500` captures error type, message, and traceback
|
||||
- [x] **Audit logging:** Security events logged in auth routes (register, login, login_failed, logout, password_change) and admin routes (backup, restore, user_delete, registration_toggle)
|
||||
- [x] **Denormalized username:** Preserves attribution after user deletion (`user_id` FK uses `ON DELETE SET NULL`)
|
||||
- [x] **Admin log viewer:** `/admin/logs` with stats summary, category/search/date filters, paginated table with expandable JSON detail rows
|
||||
- [x] **Log stats endpoint:** `GET /api/admin/logs/stats` returns category counts
|
||||
- [x] **Configurable retention:** `LOG_RETENTION_DAYS` env var (default 90), hourly asyncio cleanup task
|
||||
- [x] **Config:** `LOG_RETENTION_DAYS` added to `Config` class
|
||||
|
||||
### Phase 5.5 — SMTP Email Notifications ✓
|
||||
- [x] **SMTP email service:** `aiosmtplib` for async email sending, supports STARTTLS (587) and implicit TLS (465)
|
||||
- [x] **SMTP config in DB:** Admin configures SMTP via Settings UI, stored in `settings` table; env var fallbacks for Docker Swarm bootstrap
|
||||
- [x] **Admin SMTP endpoints:** `GET/PUT /api/admin/smtp` for config management, `POST /api/admin/smtp/test` for sending test emails
|
||||
- [x] **Security alert notifications:** Fire-and-forget `asyncio.create_task()` on login, failed login, logout, password change
|
||||
- [x] **Task due date reminders:** Hourly background loop checks for due/overdue tasks, sends grouped email per user; dedup via `app_logs` check
|
||||
- [x] **Per-user notification preferences:** `notify_task_reminders` and `notify_security_alerts` settings (default enabled)
|
||||
- [x] **Settings UI:** Notifications section for all users (checkbox toggles), SMTP section for admin (2-column grid + test email)
|
||||
|
||||
### Phase 5.6 — Assist Background-Task + Buffer Architecture ✓
|
||||
- [x] **Assist buffer helpers:** `create_assist_buffer()`, `get_assist_buffer()`, `remove_assist_buffer()` using `"assist:{user_id}"` string keys in shared `_buffers` registry
|
||||
- [x] **`run_assist_generation()`:** Lightweight background task — streams from Ollama into buffer, no DB persistence, no title generation, no cancellation
|
||||
- [x] **Two-step assist API:** `POST /api/notes/assist` returns 202 + launches background task; `GET /api/notes/assist/stream` SSE endpoint tails buffer with 15s keepalives and `Last-Event-ID` reconnection
|
||||
- [x] **409 conflict guard:** Rejects concurrent assist requests per user
|
||||
- [x] **Frontend two-step pattern:** `useAssist.ts` calls `apiPost()` then `apiSSEStream()` with named event mapping (`chunk`/`done`/`error`); tracks stream handle for cleanup
|
||||
- [x] **Fixes `NS_ERROR_NET_PARTIAL_TRANSFER`:** Keepalive pings prevent browser/Hypercorn from closing connection during gaps between LLM chunks
|
||||
|
||||
### Phase 5.7 — Invitation System ✓
|
||||
- [x] **Invitation tokens table:** `invitation_tokens` (migration 0012) — SHA256-hashed tokens, 7-day expiry, one-time use
|
||||
- [x] **Admin invitation management:** `POST/GET/DELETE /api/admin/invitations` for create/list/revoke
|
||||
- [x] **Invitation email:** Branded HTML email with accept link, sent via fire-and-forget asyncio task
|
||||
- [x] **Invitation registration flow:** `GET /api/auth/invitation/:token` validates, `POST /api/auth/register-with-invite` creates account
|
||||
- [x] **Frontend:** `/register-invite` view with token validation, email pre-fill, username/password form
|
||||
- [x] **Admin UI:** Invite User section in UserManagementView with email input, pending invitations table, revoke action
|
||||
- [x] **Base URL admin setting:** Configurable via Settings UI; used in invitation and password reset email links (replaces `Config.BASE_URL` hardcoded fallback)
|
||||
|
||||
### Phase 5.8 — Table of Contents + Markdown Improvements ✓
|
||||
- [x] **TableOfContents component:** Sticky sidebar on note/task viewer — parses markdown headings, slugified IDs, smooth-scroll click, hidden on screens ≤1200px
|
||||
- [x] **Heading IDs in rendered markdown:** Custom `marked` renderer adds `id` attributes to headings for TOC anchor links
|
||||
- [x] **First-line tag stripping:** `stripFirstLineTags()` removes leading `#tag` lines from markdown before rendering (prevents tags from rendering as headings)
|
||||
- [x] **Viewer layout:** NoteViewerView and TaskViewerView use flex layout with main content + TOC sidebar (max-width 1200px)
|
||||
- [x] **Model catalog refresh:** Updated to current models (llama3.2/3.3, gemma3, qwen3, phi4, deepseek-r1, qwen2.5-coder, dolphin3), added Reasoning category, removed discontinued models
|
||||
- [x] **Settings model tabs:** Split model section into "Installed" and "Available" tabs
|
||||
|
||||
### Phase 5.9 — Actionable "Today" Dashboard ✓
|
||||
- [x] **Due date filtering API:** Added `due_before` (exclusive `<`) and `due_after` (inclusive `>=`) params to `list_notes()` and `/api/tasks` endpoint
|
||||
- [x] **Dashboard rewrite:** HomeView shows 5 task sections: Overdue (red accent), Due Today, Due This Week (next 7 days), High Priority (amber accent, catches important tasks not already shown by date), In Progress (deduplicated catch-all)
|
||||
- [x] **Priority-aware sorting:** All task sections sort by priority desc then due date asc — highest priority items surface first within each section
|
||||
- [x] **Parallel fetching:** 6 API calls via `Promise.allSettled` — notes, overdue, due-soon (split client-side into today/week), high priority, in-progress, chats
|
||||
- [x] **Cascading deduplication:** Each section builds on a shared `seen` set so tasks only appear in their highest-priority section
|
||||
- [x] **Client-side filtering:** Filters out `done` tasks from all date/priority sections
|
||||
- [x] **Task sections hidden when empty:** Only shown if there are matching tasks
|
||||
- [x] **Status toggle:** Marking a task `done` removes it from all dashboard lists
|
||||
- [x] **Inline edit buttons:** NoteCard and TaskCard show an "Edit" button on hover (always visible on touch devices) that navigates directly to the edit view
|
||||
|
||||
### Future / Stretch
|
||||
- Tagging/labeling system with LLM-suggested tags
|
||||
- Calendar/timeline view for tasks
|
||||
- Import/export (Markdown files, JSON)
|
||||
- Plugin/extension system
|
||||
### Infrastructure
|
||||
- Multi-stage Dockerfile: Node build → Python runtime, auto-migration on startup
|
||||
- Docker Compose (dev) + Docker Swarm production stack with secrets, overlay networks, health checks
|
||||
- Quart serves Vue SPA from static + REST API under `/api/`; 404 handler for SPA routing
|
||||
- Alembic migrations: 12 migrations, all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
|
||||
- Config from env vars + Docker secrets file support (`_read_secret`)
|
||||
|
||||
## Development Workflow
|
||||
- All development and testing done via Docker: `docker compose up --build`
|
||||
@@ -728,37 +580,10 @@ When adding a new migration, follow these conventions:
|
||||
Quart serves them
|
||||
- To reset database: `docker compose down -v && docker compose up --build`
|
||||
|
||||
## Current Status
|
||||
**Phase:** Phase 5.9 complete. Invitation system, table of contents, actionable dashboard, settings improvements.
|
||||
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
|
||||
- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling
|
||||
- **Tag/wikilink autocomplete** via `@tiptap/suggestion` with heading disambiguation
|
||||
- Unified note/task model (a task is a note with `status IS NOT NULL`)
|
||||
- **Multi-user authentication** with session cookies, bcrypt passwords, admin role
|
||||
- **Per-user data isolation** across notes, conversations, and settings
|
||||
- LLM chat via Ollama with background generation task + SSE streaming
|
||||
- **AI Assist panel** pinned to bottom 1/3 of editor viewport with auto-syncing sections; uses background-task + buffer architecture with keepalive SSE (same pattern as chat)
|
||||
- Note-aware context: auto-includes current note + searches related notes by keyword
|
||||
- Context pills with promote/exclude controls; note picker in chat input
|
||||
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
|
||||
- Dedicated `/chat` page with responsive sidebar (overlay on mobile)
|
||||
- Settings page: assistant name, model catalog (installed/available tabs), password change, data export/restore (admin)
|
||||
- **Invitation system**: admin sends email invitations, recipients register via token-based `/register-invite` flow
|
||||
- **Registration control**: auto-closes after first user, admin toggle, invitation-based registration
|
||||
- **Admin user management**: `/admin/users` with user list, delete, invite, revoke
|
||||
- **Session cookie hardening**: HttpOnly, SameSite=Lax, optional Secure flag
|
||||
- **Actionable dashboard**: HomeView shows overdue/due-today/due-this-week/high-priority/in-progress task sections (priority-sorted, cascading dedup, hidden when empty), recent chats, recently edited notes
|
||||
- **Inline edit buttons**: NoteCard and TaskCard show hover edit button for direct navigation to edit view
|
||||
- **Table of contents**: Sticky sidebar on note/task viewers, auto-generated from markdown headings
|
||||
- **App-wide layout fix**: navbar always visible, all views fit within viewport
|
||||
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
|
||||
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
|
||||
- **Application logging**: audit (security events), usage (API requests), error (unhandled exceptions)
|
||||
- **Admin log viewer**: `/admin/logs` with stats, filters, pagination, expandable detail rows
|
||||
- **Automatic log retention**: configurable via `LOG_RETENTION_DAYS` (default 90 days)
|
||||
- **SMTP email notifications**: security alerts (login/logout/failed login/password change), task due date reminders, invitation emails
|
||||
- **Admin SMTP configuration**: Settings UI with test email, DB-stored config with env var fallbacks
|
||||
- **Admin base URL setting**: Configurable public URL used in email links
|
||||
- **Per-user notification preferences**: toggle task reminders and security alerts independently
|
||||
- **Password reset flow**: Email-based with SHA256-hashed tokens, 1-hour expiry
|
||||
- Dark/light theme with CSS custom properties and design tokens
|
||||
## Backlog
|
||||
- Tagging/labeling system with LLM-suggested tags
|
||||
- Calendar/timeline view for tasks
|
||||
- Import/export (Markdown files, JSON)
|
||||
- Application-level rate limiting on auth endpoints
|
||||
- Security headers middleware (CSP, X-Frame-Options, etc.) — currently handled at reverse proxy level
|
||||
- Session invalidation on user deletion
|
||||
|
||||
Reference in New Issue
Block a user