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
|
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">
|
||||||
|
×
|
||||||
|
</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 {
|
.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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 `'` 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
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user