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:
2026-02-14 18:49:06 -05:00
parent 987ec56dc3
commit 953eaf2feb
13 changed files with 710 additions and 306 deletions
+3
View File
@@ -32,6 +32,9 @@ services:
image: ollama/ollama image: ollama/ollama
volumes: volumes:
- ollama_models:/root/.ollama - ollama_models:/root/.ollama
environment:
OLLAMA_MAX_LOADED_MODELS: "2"
OLLAMA_KEEP_ALIVE: "30m"
# To enable GPU support, uncomment the deploy section below # To enable GPU support, uncomment the deploy section below
# (requires nvidia-container-toolkit) # (requires nvidia-container-toolkit)
# deploy: # 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">
&times;
</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"
>
&uarr;
</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>
+79
View File
@@ -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>
+5 -14
View File
@@ -68,28 +68,19 @@ function goEdit() {
} }
.btn-edit { .btn-edit {
flex-shrink: 0; flex-shrink: 0;
padding: 0.2rem 0.5rem; padding: 0.25rem 0.6rem;
font-size: 0.75rem; font-size: 0.8rem;
background: transparent; background: var(--color-bg-card);
color: var(--color-text-muted); color: var(--color-text-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
opacity: 0; transition: color 0.15s, border-color 0.15s, background 0.15s;
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
}
.note-card:hover .btn-edit {
opacity: 1;
} }
.btn-edit:hover { .btn-edit:hover {
color: var(--color-primary); color: var(--color-primary);
border-color: var(--color-primary); border-color: var(--color-primary);
} }
@media (hover: none) {
.btn-edit {
opacity: 1;
}
}
.note-preview { .note-preview {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
+5 -14
View File
@@ -93,28 +93,19 @@ function isOverdue(): boolean {
} }
.btn-edit { .btn-edit {
flex-shrink: 0; flex-shrink: 0;
padding: 0.2rem 0.5rem; padding: 0.25rem 0.6rem;
font-size: 0.75rem; font-size: 0.8rem;
background: transparent; background: var(--color-bg-card);
color: var(--color-text-muted); color: var(--color-text-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
opacity: 0; transition: color 0.15s, border-color 0.15s, background 0.15s;
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
}
.task-card:hover .btn-edit {
opacity: 1;
} }
.btn-edit:hover { .btn-edit:hover {
color: var(--color-primary); color: var(--color-primary);
border-color: var(--color-primary); border-color: var(--color-primary);
} }
@media (hover: none) {
.btn-edit {
opacity: 1;
}
}
.task-preview { .task-preview {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
+42
View File
@@ -15,6 +15,7 @@ import type {
Message, Message,
OllamaModel, OllamaModel,
OllamaStatus, OllamaStatus,
RunningModel,
SendMessageResponse, SendMessageResponse,
} from "@/types/chat"; } from "@/types/chat";
@@ -27,6 +28,7 @@ export const useChatStore = defineStore("chat", () => {
const streamingContent = ref(""); const streamingContent = ref("");
const lastContextMeta = ref<ContextMeta | null>(null); const lastContextMeta = ref<ContextMeta | null>(null);
const models = ref<OllamaModel[]>([]); const models = ref<OllamaModel[]>([]);
const runningModels = ref<RunningModel[]>([]);
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking"); const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "ready" | "not_found">("checking"); const modelStatus = ref<"checking" | "ready" | "not_found">("checking");
const defaultModel = ref(""); 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 { return {
conversations, conversations,
currentConversation, currentConversation,
@@ -317,6 +355,7 @@ export const useChatStore = defineStore("chat", () => {
streamingContent, streamingContent,
lastContextMeta, lastContextMeta,
models, models,
runningModels,
ollamaStatus, ollamaStatus,
modelStatus, modelStatus,
defaultModel, defaultModel,
@@ -331,6 +370,9 @@ export const useChatStore = defineStore("chat", () => {
saveMessageAsNote, saveMessageAsNote,
summarizeAsNote, summarizeAsNote,
fetchModels, fetchModels,
fetchRunningModels,
warmModel,
updateConversationModel,
fetchStatus, fetchStatus,
startStatusPolling, startStatusPolling,
stopStatusPolling, stopStatusPolling,
+7
View File
@@ -31,6 +31,13 @@ export interface OllamaModel {
size: number; size: number;
} }
export interface RunningModel {
name: string;
size: number;
size_vram: number;
expires_at: string;
}
export interface ContextMeta { export interface ContextMeta {
context_note_id: number | null; context_note_id: number | null;
context_note_title: string | null; context_note_title: string | null;
+37 -1
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown"; import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue"; import ChatMessage from "@/components/ChatMessage.vue";
import ModelSelector from "@/components/ModelSelector.vue";
import type { Note } from "@/types/note"; import type { Note } from "@/types/note";
const route = useRoute(); const route = useRoute();
@@ -28,6 +29,9 @@ const noteSearchResults = ref<{ id: number; title: string }[]>([]);
const noteSearchLoading = ref(false); const noteSearchLoading = ref(false);
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null; let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Model selection
const selectedModel = ref("");
// Exclude tracking (session-scoped per conversation) // Exclude tracking (session-scoped per conversation)
const excludedNoteIds = ref<Set<number>>(new Set()); const excludedNoteIds = ref<Set<number>>(new Set());
@@ -71,6 +75,12 @@ onMounted(async () => {
await store.fetchConversations(); await store.fetchConversations();
if (convId.value) { if (convId.value) {
await store.fetchConversation(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()); nextTick(() => inputEl.value?.focus());
}); });
@@ -101,6 +111,26 @@ watch(convId, async (newId) => {
nextTick(() => inputEl.value?.focus()); 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( watch(
() => store.streamingContent, () => store.streamingContent,
() => scrollToBottom() () => scrollToBottom()
@@ -124,7 +154,7 @@ async function selectConversation(id: number) {
} }
async function newConversation() { async function newConversation() {
const conv = await store.createConversation(); const conv = await store.createConversation("", selectedModel.value);
await store.fetchConversation(conv.id); await store.fetchConversation(conv.id);
router.push(`/chat/${conv.id}`); router.push(`/chat/${conv.id}`);
} }
@@ -313,6 +343,10 @@ function excludeAutoNote(noteId: number) {
<line x1="3" y1="18" x2="21" y2="18"/> <line x1="3" y1="18" x2="21" y2="18"/>
</svg> </svg>
</button> </button>
<ModelSelector
v-model="selectedModel"
:disabled="store.streaming"
/>
<h2>{{ store.currentConversation.title || "New Chat" }}</h2> <h2>{{ store.currentConversation.title || "New Chat" }}</h2>
<button <button
v-if="store.currentConversation.messages.length" v-if="store.currentConversation.messages.length"
@@ -584,6 +618,8 @@ function excludeAutoNote(noteId: number) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
flex: 1;
min-width: 0;
} }
.btn-summarize { .btn-summarize {
+18 -7
View File
@@ -7,6 +7,7 @@ import type { Task, TaskListResponse } from "@/types/task";
import type { Conversation } from "@/types/chat"; import type { Conversation } from "@/types/chat";
import NoteCard from "@/components/NoteCard.vue"; import NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue"; import TaskCard from "@/components/TaskCard.vue";
import DashboardChatInput from "@/components/DashboardChatInput.vue";
import type { TaskStatus } from "@/types/task"; import type { TaskStatus } from "@/types/task";
import { useTasksStore } from "@/stores/tasks"; import { useTasksStore } from "@/stores/tasks";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
@@ -166,11 +167,24 @@ function onStatusToggle(id: number, status: TaskStatus) {
}); });
} }
async function newChat() { const chatStore = useChatStore();
const chatStore = useChatStore();
const conv = await chatStore.createConversation(); // 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); await chatStore.fetchConversation(conv.id);
router.push(`/chat/${conv.id}`); router.push(`/chat/${conv.id}`);
await chatStore.sendMessage(payload.content, payload.contextNoteId);
} }
</script> </script>
@@ -275,7 +289,7 @@ async function newChat() {
<div v-else class="empty-state"> <div v-else class="empty-state">
<p class="empty-text">No chats yet.</p> <p class="empty-text">No chats yet.</p>
</div> </div>
<button class="btn-cta btn-new-chat" @click="newChat">+ New Chat</button> <DashboardChatInput @submit="onChatSubmit" />
</section> </section>
<section class="section"> <section class="section">
@@ -374,9 +388,6 @@ async function newChat() {
color: var(--color-text-muted); color: var(--color-text-muted);
white-space: nowrap; white-space: nowrap;
} }
.btn-new-chat {
margin-top: 0.75rem;
}
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 1.5rem 0; padding: 1.5rem 0;
+53 -4
View File
@@ -15,7 +15,7 @@ from fabledassistant.services.chat import (
list_conversations, list_conversations,
save_response_as_note, save_response_as_note,
summarize_conversation_as_note, summarize_conversation_as_note,
update_conversation_title, update_conversation,
) )
from fabledassistant.services.generation_buffer import ( from fabledassistant.services.generation_buffer import (
GenerationState, GenerationState,
@@ -83,9 +83,10 @@ async def update_conversation_route(conv_id: int):
uid = get_current_user_id() uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
title = data.get("title") title = data.get("title")
if title is None: model = data.get("model")
return jsonify({"error": "title is required"}), 400 if title is None and model is None:
conv = await update_conversation_title(uid, conv_id, title) return jsonify({"error": "title or model is required"}), 400
conv = await update_conversation(uid, conv_id, title=title, model=model)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return jsonify({"error": "Conversation not found"}), 404
return jsonify(conv.to_dict()) return jsonify(conv.to_dict())
@@ -233,6 +234,54 @@ async def summarize_conversation_route(conv_id: int):
return jsonify({"error": str(e)}), 400 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"]) @chat_bp.route("/status", methods=["GET"])
@login_required @login_required
async def chat_status_route(): async def chat_status_route():
+15 -3
View File
@@ -103,8 +103,11 @@ async def delete_conversation(user_id: int, conversation_id: int) -> bool:
return True return True
async def update_conversation_title( async def update_conversation(
user_id: int, conversation_id: int, title: str user_id: int,
conversation_id: int,
title: str | None = None,
model: str | None = None,
) -> Conversation | None: ) -> Conversation | None:
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -116,13 +119,22 @@ async def update_conversation_title(
conv = result.scalars().first() conv = result.scalars().first()
if conv is None: if conv is None:
return 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) conv.updated_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
await session.refresh(conv) await session.refresh(conv)
return 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( async def add_message(
conversation_id: int, conversation_id: int,
role: str, role: str,
+2 -2
View File
@@ -80,7 +80,7 @@ async def stream_chat(
payload: dict = {"model": model, "messages": messages, "stream": True} payload: dict = {"model": model, "messages": messages, "stream": True}
if options: if options:
payload["options"] = 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( async with client.stream(
"POST", "POST",
f"{Config.OLLAMA_URL}/api/chat", f"{Config.OLLAMA_URL}/api/chat",
@@ -100,7 +100,7 @@ async def stream_chat(
async def generate_completion(messages: list[dict], model: str) -> str: async def generate_completion(messages: list[dict], model: str) -> str:
"""Non-streaming chat completion, returns full response text.""" """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( resp = await client.post(
f"{Config.OLLAMA_URL}/api/chat", f"{Config.OLLAMA_URL}/api/chat",
json={"model": model, "messages": messages, "stream": False}, json={"model": model, "messages": messages, "stream": False},
+86 -261
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial. > Include file-level details in the commit body when the change is non-trivial.
## Last Updated ## 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 ## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with 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 │ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, login/register/logout/checkAuth
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors) │ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (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) │ │ ├── settings.ts # App settings: assistantName, defaultModel, pullModel, deleteModel (with toast errors)
│ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id) │ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id)
│ ├── types/ │ ├── types/
│ │ ├── auth.ts # User interface, AuthStatus interface │ │ ├── auth.ts # User interface, AuthStatus interface
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse │ │ ├── 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) │ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
│ │ └── task.ts # Task = re-export of Note; TaskListResponse │ │ └── task.ts # Task = re-export of Note; TaskListResponse
│ ├── extensions/ │ ├── extensions/
@@ -313,8 +313,8 @@ fabledassistant/
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ ├── 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; recently edited notes │ │ ├── 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) │ │ ├── SettingsView.vue # Settings page: assistant name, model catalog, data export/restore (admin)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination │ │ ├── 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 │ │ ├── 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 │ │ ├── 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) │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ ├── 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 │ │ ├── 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?}`) | | POST | `/api/chat/conversations` | Create conversation (body: `{title?, model?}`) |
| GET | `/api/chat/conversations/:id` | Get conversation with all messages | | GET | `/api/chat/conversations/:id` | Get conversation with all messages |
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to 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?}`) | | 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 | | 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/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/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 | | 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/status` | Check Ollama availability and model readiness (`{ollama, model, default_model}`) |
| GET | `/api/chat/models` | List available Ollama models | | 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) | | 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` - Set `down_revision` to chain from the previous migration's `revision`
- Data migrations (like 0003) should use `op.get_bind()` + `sa.text()` for queries - Data migrations (like 0003) should use `op.get_bind()` + `sa.text()` for queries
## Phased Roadmap ## Implemented Features
### Phase 1 — Skeleton & Dev Environment ✓ ### Notes & Tasks
- [x] Initialize Python project (pyproject.toml, Quart app scaffold) - Full CRUD with markdown bodies, Obsidian-style `#tag` extraction (skips code fences), hierarchical tag filtering
- [x] Initialize Vue.js project (Vite-based, inside `frontend/`) - Unified data model: a task is a note with `status IS NOT NULL` — convert freely between note ↔ task
- [x] Set up Dockerfile (multi-stage: build Vue, serve with Quart) - Task attributes: status (todo/in_progress/done), priority (none/low/medium/high), due_date
- [x] Docker Compose stack with Ollama service - Obsidian-style `[[wikilinks]]` with auto-create on click, backlinks ("what links here")
- [x] Quart serves Vue static build + `/api/health` endpoint - Tiptap WYSIWYG editor with markdown round-trip, tag/wikilink autocomplete and decorations, sticky toolbar
- [x] Database setup (PostgreSQL 16, asyncpg, SQLAlchemy 2.0, Alembic) - 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 ✓ ### Dashboard
- [x] Database model for notes (title, body, tags[], parent_id, timestamps) - Actionable home page with 5 task sections: Overdue, Due Today, Due This Week, High Priority, In Progress
- [x] REST API: create, read, update, delete, list notes with pagination - Priority-aware sorting (priority desc → due date asc) within each section
- [x] Vue views: note list, note editor (markdown), note viewer (rendered) - Cascading deduplication — tasks appear only in their highest-priority section
- [x] Search (ILIKE on title/body) and tag filtering (hierarchical via unnest) - `due_before`/`due_after` query params on `/api/tasks` for date-range filtering
- [x] Inline `#tag` extraction from body text (backend regex, skips code fences) - Recent chats and recently edited notes sections
- [x] Hierarchical tag filtering (`#project` matches `project` and `project/*`) - All task sections hidden when empty; marking done removes from all lists
- [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)
### Phase 3 — Tasks CRUD + Wikilinks ✓ ### LLM Chat
- [x] Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums - Ollama integration via async HTTP (httpx), auto-pull default model on startup
- [x] Alembic migration for tasks table with PG enums and indexes - Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup)
- [x] REST API: full CRUD + PATCH status toggle + filter by status/priority/tags - Stop generation with partial content preservation
- [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (Ctrl+S, dirty guard), viewer (rendered markdown) - Note-aware context building: current note + keyword search for related notes + URL fetching
- [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components - Context pills with promote/exclude controls; note picker (paperclip) in chat input
- [x] Obsidian-style wikilinks: `[[Title]]` and `[[Title|Display]]` in rendered markdown - LLM-generated conversation titles (re-generated every 10th message)
- [x] Wikilink click handling resolves notes by title via `/api/notes/by-title` - 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 ✓ ### Authentication & User Management
- [x] **Wikilink auto-create:** Clicking `[[New Page]]` creates the note if it doesn't exist - Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming
- [x] **Backlinks system:** "What links here" section on note and task viewers - Per-user data isolation across all resources
- [x] **Rendered markdown previews:** NoteCard/TaskCard show rendered markdown (links/images stripped) - Registration auto-closes after first user; admin toggle; invitation-based registration
- [x] **Tag autocomplete Tab cycling:** Tab cycles through suggestions, single match accepts immediately - Email invitation system: admin sends invite → branded email → `/register-invite` token flow
- [x] **HomeView error isolation:** Notes and tasks load independently (one failure doesn't break both) - Password reset: email-based, SHA256-hashed tokens, 1-hour expiry
- [x] **SPA routing fix:** Replaced catch-all route with 404 error handler to prevent API interception - Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag
- [x] **500 error handler:** JSON error responses for API routes, traceback logging - Admin user management: list, delete, invite, revoke
- [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
### Phase 3.6 — Merge Tasks into Notes ✓ ### Notifications & Email
- [x] **Unified note/task model:** Task is just a note with `status IS NOT NULL` - SMTP email service (aiosmtplib): STARTTLS (587) and implicit TLS (465)
- [x] **Migration 0004:** Added `status`, `priority`, `due_date` columns to notes table, migrated task data from companion notes and orphan tasks, dropped `tasks` table - Security alerts: login, failed login, logout, password change (fire-and-forget)
- [x] **Eliminated companion note system:** No more companion note creation, title sync, cascade deletes, or `_skip_cascade` flags - Task due date reminders: hourly background check, grouped per user, dedup via logs
- [x] **Standardized on `body`:** Tasks use `body` everywhere (not `description`); API accepts `description` as fallback - Invitation and password reset emails with branded HTML templates
- [x] **Convert to task:** Simple `update_note(id, status='todo', priority='none')` — same ID preserved - Per-user notification preferences (task reminders, security alerts)
- [x] **Convert to note:** New `POST /api/notes/:id/convert-to-note` endpoint clears task attributes - Admin SMTP configuration via Settings UI with test email
- [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
### Phase 4 — LLM Chat Integration ✓ ### Logging & Observability
- [x] **Ollama client:** async HTTP via httpx — `ensure_model()` (auto-pull on startup), `stream_chat()` (streaming NDJSON), `generate_completion()` (non-streaming) - Single `app_logs` table: audit (security events), usage (API requests), error (unhandled exceptions)
- [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 - Request logging middleware with timing (skips log endpoints to avoid recursion)
- [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD - Admin log viewer: stats summary, category/search/date filters, paginated table, expandable JSON details
- [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish - Configurable retention via `LOG_RETENTION_DAYS` (default 90), hourly cleanup
- [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)
### Phase 4.5 — Chat UX + Settings + Model Management ✓ ### Settings & Admin
- [x] **Settings infrastructure:** Key-value `settings` table, `GET/PUT /api/settings`, Pinia store with defaults - Per-user key-value settings store (assistant name, default model, notification prefs)
- [x] **Configurable assistant name:** Default "Fable", editable in settings, injected into LLM system prompt and chat message labels - Admin: backup/restore (full or per-user JSON), SMTP config, base URL, registration toggle, user management, log viewer
- [x] **Settings page:** `/settings` route with assistant name form + model catalog - Configurable base URL for email links (admin setting, env var fallback)
- [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 `&#39;` as a tag. Additional layers: decode entities before marked, DOMPurify sanitization, explicit `&#39;` → `'` 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
### Phase 4.6 — Improved Note Context in Chat ✓ ### UI & Theming
- [x] **Multi-word search:** `list_notes()` splits multi-word queries into per-term ILIKE filters with AND logic (each word matched independently, not adjacent) - Dark/light theme with CSS custom properties, design tokens, `prefers-color-scheme` detection
- [x] **Context metadata:** `build_context()` returns `(messages, context_meta)` tuple with auto-found note IDs/titles and attached note info - Responsive design: breakpoints (480/768/1024), hamburger menu, mobile touch targets (44px), sidebar overlays
- [x] **Fuller auto-context:** Auto-found note previews increased from 300 to 2000 chars; uses all 5 extracted keywords instead of 3 - App shell: navbar always visible, 100dvh flex layout, all views fit within viewport
- [x] **Context SSE event:** Backend emits `{context: ContextMeta}` SSE event before streaming LLM response chunks - Toast notifications (success/error/warning, 4s auto-dismiss)
- [x] **Exclude note IDs:** Backend accepts `exclude_note_ids` in request body, skips those notes during auto-search - DOMPurify sanitization on all rendered markdown
- [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
### Phase 4.7 — Styling Consistency Pass ✓ ### Infrastructure
- [x] **Design tokens:** Added `--radius-sm/md/lg/pill`, `--color-success/warning/overlay`, `--focus-ring` to theme.css - Multi-stage Dockerfile: Node build → Python runtime, auto-migration on startup
- [x] **Header alignment fix:** Removed `max-width: 960px` from header nav — now full-width with consistent padding, matching modern app layout - Docker Compose (dev) + Docker Swarm production stack with secrets, overlay networks, health checks
- [x] **Active nav link state:** Added `router-link-active` styling with primary color + card background - Quart serves Vue SPA from static + REST API under `/api/`; 404 handler for SPA routing
- [x] **Hardcoded colors replaced:** Status indicator dots, settings saved message, remove/confirm-delete buttons now use CSS variables - Alembic migrations: 12 migrations, all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
- [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 - Config from env vars + Docker secrets file support (`_read_secret`)
- [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
## Development Workflow ## Development Workflow
- All development and testing done via Docker: `docker compose up --build` - 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 Quart serves them
- To reset database: `docker compose down -v && docker compose up --build` - To reset database: `docker compose down -v && docker compose up --build`
## Current Status ## Backlog
**Phase:** Phase 5.9 complete. Invitation system, table of contents, actionable dashboard, settings improvements. - Tagging/labeling system with LLM-suggested tags
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags - Calendar/timeline view for tasks
- **Tiptap WYSIWYG editor** with inline formatting preview, markdown round-trip, paste handling - Import/export (Markdown files, JSON)
- **Tag/wikilink autocomplete** via `@tiptap/suggestion` with heading disambiguation - Application-level rate limiting on auth endpoints
- Unified note/task model (a task is a note with `status IS NOT NULL`) - Security headers middleware (CSP, X-Frame-Options, etc.) — currently handled at reverse proxy level
- **Multi-user authentication** with session cookies, bcrypt passwords, admin role - Session invalidation on user deletion
- **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