Add multi-user auth, background generation, and chat UX improvements

Phase 5: Multi-user authentication with session cookies, bcrypt passwords,
first-user-is-admin pattern, per-user data isolation, backup/restore,
Docker Swarm production stack with secrets and network isolation.

Phase 5.1: Chat UX improvements:
- Background generation architecture (GenerationBuffer + asyncio task)
  with SSE fan-out, reconnect support, and periodic DB flushes
- LLM-generated conversation titles (first exchange + every 10th message)
- Stop generation button with cancel_event and partial content preservation
- Relative timestamps in sidebar (5m ago, 3h ago, then dates)
- Empty chat auto-cleanup on navigation away
- Save-as-note uses LLM for title generation, tags notes with "chat"
- Summarize-as-note also tags with "chat"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 14:36:30 -05:00
parent db01106714
commit cbfdf5289e
49 changed files with 3105 additions and 369 deletions
+1 -1
View File
@@ -22,4 +22,4 @@ COPY alembic/ alembic/
ENV PYTHONPATH=/app/src ENV PYTHONPATH=/app/src
EXPOSE 5000 EXPOSE 5000
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000"] CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
@@ -0,0 +1,121 @@
"""add users table and user_id columns
Revision ID: 0008
Revises: 0007
Create Date: 2026-02-11 00:00:00.000000
"""
from alembic import op
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table
op.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ DEFAULT now()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_users_username ON users (username)")
# Add user_id columns WITHOUT FK constraints first (so we can assign existing rows)
op.execute("""
DO $$ BEGIN
ALTER TABLE notes ADD COLUMN user_id INTEGER;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$
""")
op.execute("""
DO $$ BEGIN
ALTER TABLE conversations ADD COLUMN user_id INTEGER;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$
""")
op.execute("""
DO $$ BEGIN
ALTER TABLE settings ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$
""")
# Assign all existing data to user_id=1 (the first registered user gets SERIAL id=1)
op.execute("UPDATE notes SET user_id = 1 WHERE user_id IS NULL")
op.execute("UPDATE conversations SET user_id = 1 WHERE user_id IS NULL")
op.execute("UPDATE settings SET user_id = 1 WHERE user_id != 1")
# Restructure settings PK: drop old single-column PK, create composite (user_id, key)
op.execute("""
DO $$ BEGIN
ALTER TABLE settings DROP CONSTRAINT settings_pkey;
EXCEPTION WHEN undefined_object THEN NULL;
END $$
""")
op.execute("""
DO $$ BEGIN
ALTER TABLE settings ADD CONSTRAINT settings_pkey PRIMARY KEY (user_id, key);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
""")
# Now add FK constraints with NOT VALID (skips validation of existing rows
# where user_id=1 has no matching user yet — becomes valid once first user registers)
op.execute("""
DO $$ BEGIN
ALTER TABLE notes ADD CONSTRAINT notes_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE NOT VALID;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
""")
op.execute("""
DO $$ BEGIN
ALTER TABLE conversations ADD CONSTRAINT conversations_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE NOT VALID;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
""")
op.execute("""
DO $$ BEGIN
ALTER TABLE settings ADD CONSTRAINT settings_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE NOT VALID;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_notes_user_id ON notes (user_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_conversations_user_id ON conversations (user_id)")
def downgrade() -> None:
# Reverse settings changes
op.execute("""
DO $$ BEGIN
ALTER TABLE settings DROP CONSTRAINT settings_pkey;
EXCEPTION WHEN undefined_object THEN NULL;
END $$
""")
op.execute("ALTER TABLE settings ALTER COLUMN user_id DROP NOT NULL")
op.execute("""
DO $$ BEGIN
ALTER TABLE settings ADD CONSTRAINT settings_pkey PRIMARY KEY (key);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
""")
op.execute("ALTER TABLE settings DROP COLUMN IF EXISTS user_id")
op.execute("DROP INDEX IF EXISTS ix_conversations_user_id")
op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS user_id")
op.execute("DROP INDEX IF EXISTS ix_notes_user_id")
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS user_id")
op.execute("DROP INDEX IF EXISTS ix_users_username")
op.execute("DROP TABLE IF EXISTS users")
@@ -0,0 +1,26 @@
"""add message status column
Revision ID: 0009
Revises: 0008
Create Date: 2026-02-11 00:00:00.000000
"""
from alembic import op
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
DO $$ BEGIN
ALTER TABLE messages ADD COLUMN status TEXT NOT NULL DEFAULT 'complete';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$
""")
def downgrade() -> None:
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS status")
+109
View File
@@ -0,0 +1,109 @@
services:
app:
build: .
ports:
- "5000:5000"
depends_on:
db:
condition: service_healthy
ollama:
condition: service_started
environment:
DATABASE_URL_FILE: /run/secrets/fabled_assistant_db_url
SECRET_KEY_FILE: /run/secrets/fabled_assistant_secret_key
OLLAMA_URL: "http://ollama:11434"
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
secrets:
- fabled_assistant_db_url
- fabled_assistant_secret_key
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
deploy:
resources:
limits:
memory: 512M
restart_policy:
condition: on-failure
max_attempts: 5
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: fabled
POSTGRES_PASSWORD_FILE: /run/secrets/fabled_assistant_db_password
POSTGRES_DB: fabledassistant
secrets:
- fabled_assistant_db_password
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fabled"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 256M
restart_policy:
condition: on-failure
max_attempts: 5
ollama:
image: ollama/ollama
volumes:
- ollama_models:/root/.ollama
networks:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
deploy:
placement:
constraints:
- node.role == worker
resources:
limits:
memory: 8G
restart_policy:
condition: on-failure
max_attempts: 3
# To enable GPU support, uncomment the section below
# (requires nvidia-container-toolkit)
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
volumes:
pgdata:
ollama_models:
networks:
frontend:
backend:
internal: true
secrets:
fabled_assistant_secret_key:
external: true
fabled_assistant_db_password:
external: true
fabled_assistant_db_url:
external: true
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Fabled Assistant</title> <title>Fabled Assistant</title>
</head> </head>
<body> <body>
+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<style>
.book { fill: #2d3748; stroke: #4a5568; }
.spine { stroke: #4a5568; }
.lines { stroke: #a0aec0; }
@media (prefers-color-scheme: dark) {
.book { fill: #e2e8f0; stroke: #cbd5e0; }
.spine { stroke: #cbd5e0; }
.lines { stroke: #4a5568; }
}
</style>
<!-- Book body -->
<path class="book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
<!-- Book spine -->
<line class="spine" x1="16" y1="6" x2="16" y2="25" stroke-width="0.8"/>
<!-- Left page lines -->
<line class="lines" x1="7" y1="11" x2="14" y2="10.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="7" y1="14" x2="14" y2="13.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="7" y1="17" x2="14" y2="16.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="7" y1="20" x2="12" y2="19.5" stroke-width="0.6" stroke-linecap="round"/>
<!-- Right page lines -->
<line class="lines" x1="18" y1="10.5" x2="25" y2="11" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="18" y1="13.5" x2="25" y2="14" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="18" y1="16.5" x2="25" y2="17" stroke-width="0.6" stroke-linecap="round"/>
<!-- Magic spark -->
<g transform="translate(24, 5)">
<path d="M0 -4 L1 -1 L4 0 L1 1 L0 4 L-1 1 L-4 0 L-1 -1 Z" fill="#f6ad55"/>
<path d="M0 -2.5 L0.6 -0.6 L2.5 0 L0.6 0.6 L0 2.5 L-0.6 0.6 L-2.5 0 L-0.6 -0.6 Z" fill="#fbd38d"/>
</g>
<!-- Secondary sparkles -->
<circle cx="20" cy="3" r="0.7" fill="#f6ad55" opacity="0.7"/>
<circle cx="27" cy="8" r="0.5" fill="#fbd38d" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+39 -10
View File
@@ -1,16 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue"; import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import AppHeader from "@/components/AppHeader.vue"; import AppHeader from "@/components/AppHeader.vue";
import ChatPanel from "@/components/ChatPanel.vue"; import ChatPanel from "@/components/ChatPanel.vue";
import ToastNotification from "@/components/ToastNotification.vue"; import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme"; import { useTheme } from "@/composables/useTheme";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
useTheme(); useTheme();
const route = useRoute(); const route = useRoute();
const authStore = useAuthStore();
const chatStore = useChatStore(); const chatStore = useChatStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const chatPanelOpen = ref(false); const chatPanelOpen = ref(false);
@@ -29,23 +31,50 @@ function toggleChatPanel() {
chatPanelOpen.value = !chatPanelOpen.value; chatPanelOpen.value = !chatPanelOpen.value;
} }
onMounted(() => { function startAppServices() {
chatStore.startStatusPolling(); chatStore.startStatusPolling();
settingsStore.fetchSettings(); settingsStore.fetchSettings();
}
function stopAppServices() {
chatStore.stopStatusPolling();
}
onMounted(async () => {
await authStore.checkAuth();
if (authStore.isAuthenticated) {
startAppServices();
}
}); });
watch(
() => authStore.isAuthenticated,
(authenticated) => {
if (authenticated) {
startAppServices();
} else {
stopAppServices();
}
}
);
onUnmounted(() => { onUnmounted(() => {
chatStore.stopStatusPolling(); stopAppServices();
}); });
</script> </script>
<template> <template>
<AppHeader @toggle-chat-panel="toggleChatPanel" /> <template v-if="authStore.isAuthenticated">
<router-view /> <AppHeader @toggle-chat-panel="toggleChatPanel" />
<ChatPanel <router-view />
v-if="chatPanelOpen" <ChatPanel
:context-note-id="contextNoteId" v-if="chatPanelOpen"
@close="chatPanelOpen = false" :context-note-id="contextNoteId"
/> @close="chatPanelOpen = false"
/>
</template>
<template v-else>
<router-view />
</template>
<ToastNotification /> <ToastNotification />
</template> </template>
+203 -34
View File
@@ -1,21 +1,55 @@
export async function apiGet<T>(path: string): Promise<T> { export class ApiError extends Error {
const res = await fetch(path); status: number;
body: Record<string, unknown>;
constructor(status: number, body: Record<string, unknown>) {
const msg = (body.error as string) || `API error: ${status}`;
super(msg);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
async function handleResponse<T>(res: Response, path: string): Promise<T> {
if (!res.ok) { if (!res.ok) {
throw new Error(`API error: ${res.status}`); let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
// Redirect to login on 401 (except for auth endpoints)
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
const currentPath = window.location.pathname;
if (currentPath !== "/login" && currentPath !== "/register") {
router.push({ name: "login", query: { redirect: currentPath } });
}
}
throw new ApiError(res.status, body);
}
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
} }
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
return handleResponse<T>(res, path);
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> { export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) { return handleResponse<T>(res, path);
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
} }
export async function apiPut<T>(path: string, body: unknown): Promise<T> { export async function apiPut<T>(path: string, body: unknown): Promise<T> {
@@ -24,10 +58,7 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) { return handleResponse<T>(res, path);
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
} }
export async function apiPatch<T>(path: string, body: unknown): Promise<T> { export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
@@ -36,17 +67,132 @@ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) { return handleResponse<T>(res, path);
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
} }
export async function apiDelete(path: string): Promise<void> { export async function apiDelete(path: string): Promise<void> {
const res = await fetch(path, { method: "DELETE" }); const res = await fetch(path, { method: "DELETE" });
if (!res.ok) { return handleResponse<void>(res, path);
throw new Error(`API error: ${res.status}`); }
export interface SSEStreamHandle {
close(): void;
/** Resolves when the stream closes (normally or via error/abort). */
done: Promise<void>;
}
export interface SSENamedEvent {
id: number;
event: string;
data: Record<string, unknown>;
}
export function apiSSEStream(
path: string,
onEvent: (event: SSENamedEvent) => void,
options?: { lastEventId?: number; signal?: AbortSignal },
): SSEStreamHandle {
const controller = new AbortController();
const combinedSignal = options?.signal
? AbortSignal.any([controller.signal, options.signal])
: controller.signal;
const headers: Record<string, string> = {};
if (options?.lastEventId !== undefined) {
headers["Last-Event-ID"] = String(options.lastEventId);
} }
const done = (async () => {
const res = await fetch(path, { headers, signal: combinedSignal });
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
body = await res.json();
} catch {
body = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, body);
}
const reader = res.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
// SSE field parsing state
let currentId = -1;
let currentEvent = "message";
let currentData = "";
function dispatch() {
if (!currentData) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(currentData);
} catch {
return;
}
onEvent({ id: currentId, event: currentEvent, data: parsed });
// Reset for next event
currentEvent = "message";
currentData = "";
}
function processLine(line: string) {
if (line === "") {
// Empty line = end of event
dispatch();
return;
}
if (line.startsWith(":")) return; // comment / keepalive
const colonIdx = line.indexOf(":");
let field: string;
let value: string;
if (colonIdx === -1) {
field = line;
value = "";
} else {
field = line.slice(0, colonIdx);
value = line.slice(colonIdx + 1);
if (value.startsWith(" ")) value = value.slice(1);
}
switch (field) {
case "id":
currentId = parseInt(value, 10);
break;
case "event":
currentEvent = value;
break;
case "data":
currentData += value;
break;
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
processLine(line);
}
}
// Process remaining buffer
if (buffer) {
processLine(buffer);
}
dispatch();
})().catch(() => {
// Stream closed or aborted — handled by caller via reconnection
});
return { close: () => controller.abort(), done };
} }
export async function apiStreamPost( export async function apiStreamPost(
@@ -60,7 +206,19 @@ export async function apiStreamPost(
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`API error: ${res.status}`); let errBody: Record<string, unknown> = {};
try {
errBody = await res.json();
} catch {
errBody = { error: `API error: ${res.status}` };
}
if (res.status === 401 && !path.startsWith("/api/auth/")) {
const { default: router } = await import("@/router/index");
router.push({ name: "login" });
}
throw new ApiError(res.status, errBody);
} }
const reader = res.body?.getReader(); const reader = res.body?.getReader();
if (!reader) throw new Error("No response body"); if (!reader) throw new Error("No response body");
@@ -68,35 +226,46 @@ export async function apiStreamPost(
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
while (true) { function processLines(text: string) {
const { done, value } = await reader.read(); const lines = text.split("\n");
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last (possibly incomplete) line in the buffer // Keep the last (possibly incomplete) line in the buffer
buffer = lines.pop() || ""; buffer = lines.pop() || "";
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed.startsWith("data: ")) { if (trimmed.startsWith("data: ")) {
let data;
try { try {
const data = JSON.parse(trimmed.slice(6)); data = JSON.parse(trimmed.slice(6));
onChunk(data);
} catch { } catch {
// Skip malformed JSON lines continue; // Skip malformed JSON lines
} }
onChunk(data);
} }
} }
} }
// Process any remaining buffer try {
if (buffer.trim().startsWith("data: ")) { while (true) {
try { const { done, value } = await reader.read();
const data = JSON.parse(buffer.trim().slice(6)); if (done) break;
onChunk(data); buffer += decoder.decode(value, { stream: true });
} catch { processLines(buffer);
// Skip malformed JSON
} }
} catch {
// Stream may close with a network error after all data was sent.
// Process whatever remains in the buffer before deciding to throw.
}
// Process any remaining buffer
const remaining = buffer.trim();
if (remaining.startsWith("data: ")) {
let data;
try {
data = JSON.parse(remaining.slice(6));
} catch {
return; // Skip malformed JSON
}
onChunk(data);
} }
} }
+18
View File
@@ -110,3 +110,21 @@ button:focus-visible {
outline: none; outline: none;
box-shadow: var(--focus-ring); box-shadow: var(--focus-ring);
} }
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
@media (max-width: 768px) {
.hide-mobile {
display: none !important;
}
button,
[role="button"],
.btn-new-conv,
.btn-send {
min-height: 44px;
}
}
@media (min-width: 769px) {
.hide-desktop {
display: none !important;
}
}
+140 -3
View File
@@ -1,10 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import { useTheme } from "@/composables/useTheme"; import { useTheme } from "@/composables/useTheme";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat"; import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue";
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const authStore = useAuthStore();
const chatStore = useChatStore(); const chatStore = useChatStore();
const router = useRouter();
const mobileMenuOpen = ref(false);
const emit = defineEmits<{ const emit = defineEmits<{
toggleChatPanel: []; toggleChatPanel: [];
@@ -23,13 +29,38 @@ const statusClass = computed(() => {
if (chatStore.chatReady) return "status-green"; if (chatStore.chatReady) return "status-green";
return "status-yellow"; return "status-yellow";
}); });
function toggleMobileMenu() {
mobileMenuOpen.value = !mobileMenuOpen.value;
}
async function handleLogout() {
await authStore.logout();
mobileMenuOpen.value = false;
router.push("/login");
}
// Close mobile menu on route change
router.afterEach(() => {
mobileMenuOpen.value = false;
});
</script> </script>
<template> <template>
<header class="app-header"> <header class="app-header">
<nav class="nav"> <nav class="nav">
<router-link to="/" class="nav-brand">Fabled Assistant</router-link> <router-link to="/" class="nav-brand">
<div class="nav-links"> <AppLogo />
Fabled Assistant
</router-link>
<button class="hamburger" @click="toggleMobileMenu" aria-label="Toggle menu">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<div class="nav-links" :class="{ open: mobileMenuOpen }">
<router-link to="/notes" class="nav-link">Notes</router-link> <router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link> <router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" class="nav-link">Chat</router-link> <router-link to="/chat" class="nav-link">Chat</router-link>
@@ -43,6 +74,13 @@ const statusClass = computed(() => {
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`"> <button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }} {{ theme === "dark" ? "\u2600" : "\u263E" }}
</button> </button>
<div class="user-info">
<span class="username">{{ authStore.user?.username }}</span>
<span v-if="authStore.isAdmin" class="admin-badge">admin</span>
<button class="btn-logout" @click="handleLogout" title="Sign out">
Sign out
</button>
</div>
</div> </div>
</nav> </nav>
</header> </header>
@@ -60,11 +98,30 @@ const statusClass = computed(() => {
justify-content: space-between; justify-content: space-between;
} }
.nav-brand { .nav-brand {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 700; font-weight: 700;
font-size: 1.1rem; font-size: 1.1rem;
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }
.hamburger {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
flex-direction: column;
gap: 4px;
}
.hamburger-line {
display: block;
width: 20px;
height: 2px;
background: var(--color-text);
border-radius: 1px;
}
.nav-links { .nav-links {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -139,4 +196,84 @@ const statusClass = computed(() => {
.theme-toggle:hover { .theme-toggle:hover {
background: var(--color-bg-card); background: var(--color-bg-card);
} }
.user-info {
display: flex;
align-items: center;
gap: 0.4rem;
margin-left: 0.5rem;
}
.username {
font-size: 0.85rem;
color: var(--color-text-secondary);
font-weight: 500;
}
.admin-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
}
.btn-logout {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.2rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.btn-logout:hover {
color: var(--color-danger);
border-color: var(--color-danger);
}
@media (max-width: 768px) {
.hamburger {
display: flex;
}
.nav-links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-direction: column;
padding: 0.75rem 1rem;
gap: 0.25rem;
z-index: 100;
}
.nav-links.open {
display: flex;
}
.nav-link {
padding: 0.5rem 0.75rem;
min-height: 44px;
display: flex;
align-items: center;
}
.status-indicator {
margin-left: 0;
}
.user-info {
margin-left: 0;
margin-top: 0.25rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
width: 100%;
}
.btn-chat-panel,
.theme-toggle,
.btn-logout {
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
</style> </style>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
defineProps<{ size?: number }>();
</script>
<template>
<svg
class="app-logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
:width="size ?? 24"
:height="size ?? 24"
aria-hidden="true"
>
<!-- Book body -->
<path class="logo-book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
<!-- Book spine -->
<line class="logo-spine" x1="16" y1="6" x2="16" y2="25" stroke-width="0.8"/>
<!-- Left page lines -->
<line class="logo-lines" x1="7" y1="11" x2="14" y2="10.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="7" y1="14" x2="14" y2="13.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="7" y1="17" x2="14" y2="16.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="7" y1="20" x2="12" y2="19.5" stroke-width="0.6" stroke-linecap="round"/>
<!-- Right page lines -->
<line class="logo-lines" x1="18" y1="10.5" x2="25" y2="11" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="18" y1="13.5" x2="25" y2="14" stroke-width="0.6" stroke-linecap="round"/>
<line class="logo-lines" x1="18" y1="16.5" x2="25" y2="17" stroke-width="0.6" stroke-linecap="round"/>
<!-- Magic spark -->
<g transform="translate(24, 5)">
<path d="M0 -4 L1 -1 L4 0 L1 1 L0 4 L-1 1 L-4 0 L-1 -1 Z" fill="#f6ad55"/>
<path d="M0 -2.5 L0.6 -0.6 L2.5 0 L0.6 0.6 L0 2.5 L-0.6 0.6 L-2.5 0 L-0.6 -0.6 Z" fill="#fbd38d"/>
</g>
<!-- Secondary sparkles -->
<circle cx="20" cy="3" r="0.7" fill="#f6ad55" opacity="0.7"/>
<circle cx="27" cy="8" r="0.5" fill="#fbd38d" opacity="0.6"/>
</svg>
</template>
<style scoped>
.logo-book {
fill: var(--color-text);
stroke: var(--color-text-secondary);
}
.logo-spine {
stroke: var(--color-text-secondary);
}
.logo-lines {
stroke: var(--color-text-muted);
}
</style>
+31 -2
View File
@@ -13,7 +13,8 @@ const toastStore = useToastStore();
class="toast" class="toast"
:class="`toast--${toast.type}`" :class="`toast--${toast.type}`"
> >
{{ toast.message }} <span class="toast-msg">{{ toast.message }}</span>
<button class="toast-close" @click="toastStore.dismiss(toast.id)">&times;</button>
</div> </div>
</transition-group> </transition-group>
</div> </div>
@@ -30,19 +31,47 @@ const toastStore = useToastStore();
gap: 0.5rem; gap: 0.5rem;
} }
.toast { .toast {
padding: 0.75rem 1.25rem; display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 6px; border-radius: 6px;
color: #fff; color: #fff;
font-size: 0.9rem; font-size: 0.9rem;
box-shadow: 0 2px 8px var(--color-shadow); box-shadow: 0 2px 8px var(--color-shadow);
min-width: 200px; min-width: 200px;
} }
.toast-msg {
flex: 1;
}
.toast-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0 0.15rem;
}
.toast-close:hover {
color: #fff;
}
.toast--success { .toast--success {
background: var(--color-toast-success); background: var(--color-toast-success);
} }
.toast--error { .toast--error {
background: var(--color-toast-error); background: var(--color-toast-error);
} }
.toast--warning {
background: var(--color-warning);
color: #1a1a1a;
}
.toast--warning .toast-close {
color: rgba(0, 0, 0, 0.5);
}
.toast--warning .toast-close:hover {
color: #1a1a1a;
}
.toast-enter-active, .toast-enter-active,
.toast-leave-active { .toast-leave-active {
transition: all 0.3s ease; transition: all 0.3s ease;
+28
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import HomeView from "@/views/HomeView.vue"; import HomeView from "@/views/HomeView.vue";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -9,6 +10,18 @@ const router = createRouter({
name: "home", name: "home",
component: HomeView, component: HomeView,
}, },
{
path: "/login",
name: "login",
component: () => import("@/views/LoginView.vue"),
meta: { public: true },
},
{
path: "/register",
name: "register",
component: () => import("@/views/RegisterView.vue"),
meta: { public: true },
},
{ {
path: "/notes", path: "/notes",
name: "notes", name: "notes",
@@ -67,4 +80,19 @@ const router = createRouter({
], ],
}); });
router.beforeEach(async (to) => {
if (to.meta.public) return;
const authStore = useAuthStore();
// Wait for initial auth check if still loading
if (authStore.loading && !authStore.isAuthenticated) {
await authStore.checkAuth();
}
if (!authStore.isAuthenticated) {
return { name: "login", query: { redirect: to.fullPath } };
}
});
export default router; export default router;
+63
View File
@@ -0,0 +1,63 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost } from "@/api/client";
import type { User, AuthStatus } from "@/types/auth";
export const useAuthStore = defineStore("auth", () => {
const user = ref<User | null>(null);
const loading = ref(true);
const hasUsers = ref(true);
const isAuthenticated = computed(() => user.value !== null);
const isAdmin = computed(() => user.value?.role === "admin");
async function checkAuth() {
loading.value = true;
try {
user.value = await apiGet<User>("/api/auth/me");
} catch {
user.value = null;
} finally {
loading.value = false;
}
}
async function checkHasUsers() {
try {
const data = await apiGet<AuthStatus>("/api/auth/status");
hasUsers.value = data.has_users;
} catch {
hasUsers.value = true;
}
}
async function login(username: string, password: string) {
user.value = await apiPost<User>("/api/auth/login", { username, password });
}
async function register(username: string, password: string, email?: string) {
user.value = await apiPost<User>("/api/auth/register", {
username,
password,
email: email || undefined,
});
}
async function logout() {
await apiPost("/api/auth/logout", {});
user.value = null;
}
return {
user,
loading,
hasUsers,
isAuthenticated,
isAdmin,
checkAuth,
checkHasUsers,
login,
register,
logout,
};
});
+153 -58
View File
@@ -5,8 +5,9 @@ import {
apiPost, apiPost,
apiPatch, apiPatch,
apiDelete, apiDelete,
apiStreamPost, apiSSEStream,
} from "@/api/client"; } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { import type {
Conversation, Conversation,
ConversationDetail, ConversationDetail,
@@ -14,6 +15,7 @@ import type {
Message, Message,
OllamaModel, OllamaModel,
OllamaStatus, OllamaStatus,
SendMessageResponse,
} from "@/types/chat"; } from "@/types/chat";
export const useChatStore = defineStore("chat", () => { export const useChatStore = defineStore("chat", () => {
@@ -42,6 +44,9 @@ export const useChatStore = defineStore("chat", () => {
); );
conversations.value = data.conversations; conversations.value = data.conversations;
total.value = data.total; total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load conversations", "error");
throw e;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -51,12 +56,17 @@ export const useChatStore = defineStore("chat", () => {
title = "", title = "",
model = "" model = ""
): Promise<Conversation> { ): Promise<Conversation> {
const conv = await apiPost<Conversation>("/api/chat/conversations", { try {
title, const conv = await apiPost<Conversation>("/api/chat/conversations", {
model, title,
}); model,
conversations.value.unshift(conv); });
return conv; conversations.value.unshift(conv);
return conv;
} catch (e) {
useToastStore().show("Failed to create conversation", "error");
throw e;
}
} }
async function fetchConversation(id: number) { async function fetchConversation(id: number) {
@@ -65,30 +75,43 @@ export const useChatStore = defineStore("chat", () => {
currentConversation.value = await apiGet<ConversationDetail>( currentConversation.value = await apiGet<ConversationDetail>(
`/api/chat/conversations/${id}` `/api/chat/conversations/${id}`
); );
} catch (e) {
useToastStore().show("Failed to load conversation", "error");
throw e;
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
async function deleteConversation(id: number) { async function deleteConversation(id: number) {
await apiDelete(`/api/chat/conversations/${id}`); try {
conversations.value = conversations.value.filter((c) => c.id !== id); await apiDelete(`/api/chat/conversations/${id}`);
if (currentConversation.value?.id === id) { conversations.value = conversations.value.filter((c) => c.id !== id);
currentConversation.value = null; if (currentConversation.value?.id === id) {
currentConversation.value = null;
}
} catch (e) {
useToastStore().show("Failed to delete conversation", "error");
throw e;
} }
} }
async function updateTitle(id: number, title: string) { async function updateTitle(id: number, title: string) {
const updated = await apiPatch<Conversation>( try {
`/api/chat/conversations/${id}`, const updated = await apiPatch<Conversation>(
{ title } `/api/chat/conversations/${id}`,
); { title }
const idx = conversations.value.findIndex((c) => c.id === id); );
if (idx !== -1) { const idx = conversations.value.findIndex((c) => c.id === id);
conversations.value[idx] = { ...conversations.value[idx], ...updated }; if (idx !== -1) {
} conversations.value[idx] = { ...conversations.value[idx], ...updated };
if (currentConversation.value?.id === id) { }
currentConversation.value.title = updated.title; if (currentConversation.value?.id === id) {
currentConversation.value.title = updated.title;
}
} catch (e) {
useToastStore().show("Failed to update title", "error");
throw e;
} }
} }
@@ -116,53 +139,124 @@ export const useChatStore = defineStore("chat", () => {
streaming.value = true; streaming.value = true;
streamingContent.value = ""; streamingContent.value = "";
// Phase 1: POST to start generation
let assistantMessageId: number;
try { try {
await apiStreamPost( const resp = await apiPost<SendMessageResponse>(
`/api/chat/conversations/${convId}/messages`, `/api/chat/conversations/${convId}/messages`,
{ {
content, content,
context_note_id: contextNoteId, context_note_id: contextNoteId,
exclude_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined, exclude_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
}, },
(data) => {
if (data.context) {
lastContextMeta.value = data.context as ContextMeta;
}
if (data.chunk) {
streamingContent.value += data.chunk as string;
}
if (data.done) {
// Add the final assistant message
const assistantMsg: Message = {
id: data.message_id as number,
conversation_id: convId,
role: "assistant",
content: streamingContent.value,
context_note_id: null,
created_at: new Date().toISOString(),
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
}
streamingContent.value = "";
streaming.value = false;
// Update conversation in list
const idx = conversations.value.findIndex((c) => c.id === convId);
if (idx !== -1) {
conversations.value[idx].message_count += 2;
conversations.value[idx].updated_at = new Date().toISOString();
}
}
if (data.error) {
streaming.value = false;
streamingContent.value = "";
}
}
); );
} catch { assistantMessageId = resp.assistant_message_id;
} catch (e) {
streaming.value = false; streaming.value = false;
streamingContent.value = ""; streamingContent.value = "";
useToastStore().show("Failed to send message", "error");
return;
}
// Phase 2: Connect to SSE stream with reconnection
await _streamGeneration(convId, assistantMessageId);
}
async function _streamGeneration(
convId: number,
assistantMessageId: number,
initialLastEventId = -1,
attempt = 0,
) {
const MAX_RETRIES = 5;
let lastEventId = initialLastEventId;
let gotDone = false;
const handle = apiSSEStream(
`/api/chat/conversations/${convId}/generation/stream` +
(lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""),
(event) => {
lastEventId = event.id;
switch (event.event) {
case "context":
lastContextMeta.value = event.data.context as ContextMeta;
break;
case "chunk":
streamingContent.value += event.data.chunk as string;
break;
case "done":
gotDone = true;
{
const assistantMsg: Message = {
id: event.data.message_id as number,
conversation_id: convId,
role: "assistant",
content: streamingContent.value,
context_note_id: null,
created_at: new Date().toISOString(),
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
}
streamingContent.value = "";
streaming.value = false;
// Update conversation in list
const idx = conversations.value.findIndex((c) => c.id === convId);
if (idx !== -1) {
conversations.value[idx].message_count += 2;
conversations.value[idx].updated_at = new Date().toISOString();
}
}
break;
case "error":
gotDone = true;
streaming.value = false;
streamingContent.value = "";
useToastStore().show(
"Chat error: " + (event.data.error as string),
"error",
);
break;
}
},
);
// Wait for the stream to close (events are processed via callback above)
await handle.done;
// If stream ended without done/error, attempt reconnection
if (!gotDone && attempt < MAX_RETRIES) {
const delay = Math.min(1000 * 2 ** attempt, 10_000);
await new Promise((r) => setTimeout(r, delay));
if (currentConversation.value?.id === convId) {
return _streamGeneration(convId, assistantMessageId, lastEventId, attempt + 1);
}
}
// Recovery fallback: re-fetch conversation from DB
if (!gotDone && currentConversation.value?.id === convId) {
try {
await fetchConversation(convId);
} catch {
// Re-fetch failed — partial content visible on next page load
}
}
streaming.value = false;
streamingContent.value = "";
}
async function cancelGeneration() {
if (!currentConversation.value) return;
try {
await apiPost(
`/api/chat/conversations/${currentConversation.value.id}/generation/cancel`,
{}
);
} catch {
// Generation may have already finished — ignore
} }
} }
@@ -233,6 +327,7 @@ export const useChatStore = defineStore("chat", () => {
deleteConversation, deleteConversation,
updateTitle, updateTitle,
sendMessage, sendMessage,
cancelGeneration,
saveMessageAsNote, saveMessageAsNote,
summarizeAsNote, summarizeAsNote,
fetchModels, fetchModels,
+55 -25
View File
@@ -1,6 +1,7 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client"; import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Note, NoteListResponse } from "@/types/note"; import type { Note, NoteListResponse } from "@/types/note";
export const useNotesStore = defineStore("notes", () => { export const useNotesStore = defineStore("notes", () => {
@@ -36,6 +37,9 @@ export const useNotesStore = defineStore("notes", () => {
); );
notes.value = data.notes; notes.value = data.notes;
total.value = data.total; total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load notes", "error");
throw e;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -62,6 +66,9 @@ export const useNotesStore = defineStore("notes", () => {
loading.value = true; loading.value = true;
try { try {
currentNote.value = await apiGet<Note>(`/api/notes/${id}`); currentNote.value = await apiGet<Note>(`/api/notes/${id}`);
} catch (e) {
useToastStore().show("Failed to load note", "error");
throw e;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -71,26 +78,40 @@ export const useNotesStore = defineStore("notes", () => {
title: string; title: string;
body: string; body: string;
}): Promise<Note> { }): Promise<Note> {
const note = await apiPost<Note>("/api/notes", data); try {
return note; return await apiPost<Note>("/api/notes", data);
} catch (e) {
useToastStore().show("Failed to create note", "error");
throw e;
}
} }
async function updateNote( async function updateNote(
id: number, id: number,
data: Partial<Pick<Note, "title" | "body">> data: Partial<Pick<Note, "title" | "body">>
): Promise<Note> { ): Promise<Note> {
const note = await apiPut<Note>(`/api/notes/${id}`, data); try {
if (currentNote.value?.id === id) { const note = await apiPut<Note>(`/api/notes/${id}`, data);
currentNote.value = note; if (currentNote.value?.id === id) {
currentNote.value = note;
}
return note;
} catch (e) {
useToastStore().show("Failed to update note", "error");
throw e;
} }
return note;
} }
async function deleteNote(id: number) { async function deleteNote(id: number) {
await apiDelete(`/api/notes/${id}`); try {
notes.value = notes.value.filter((n) => n.id !== id); await apiDelete(`/api/notes/${id}`);
if (currentNote.value?.id === id) { notes.value = notes.value.filter((n) => n.id !== id);
currentNote.value = null; if (currentNote.value?.id === id) {
currentNote.value = null;
}
} catch (e) {
useToastStore().show("Failed to delete note", "error");
throw e;
} }
} }
@@ -143,26 +164,35 @@ export const useNotesStore = defineStore("notes", () => {
} }
async function convertToTask(noteId: number): Promise<Note> { async function convertToTask(noteId: number): Promise<Note> {
const note = await apiPost<Note>( try {
`/api/notes/${noteId}/convert-to-task`, const note = await apiPost<Note>(
{} `/api/notes/${noteId}/convert-to-task`,
); {}
// Update local state — the note is now a task );
if (currentNote.value?.id === noteId) { if (currentNote.value?.id === noteId) {
currentNote.value = note; currentNote.value = note;
}
return note;
} catch (e) {
useToastStore().show("Failed to convert to task", "error");
throw e;
} }
return note;
} }
async function convertToNote(noteId: number): Promise<Note> { async function convertToNote(noteId: number): Promise<Note> {
const note = await apiPost<Note>( try {
`/api/notes/${noteId}/convert-to-note`, const note = await apiPost<Note>(
{} `/api/notes/${noteId}/convert-to-note`,
); {}
if (currentNote.value?.id === noteId) { );
currentNote.value = note; if (currentNote.value?.id === noteId) {
currentNote.value = note;
}
return note;
} catch (e) {
useToastStore().show("Failed to convert to note", "error");
throw e;
} }
return note;
} }
async function fetchBacklinks( async function fetchBacklinks(
+11 -2
View File
@@ -1,6 +1,7 @@
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { apiGet, apiPut, apiPost, apiStreamPost } from "@/api/client"; import { apiGet, apiPut, apiPost, apiStreamPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { AppSettings } from "@/types/settings"; import type { AppSettings } from "@/types/settings";
export const useSettingsStore = defineStore("settings", () => { export const useSettingsStore = defineStore("settings", () => {
@@ -31,6 +32,9 @@ export const useSettingsStore = defineStore("settings", () => {
loading.value = true; loading.value = true;
try { try {
settings.value = await apiPut<AppSettings>("/api/settings", updates); settings.value = await apiPut<AppSettings>("/api/settings", updates);
} catch (e) {
useToastStore().show("Failed to save settings", "error");
throw e;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -55,8 +59,13 @@ export const useSettingsStore = defineStore("settings", () => {
} }
async function deleteModel(model: string) { async function deleteModel(model: string) {
await apiPost("/api/chat/models/delete", { model }); try {
await fetchInstalledModels(); await apiPost("/api/chat/models/delete", { model });
await fetchInstalledModels();
} catch (e) {
useToastStore().show("Failed to delete model", "error");
throw e;
}
} }
return { return {
+44 -18
View File
@@ -1,6 +1,7 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client"; import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task"; import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task";
export const useTasksStore = defineStore("tasks", () => { export const useTasksStore = defineStore("tasks", () => {
@@ -41,6 +42,9 @@ export const useTasksStore = defineStore("tasks", () => {
); );
tasks.value = data.tasks; tasks.value = data.tasks;
total.value = data.total; total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load tasks", "error");
throw e;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -50,6 +54,9 @@ export const useTasksStore = defineStore("tasks", () => {
loading.value = true; loading.value = true;
try { try {
currentTask.value = await apiGet<Task>(`/api/tasks/${id}`); currentTask.value = await apiGet<Task>(`/api/tasks/${id}`);
} catch (e) {
useToastStore().show("Failed to load task", "error");
throw e;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -62,7 +69,12 @@ export const useTasksStore = defineStore("tasks", () => {
priority?: TaskPriority; priority?: TaskPriority;
due_date?: string | null; due_date?: string | null;
}): Promise<Task> { }): Promise<Task> {
return await apiPost<Task>("/api/tasks", data); try {
return await apiPost<Task>("/api/tasks", data);
} catch (e) {
useToastStore().show("Failed to create task", "error");
throw e;
}
} }
async function updateTask( async function updateTask(
@@ -71,31 +83,45 @@ export const useTasksStore = defineStore("tasks", () => {
Pick<Task, "title" | "body" | "status" | "priority" | "due_date"> Pick<Task, "title" | "body" | "status" | "priority" | "due_date">
> >
): Promise<Task> { ): Promise<Task> {
const task = await apiPut<Task>(`/api/tasks/${id}`, data); try {
if (currentTask.value?.id === id) { const task = await apiPut<Task>(`/api/tasks/${id}`, data);
currentTask.value = task; if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
} catch (e) {
useToastStore().show("Failed to update task", "error");
throw e;
} }
return task;
} }
async function patchStatus(id: number, status: TaskStatus): Promise<Task> { async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status }); try {
// Update in list if present const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
const idx = tasks.value.findIndex((t) => t.id === id); const idx = tasks.value.findIndex((t) => t.id === id);
if (idx !== -1) { if (idx !== -1) {
tasks.value[idx] = task; tasks.value[idx] = task;
}
if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
} catch (e) {
useToastStore().show("Failed to update task status", "error");
throw e;
} }
if (currentTask.value?.id === id) {
currentTask.value = task;
}
return task;
} }
async function deleteTask(id: number) { async function deleteTask(id: number) {
await apiDelete(`/api/tasks/${id}`); try {
tasks.value = tasks.value.filter((t) => t.id !== id); await apiDelete(`/api/tasks/${id}`);
if (currentTask.value?.id === id) { tasks.value = tasks.value.filter((t) => t.id !== id);
currentTask.value = null; if (currentTask.value?.id === id) {
currentTask.value = null;
}
} catch (e) {
useToastStore().show("Failed to delete task", "error");
throw e;
} }
} }
+8 -4
View File
@@ -4,7 +4,7 @@ import { defineStore } from "pinia";
export interface Toast { export interface Toast {
id: number; id: number;
message: string; message: string;
type: "success" | "error"; type: "success" | "error" | "warning";
} }
let nextId = 0; let nextId = 0;
@@ -12,13 +12,17 @@ let nextId = 0;
export const useToastStore = defineStore("toast", () => { export const useToastStore = defineStore("toast", () => {
const toasts = ref<Toast[]>([]); const toasts = ref<Toast[]>([]);
function show(message: string, type: "success" | "error" = "success") { function show(message: string, type: "success" | "error" | "warning" = "success") {
const id = nextId++; const id = nextId++;
toasts.value.push({ id, message, type }); toasts.value.push({ id, message, type });
setTimeout(() => { setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id); toasts.value = toasts.value.filter((t) => t.id !== id);
}, 3000); }, 4000);
} }
return { toasts, show }; function dismiss(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
return { toasts, show, dismiss };
}); });
+11
View File
@@ -0,0 +1,11 @@
export interface User {
id: number;
username: string;
email: string | null;
role: string;
created_at: string;
}
export interface AuthStatus {
has_users: boolean;
}
+6
View File
@@ -3,10 +3,16 @@ export interface Message {
conversation_id: number; conversation_id: number;
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
content: string; content: string;
status?: "complete" | "generating" | "error";
context_note_id: number | null; context_note_id: number | null;
created_at: string; created_at: string;
} }
export interface SendMessageResponse {
assistant_message_id: number;
status: string;
}
export interface Conversation { export interface Conversation {
id: number; id: number;
title: string; title: string;
+161 -10
View File
@@ -18,6 +18,7 @@ const messagesEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null); const inputEl = ref<HTMLTextAreaElement | null>(null);
const sending = ref(false); const sending = ref(false);
const summarizing = ref(false); const summarizing = ref(false);
const sidebarOpen = ref(false);
// Note picker state // Note picker state
const attachedNote = ref<{ id: number; title: string } | null>(null); const attachedNote = ref<{ id: number; title: string } | null>(null);
@@ -30,11 +31,32 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// 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());
let prevConvId: number | null = null;
const convId = computed(() => { const convId = computed(() => {
const id = route.params.id; const id = route.params.id;
return id ? Number(id) : null; return id ? Number(id) : null;
}); });
function formatConvDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
// Relative time for < 10 hours
if (diffMin < 1) return "Just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 10) return `${diffHrs}h ago`;
// Date for older
if (date.getFullYear() === now.getFullYear()) {
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
const streamingRendered = computed(() => { const streamingRendered = computed(() => {
if (!store.streamingContent) return ""; if (!store.streamingContent) return "";
return renderMarkdown(store.streamingContent); return renderMarkdown(store.streamingContent);
@@ -54,11 +76,24 @@ onMounted(async () => {
}); });
watch(convId, async (newId) => { watch(convId, async (newId) => {
// Clean up empty previous conversation
if (prevConvId && prevConvId !== newId) {
const prev = store.conversations.find((c) => c.id === prevConvId);
if (prev && prev.message_count === 0) {
store.deleteConversation(prevConvId);
}
}
prevConvId = newId ?? null;
excludedNoteIds.value = new Set(); excludedNoteIds.value = new Set();
attachedNote.value = null; attachedNote.value = null;
store.lastContextMeta = null; store.lastContextMeta = null;
if (newId) { if (newId) {
await store.fetchConversation(newId); // Skip re-fetch if this conversation is already loaded (avoids race
// condition where a stale GET overwrites messages during streaming)
if (store.currentConversation?.id !== newId) {
await store.fetchConversation(newId);
}
scrollToBottom(); scrollToBottom();
} else { } else {
store.currentConversation = null; store.currentConversation = null;
@@ -79,7 +114,12 @@ function scrollToBottom() {
}); });
} }
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value;
}
async function selectConversation(id: number) { async function selectConversation(id: number) {
sidebarOpen.value = false;
router.push(`/chat/${id}`); router.push(`/chat/${id}`);
} }
@@ -228,7 +268,12 @@ function excludeAutoNote(noteId: number) {
<template> <template>
<main class="chat-page"> <main class="chat-page">
<aside class="chat-sidebar"> <div
v-if="sidebarOpen"
class="sidebar-overlay"
@click="sidebarOpen = false"
></div>
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
<button class="btn-new-conv" @click="newConversation"> <button class="btn-new-conv" @click="newConversation">
+ New Chat + New Chat
</button> </button>
@@ -240,7 +285,10 @@ function excludeAutoNote(noteId: number) {
:class="{ active: convId === conv.id }" :class="{ active: convId === conv.id }"
@click="selectConversation(conv.id)" @click="selectConversation(conv.id)"
> >
<span class="conv-title">{{ conv.title || "Untitled" }}</span> <div class="conv-info">
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
<span class="conv-date">{{ formatConvDate(conv.updated_at) }}</span>
</div>
<button <button
class="btn-delete-conv" class="btn-delete-conv"
@click.stop="removeConversation(conv.id)" @click.stop="removeConversation(conv.id)"
@@ -258,6 +306,13 @@ function excludeAutoNote(noteId: number) {
<section class="chat-main"> <section class="chat-main">
<template v-if="store.currentConversation"> <template v-if="store.currentConversation">
<div class="chat-header"> <div class="chat-header">
<button class="btn-sidebar-toggle hide-desktop" @click="toggleSidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<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"
@@ -383,9 +438,20 @@ function excludeAutoNote(noteId: number) {
rows="1" rows="1"
></textarea> ></textarea>
<button <button
v-if="store.streaming"
class="btn-stop"
@click="store.cancelGeneration()"
title="Stop generation"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect width="14" height="14" rx="2" />
</svg>
</button>
<button
v-else
class="btn-send" class="btn-send"
@click="sendMessage" @click="sendMessage"
:disabled="!messageInput.trim() || store.streaming || !store.chatReady" :disabled="!messageInput.trim() || !store.chatReady"
> >
&uarr; &uarr;
</button> </button>
@@ -394,6 +460,13 @@ function excludeAutoNote(noteId: number) {
</template> </template>
<div v-else class="no-conversation"> <div v-else class="no-conversation">
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<p>Select a conversation or start a new chat.</p> <p>Select a conversation or start a new chat.</p>
<button class="btn-new-conv" @click="newConversation"> <button class="btn-new-conv" @click="newConversation">
+ New Chat + New Chat
@@ -455,13 +528,26 @@ function excludeAutoNote(noteId: number) {
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: #fff;
} }
.conv-title { .conv-info {
flex: 1; flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.conv-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 0.9rem; font-size: 0.9rem;
} }
.conv-date {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 1px;
}
.conv-item.active .conv-date {
color: rgba(255, 255, 255, 0.7);
}
.btn-delete-conv { .btn-delete-conv {
background: none; background: none;
border: none; border: none;
@@ -526,6 +612,9 @@ function excludeAutoNote(noteId: number) {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 848px;
margin: 0 auto;
width: 100%;
} }
.messages-inner { .messages-inner {
margin-top: auto; margin-top: auto;
@@ -643,7 +732,11 @@ function excludeAutoNote(noteId: number) {
/* Input wrapper */ /* Input wrapper */
.input-wrapper { .input-wrapper {
margin: 0 1rem 2rem; max-width: 848px;
margin: 0 auto 2rem;
padding: 0 1rem;
width: 100%;
box-sizing: border-box;
} }
/* Attached note */ /* Attached note */
@@ -789,6 +882,23 @@ function excludeAutoNote(noteId: number) {
opacity: 0.35; opacity: 0.35;
cursor: default; cursor: default;
} }
.btn-stop {
width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-danger, #e74c3c);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
flex-shrink: 0;
}
.btn-stop:hover {
opacity: 0.85;
}
.no-conversation { .no-conversation {
flex: 1; flex: 1;
@@ -807,16 +917,57 @@ function excludeAutoNote(noteId: number) {
padding: 1rem; padding: 1rem;
} }
@media (max-width: 640px) { .btn-sidebar-toggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-text);
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.no-conv-toggle {
position: absolute;
top: 0.75rem;
left: 0.75rem;
}
.no-conversation {
position: relative;
}
.sidebar-overlay {
display: none;
}
@media (max-width: 768px) {
.chat-sidebar { .chat-sidebar {
width: 200px; position: fixed;
min-width: 160px; top: 49px;
left: 0;
bottom: 0;
z-index: 100;
transform: translateX(-100%);
transition: transform 0.25s ease;
width: 260px;
}
.chat-sidebar.open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 0;
top: 49px;
background: var(--color-overlay);
z-index: 99;
} }
.messages-container { .messages-container {
padding: 0.75rem; padding: 0.75rem;
} }
.input-wrapper { .input-wrapper {
margin: 0 0.5rem 0.5rem; padding: 0 0.5rem;
margin-bottom: 0.5rem;
} }
} }
</style> </style>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const username = ref("");
const password = ref("");
const error = ref("");
const submitting = ref(false);
onMounted(async () => {
await authStore.checkHasUsers();
});
async function handleSubmit() {
error.value = "";
submitting.value = true;
try {
await authStore.login(username.value, password.value);
const redirect = (route.query.redirect as string) || "/";
router.push(redirect);
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Login failed";
} else {
error.value = "Login failed";
}
} finally {
submitting.value = false;
}
}
</script>
<template>
<main class="auth-page">
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Sign In</h1></div>
<p v-if="!authStore.hasUsers" class="auth-hint">
No accounts yet.
<router-link to="/register">Create the first account</router-link>
to get started.
</p>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="input"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
required
class="input"
/>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="submitting">
{{ submitting ? "Signing in..." : "Sign In" }}
</button>
</form>
<p class="auth-footer">
Don't have an account?
<router-link to="/register">Register</router-link>
</p>
</div>
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--color-primary);
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
const router = useRouter();
const authStore = useAuthStore();
const username = ref("");
const password = ref("");
const email = ref("");
const error = ref("");
const submitting = ref(false);
async function handleSubmit() {
error.value = "";
submitting.value = true;
try {
await authStore.register(username.value, password.value, email.value || undefined);
router.push("/");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
} finally {
submitting.value = false;
}
}
</script>
<template>
<main class="auth-page">
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
class="input"
/>
</div>
<div class="field">
<label for="email">Email (optional)</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
class="input"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
<p v-if="error" class="error-msg">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="submitting">
{{ submitting ? "Creating account..." : "Create Account" }}
</button>
</form>
<p class="auth-footer">
Already have an account?
<router-link to="/login">Sign in</router-link>
</p>
</div>
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.btn-submit {
width: 100%;
padding: 0.6rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
.btn-submit:disabled {
opacity: 0.6;
cursor: default;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.9;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--color-primary);
}
</style>
+147
View File
@@ -1,15 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import type { ModelInfo } from "@/types/settings"; import type { ModelInfo } from "@/types/settings";
const store = useSettingsStore(); const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const assistantName = ref(""); const assistantName = ref("");
const saving = ref(false); const saving = ref(false);
const saved = ref(false); const saved = ref(false);
const pullProgress = ref<{ model: string; percent: number } | null>(null); const pullProgress = ref<{ model: string; percent: number } | null>(null);
const deleting = ref<string | null>(null); const deleting = ref<string | null>(null);
const confirmDelete = ref<string | null>(null); const confirmDelete = ref<string | null>(null);
const exporting = ref(false);
const restoring = ref(false);
const restoreFileInput = ref<HTMLInputElement | null>(null);
const MODEL_CATALOG: ModelInfo[] = [ const MODEL_CATALOG: ModelInfo[] = [
// — General Purpose — // — General Purpose —
@@ -244,6 +251,63 @@ async function removeModel(name: string) {
function cancelDelete() { function cancelDelete() {
confirmDelete.value = null; confirmDelete.value = null;
} }
async function exportData(scope: "user" | "full") {
exporting.value = true;
try {
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
const res = await fetch(url);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
}
const data = await res.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(a.href);
toastStore.show("Backup downloaded");
} catch (e) {
toastStore.show("Export failed: " + (e as Error).message, "error");
} finally {
exporting.value = false;
}
}
function triggerRestoreUpload() {
restoreFileInput.value?.click();
}
async function handleRestoreFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
restoring.value = true;
try {
const text = await file.text();
const data = JSON.parse(text);
const res = await fetch("/api/admin/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
}
const result = await res.json();
toastStore.show(
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
);
} catch (e) {
toastStore.show("Restore failed: " + (e as Error).message, "error");
} finally {
restoring.value = false;
// Reset file input
if (restoreFileInput.value) restoreFileInput.value.value = "";
}
}
</script> </script>
<template> <template>
@@ -345,6 +409,45 @@ function cancelDelete() {
</div> </div>
</div> </div>
</section> </section>
<section class="settings-section">
<h2>Data</h2>
<p class="section-desc">Export your data or restore from a backup.</p>
<div class="data-actions">
<button
class="btn-export"
@click="exportData('user')"
:disabled="exporting"
>
{{ exporting ? "Exporting..." : "Export My Data" }}
</button>
<template v-if="authStore.isAdmin">
<button
class="btn-export"
@click="exportData('full')"
:disabled="exporting"
>
{{ exporting ? "Exporting..." : "Export Full Backup" }}
</button>
<button
class="btn-restore"
@click="triggerRestoreUpload"
:disabled="restoring"
>
{{ restoring ? "Restoring..." : "Restore from Backup" }}
</button>
<input
ref="restoreFileInput"
type="file"
accept=".json"
class="hidden-file-input"
@change="handleRestoreFile"
/>
</template>
</div>
</section>
</main> </main>
</template> </template>
@@ -598,4 +701,48 @@ function cancelDelete() {
color: var(--color-text); color: var(--color-text);
border-color: var(--color-text-muted); border-color: var(--color-text-muted);
} }
/* Data section */
.data-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.btn-export {
padding: 0.45rem 1rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
}
.btn-export:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-export:disabled {
opacity: 0.6;
cursor: default;
}
.btn-restore {
padding: 0.45rem 1rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
}
.btn-restore:hover:not(:disabled) {
border-color: var(--color-warning);
color: var(--color-warning);
}
.btn-restore:disabled {
opacity: 0.6;
cursor: default;
}
.hidden-file-input {
display: none;
}
</style> </style>
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"alembic>=1.13", "alembic>=1.13",
"httpx>=0.27", "httpx>=0.27",
"hypercorn>=0.17", "hypercorn>=0.17",
"bcrypt>=4.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+33 -4
View File
@@ -1,10 +1,13 @@
import logging import logging
import time
from pathlib import Path from pathlib import Path
from quart import Quart, jsonify, make_response, request, send_from_directory from quart import Quart, g, jsonify, make_response, request, send_from_directory
from fabledassistant.config import Config from fabledassistant.config import Config
from fabledassistant.routes.admin import admin_bp
from fabledassistant.routes.api import api from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.chat import chat_bp from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.notes import notes_bp from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.settings import settings_bp from fabledassistant.routes.settings import settings_bp
@@ -15,21 +18,49 @@ logger = logging.getLogger(__name__)
def create_app() -> Quart: def create_app() -> Quart:
# Configure logging
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO),
)
app = Quart(__name__, static_folder=None) app = Quart(__name__, static_folder=None)
app.secret_key = Config.SECRET_KEY app.secret_key = Config.SECRET_KEY
app.register_blueprint(admin_bp)
app.register_blueprint(api) app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp) app.register_blueprint(chat_bp)
app.register_blueprint(notes_bp) app.register_blueprint(notes_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(tasks_bp) app.register_blueprint(tasks_bp)
@app.before_request
async def before_request():
g.request_start = time.monotonic()
@app.after_request
async def after_request(response):
duration = time.monotonic() - getattr(g, "request_start", time.monotonic())
duration_ms = round(duration * 1000, 1)
logger.info(
"%s %s %s %.1fms",
request.method,
request.path,
response.status_code,
duration_ms,
)
return response
@app.before_serving @app.before_serving
async def startup(): async def startup():
import asyncio import asyncio
from fabledassistant.services.generation_buffer import start_cleanup_loop
from fabledassistant.services.llm import ensure_model from fabledassistant.services.llm import ensure_model
start_cleanup_loop()
async def _pull_model(): async def _pull_model():
try: try:
await ensure_model(Config.OLLAMA_MODEL) await ensure_model(Config.OLLAMA_MODEL)
@@ -70,11 +101,9 @@ def create_app() -> Quart:
@app.errorhandler(500) @app.errorhandler(500)
async def handle_500(error): async def handle_500(error):
import traceback
traceback.print_exc()
logger.exception("Internal server error on %s %s", request.method, request.path) logger.exception("Internal server error on %s %s", request.method, request.path)
if request.path.startswith("/api/"): if request.path.startswith("/api/"):
return jsonify({"error": str(error)}), 500 return jsonify({"error": "Internal server error"}), 500
return "Internal Server Error", 500 return "Internal Server Error", 500
return app return app
+41
View File
@@ -0,0 +1,41 @@
import functools
from quart import g, jsonify, session
from fabledassistant.services.auth import get_user_by_id
def login_required(f):
@functools.wraps(f)
async def decorated(*args, **kwargs):
user_id = session.get("user_id")
if not user_id:
return jsonify({"error": "Authentication required"}), 401
user = await get_user_by_id(user_id)
if not user:
session.clear()
return jsonify({"error": "Authentication required"}), 401
g.user = user
return await f(*args, **kwargs)
return decorated
def admin_required(f):
@functools.wraps(f)
async def decorated(*args, **kwargs):
user_id = session.get("user_id")
if not user_id:
return jsonify({"error": "Authentication required"}), 401
user = await get_user_by_id(user_id)
if not user:
session.clear()
return jsonify({"error": "Authentication required"}), 401
if user.role != "admin":
return jsonify({"error": "Admin access required"}), 403
g.user = user
return await f(*args, **kwargs)
return decorated
def get_current_user_id() -> int:
return g.user.id
+19 -2
View File
@@ -1,11 +1,28 @@
import os import os
def _read_secret(env_var: str, secret_file_var: str, default: str) -> str:
"""Read a config value from env var, or from a Docker secrets file."""
value = os.environ.get(env_var)
if value:
return value
secret_file = os.environ.get(secret_file_var)
if secret_file:
try:
with open(secret_file) as f:
return f.read().strip()
except FileNotFoundError:
pass
return default
class Config: class Config:
DATABASE_URL: str = os.environ.get( DATABASE_URL: str = _read_secret(
"DATABASE_URL", "DATABASE_URL",
"DATABASE_URL_FILE",
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant", "postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
) )
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434") OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llama3.1") OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llama3.1")
SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-change-me") SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
+1
View File
@@ -14,3 +14,4 @@ class Base(DeclarativeBase):
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401 from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401 from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
from fabledassistant.models.setting import Setting # noqa: E402, F401 from fabledassistant.models.setting import Setting # noqa: E402, F401
from fabledassistant.models.user import User # noqa: E402, F401
@@ -11,6 +11,9 @@ class Conversation(Base):
__tablename__ = "conversations" __tablename__ = "conversations"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
title: Mapped[str] = mapped_column(Text, default="") title: Mapped[str] = mapped_column(Text, default="")
model: Mapped[str] = mapped_column(Text, default="") model: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
@@ -30,6 +33,7 @@ class Conversation(Base):
__table_args__ = ( __table_args__ = (
Index("ix_conversations_updated_at", "updated_at"), Index("ix_conversations_updated_at", "updated_at"),
Index("ix_conversations_user_id", "user_id"),
) )
def to_dict(self) -> dict: def to_dict(self) -> dict:
@@ -57,6 +61,7 @@ class Message(Base):
) )
role: Mapped[str] = mapped_column(Text) role: Mapped[str] = mapped_column(Text)
content: Mapped[str] = mapped_column(Text, default="") content: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="complete")
context_note_id: Mapped[int | None] = mapped_column( context_note_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
) )
@@ -76,6 +81,7 @@ class Message(Base):
"conversation_id": self.conversation_id, "conversation_id": self.conversation_id,
"role": self.role, "role": self.role,
"content": self.content, "content": self.content,
"status": self.status,
"context_note_id": self.context_note_id, "context_note_id": self.context_note_id,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
} }
+5 -1
View File
@@ -1,7 +1,7 @@
import enum import enum
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import Date, DateTime, Index, Text from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -25,6 +25,9 @@ class Note(Base):
__tablename__ = "notes" __tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
title: Mapped[str] = mapped_column(Text, default="") title: Mapped[str] = mapped_column(Text, default="")
body: Mapped[str] = mapped_column(Text, default="") body: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list) tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
@@ -47,6 +50,7 @@ class Note(Base):
Index("ix_notes_tags", "tags", postgresql_using="gin"), Index("ix_notes_tags", "tags", postgresql_using="gin"),
Index("ix_notes_status", "status"), Index("ix_notes_status", "status"),
Index("ix_notes_title", "title"), Index("ix_notes_title", "title"),
Index("ix_notes_user_id", "user_id"),
) )
@property @property
+4 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Text from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from fabledassistant.models import Base
@@ -7,6 +7,9 @@ from fabledassistant.models import Base
class Setting(Base): class Setting(Base):
__tablename__ = "settings" __tablename__ = "settings"
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
key: Mapped[str] = mapped_column(Text, primary_key=True) key: Mapped[str] = mapped_column(Text, primary_key=True)
value: Mapped[str] = mapped_column(Text, default="") value: Mapped[str] = mapped_column(Text, default="")
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
email: Mapped[str | None] = mapped_column(Text, nullable=True)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("ix_users_username", "username"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"username": self.username,
"email": self.email,
"role": self.role,
"created_at": self.created_at.isoformat(),
}
+47
View File
@@ -0,0 +1,47 @@
import json
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.services.backup import (
export_full_backup,
export_user_backup,
restore_full_backup,
)
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@admin_bp.route("/backup", methods=["GET"])
@login_required
async def backup():
uid = get_current_user_id()
scope = request.args.get("scope", "user")
if scope == "full":
# Full backup requires admin
from quart import g
if g.user.role != "admin":
return jsonify({"error": "Admin access required for full backup"}), 403
data = await export_full_backup()
else:
data = await export_user_backup(uid)
return Response(
json.dumps(data, indent=2, default=str),
content_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="fabled-backup-{scope}.json"',
},
)
@admin_bp.route("/restore", methods=["POST"])
@admin_required
async def restore():
data = await request.get_json()
if not data:
return jsonify({"error": "No backup data provided"}), 400
stats = await restore_full_backup(data)
return jsonify({"status": "ok", "stats": stats})
+70
View File
@@ -0,0 +1,70 @@
from quart import Blueprint, jsonify, request, session
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.auth import (
authenticate,
create_user,
get_user_by_id,
get_user_count,
)
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@auth_bp.route("/register", methods=["POST"])
async def register():
data = await request.get_json()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
email = (data.get("email") or "").strip() or None
if not username:
return jsonify({"error": "Username is required"}), 400
if len(password) < 8:
return jsonify({"error": "Password must be at least 8 characters"}), 400
try:
user = await create_user(username, password, email)
except Exception:
return jsonify({"error": "Username already taken"}), 409
session["user_id"] = user.id
return jsonify(user.to_dict()), 201
@auth_bp.route("/login", methods=["POST"])
async def login():
data = await request.get_json()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
user = await authenticate(username, password)
if not user:
return jsonify({"error": "Invalid username or password"}), 401
session["user_id"] = user.id
return jsonify(user.to_dict())
@auth_bp.route("/logout", methods=["POST"])
async def logout():
session.clear()
return jsonify({"status": "ok"})
@auth_bp.route("/me", methods=["GET"])
@login_required
async def me():
user = await get_user_by_id(get_current_user_id())
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict())
@auth_bp.route("/status", methods=["GET"])
async def status():
count = await get_user_count()
return jsonify({"has_users": count > 0})
+115 -42
View File
@@ -1,9 +1,11 @@
import asyncio
import json import json
import logging import logging
import httpx import httpx
from quart import Blueprint, Response, jsonify, request from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config from fabledassistant.config import Config
from fabledassistant.services.chat import ( from fabledassistant.services.chat import (
add_message, add_message,
@@ -15,7 +17,13 @@ from fabledassistant.services.chat import (
summarize_conversation_as_note, summarize_conversation_as_note,
update_conversation_title, update_conversation_title,
) )
from fabledassistant.services.llm import build_context, stream_chat from fabledassistant.services.generation_buffer import (
GenerationState,
create_buffer,
get_buffer,
)
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.llm import build_context
from fabledassistant.services.settings import get_setting from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,10 +32,12 @@ chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
@chat_bp.route("/conversations", methods=["GET"]) @chat_bp.route("/conversations", methods=["GET"])
@login_required
async def list_conversations_route(): async def list_conversations_route():
uid = get_current_user_id()
limit = request.args.get("limit", 50, type=int) limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int) offset = request.args.get("offset", 0, type=int)
conversations, total = await list_conversations(limit=limit, offset=offset) conversations, total = await list_conversations(uid, limit=limit, offset=offset)
return jsonify({ return jsonify({
"conversations": conversations, "conversations": conversations,
"total": total, "total": total,
@@ -35,17 +45,21 @@ async def list_conversations_route():
@chat_bp.route("/conversations", methods=["POST"]) @chat_bp.route("/conversations", methods=["POST"])
@login_required
async def create_conversation_route(): async def create_conversation_route():
uid = get_current_user_id()
data = await request.get_json(force=True, silent=True) or {} data = await request.get_json(force=True, silent=True) or {}
title = data.get("title", "") title = data.get("title", "")
model = data.get("model", Config.OLLAMA_MODEL) model = data.get("model", Config.OLLAMA_MODEL)
conv = await create_conversation(title=title, model=model) conv = await create_conversation(uid, title=title, model=model)
return jsonify(conv.to_dict()), 201 return jsonify(conv.to_dict()), 201
@chat_bp.route("/conversations/<int:conv_id>", methods=["GET"]) @chat_bp.route("/conversations/<int:conv_id>", methods=["GET"])
@login_required
async def get_conversation_route(conv_id: int): async def get_conversation_route(conv_id: int):
conv = await get_conversation(conv_id) uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return jsonify({"error": "Conversation not found"}), 404
result = conv.to_dict() result = conv.to_dict()
@@ -54,29 +68,35 @@ async def get_conversation_route(conv_id: int):
@chat_bp.route("/conversations/<int:conv_id>", methods=["DELETE"]) @chat_bp.route("/conversations/<int:conv_id>", methods=["DELETE"])
@login_required
async def delete_conversation_route(conv_id: int): async def delete_conversation_route(conv_id: int):
deleted = await delete_conversation(conv_id) uid = get_current_user_id()
deleted = await delete_conversation(uid, conv_id)
if not deleted: if not deleted:
return jsonify({"error": "Conversation not found"}), 404 return jsonify({"error": "Conversation not found"}), 404
return "", 204 return "", 204
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"]) @chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
@login_required
async def update_conversation_route(conv_id: int): async def update_conversation_route(conv_id: int):
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: if title is None:
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
conv = await update_conversation_title(conv_id, title) conv = await update_conversation_title(uid, conv_id, title)
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())
@chat_bp.route("/conversations/<int:conv_id>/messages", methods=["POST"]) @chat_bp.route("/conversations/<int:conv_id>/messages", methods=["POST"])
@login_required
async def send_message_route(conv_id: int): async def send_message_route(conv_id: int):
"""Send a user message, stream the LLM response via SSE.""" """Start generation: save user message, launch background task, return 202."""
conv = await get_conversation(conv_id) uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return jsonify({"error": "Conversation not found"}), 404
@@ -87,10 +107,20 @@ async def send_message_route(conv_id: int):
context_note_id = data.get("context_note_id") context_note_id = data.get("context_note_id")
exclude_note_ids = data.get("exclude_note_ids") or [] exclude_note_ids = data.get("exclude_note_ids") or []
# Reject if generation already running for this conversation
existing = get_buffer(conv_id)
if existing and existing.state == GenerationState.RUNNING:
return jsonify({"error": "Generation already in progress"}), 409
# Save user message # Save user message
await add_message(conv_id, "user", content, context_note_id=context_note_id) await add_message(conv_id, "user", content, context_note_id=context_note_id)
# Build history from existing messages (excluding system) # Create placeholder assistant message
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
# Build history from existing messages (excluding system and the placeholder)
history = [] history = []
for msg in conv.messages: for msg in conv.messages:
if msg.role != "system": if msg.role != "system":
@@ -98,43 +128,60 @@ async def send_message_route(conv_id: int):
# Build context with note search, URL fetching, etc. # Build context with note search, URL fetching, etc.
messages, context_meta = await build_context( messages, context_meta = await build_context(
history, context_note_id, content, exclude_note_ids=exclude_note_ids uid, history, context_note_id, content, exclude_note_ids=exclude_note_ids
) )
model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL) model = conv.model or await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
async def generate(): # Launch background generation task
# Emit context metadata before streaming LLM response asyncio.create_task(run_generation(
context_event = json.dumps({"context": context_meta}) buf, messages, model, context_meta,
yield f"data: {context_event}\n\n" uid, conv_id, conv.title, content,
))
full_response = [] return jsonify({
try: "assistant_message_id": assistant_msg.id,
async for chunk in stream_chat(messages, model): "status": "generating",
full_response.append(chunk) }), 202
event_data = json.dumps({"chunk": chunk})
yield f"data: {event_data}\n\n"
# Save complete assistant message
full_text = "".join(full_response)
msg = await add_message(conv_id, "assistant", full_text)
# Auto-generate title from first user message if conversation has no title @chat_bp.route("/conversations/<int:conv_id>/generation/stream", methods=["GET"])
if not conv.title: @login_required
title = content[:80] async def generation_stream_route(conv_id: int):
if len(content) > 80: """SSE endpoint that tails the generation buffer for a conversation."""
title += "..." uid = get_current_user_id()
await update_conversation_title(conv_id, title) conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
done_data = json.dumps({"done": True, "message_id": msg.id}) buf = get_buffer(conv_id)
yield f"data: {done_data}\n\n" if buf is None:
except Exception as e: return jsonify({"error": "No active generation"}), 404
logger.exception("Error streaming LLM response")
error_data = json.dumps({"error": str(e)}) # Determine starting point from Last-Event-ID header or query param
yield f"data: {error_data}\n\n" last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
last_id = int(last_id_str) if last_id_str is not None else -1
async def stream():
cursor = last_id
while True:
# Replay any buffered events past the cursor
pending = buf.events_after(cursor)
for event in pending:
yield buf.format_sse(event)
cursor = event.index
# If generation is done and all events delivered, close stream
if buf.state != GenerationState.RUNNING:
break
# Wait for new events or send keepalive on timeout
got_new = await buf.wait_for_event(cursor, timeout=15.0)
if not got_new:
yield ": keepalive\n\n"
return Response( return Response(
generate(), stream(),
content_type="text/event-stream", content_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
@@ -143,32 +190,55 @@ async def send_message_route(conv_id: int):
) )
@chat_bp.route("/conversations/<int:conv_id>/generation/cancel", methods=["POST"])
@login_required
async def cancel_generation_route(conv_id: int):
"""Cancel an active generation for a conversation."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
return jsonify({"error": "No active generation"}), 404
buf.cancel_event.set()
return jsonify({"status": "cancelled"})
@chat_bp.route("/messages/<int:message_id>/save-as-note", methods=["POST"]) @chat_bp.route("/messages/<int:message_id>/save-as-note", methods=["POST"])
@login_required
async def save_message_as_note_route(message_id: int): async def save_message_as_note_route(message_id: int):
uid = get_current_user_id()
try: try:
note = await save_response_as_note(message_id) note = await save_response_as_note(uid, message_id)
return jsonify(note), 201 return jsonify(note), 201
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
@chat_bp.route("/conversations/<int:conv_id>/summarize", methods=["POST"]) @chat_bp.route("/conversations/<int:conv_id>/summarize", methods=["POST"])
@login_required
async def summarize_conversation_route(conv_id: int): async def summarize_conversation_route(conv_id: int):
conv = await get_conversation(conv_id) uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None: if conv is None:
return jsonify({"error": "Conversation not found"}), 404 return jsonify({"error": "Conversation not found"}), 404
model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL) model = conv.model or await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
try: try:
note = await summarize_conversation_as_note(conv_id, model) note = await summarize_conversation_as_note(uid, conv_id, model)
return jsonify(note), 201 return jsonify(note), 201
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
@chat_bp.route("/status", methods=["GET"]) @chat_bp.route("/status", methods=["GET"])
@login_required
async def chat_status_route(): async def chat_status_route():
"""Check Ollama availability and model readiness.""" """Check Ollama availability and model readiness."""
default_model = await get_setting("default_model", Config.OLLAMA_MODEL) uid = get_current_user_id()
default_model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
result = { result = {
"ollama": "unavailable", "ollama": "unavailable",
"model": "not_found", "model": "not_found",
@@ -190,6 +260,7 @@ async def chat_status_route():
@chat_bp.route("/models", methods=["GET"]) @chat_bp.route("/models", methods=["GET"])
@login_required
async def list_models_route(): async def list_models_route():
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
@@ -207,6 +278,7 @@ async def list_models_route():
@chat_bp.route("/models/pull", methods=["POST"]) @chat_bp.route("/models/pull", methods=["POST"])
@login_required
async def pull_model_route(): async def pull_model_route():
"""Pull a model from Ollama, streaming progress via SSE.""" """Pull a model from Ollama, streaming progress via SSE."""
data = await request.get_json() data = await request.get_json()
@@ -245,6 +317,7 @@ async def pull_model_route():
@chat_bp.route("/models/delete", methods=["POST"]) @chat_bp.route("/models/delete", methods=["POST"])
@login_required
async def delete_model_route(): async def delete_model_route():
"""Delete a model from Ollama.""" """Delete a model from Ollama."""
data = await request.get_json() data = await request.get_json()
+34 -10
View File
@@ -2,6 +2,7 @@ from datetime import date
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.notes import ( from fabledassistant.services.notes import (
convert_note_to_task, convert_note_to_task,
convert_task_to_note, convert_task_to_note,
@@ -21,7 +22,9 @@ notes_bp = Blueprint("notes", __name__, url_prefix="/api/notes")
@notes_bp.route("", methods=["GET"]) @notes_bp.route("", methods=["GET"])
@login_required
async def list_notes_route(): async def list_notes_route():
uid = get_current_user_id()
q = request.args.get("q") q = request.args.get("q")
tag = request.args.getlist("tag") tag = request.args.getlist("tag")
sort = request.args.get("sort", "updated_at") sort = request.args.get("sort", "updated_at")
@@ -37,13 +40,15 @@ async def list_notes_route():
is_task = True is_task = True
notes, total = await list_notes( notes, total = await list_notes(
q=q, tags=tag or None, is_task=is_task, sort=sort, order=order, limit=limit, offset=offset uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order, limit=limit, offset=offset
) )
return jsonify({"notes": [n.to_dict() for n in notes], "total": total}) return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
@notes_bp.route("", methods=["POST"]) @notes_bp.route("", methods=["POST"])
@login_required
async def create_note_route(): async def create_note_route():
uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
body = data.get("body", "") body = data.get("body", "")
tags = extract_tags(body) tags = extract_tags(body)
@@ -56,6 +61,7 @@ async def create_note_route():
due_date = date.fromisoformat(data["due_date"]) due_date = date.fromisoformat(data["due_date"])
note = await create_note( note = await create_note(
uid,
title=data.get("title", ""), title=data.get("title", ""),
body=body, body=body,
tags=tags, tags=tags,
@@ -68,43 +74,53 @@ async def create_note_route():
@notes_bp.route("/tags", methods=["GET"]) @notes_bp.route("/tags", methods=["GET"])
@login_required
async def list_tags_route(): async def list_tags_route():
uid = get_current_user_id()
q = request.args.get("q") q = request.args.get("q")
tags = await get_all_tags(q=q) tags = await get_all_tags(uid, q=q)
return jsonify({"tags": tags}) return jsonify({"tags": tags})
@notes_bp.route("/by-title", methods=["GET"]) @notes_bp.route("/by-title", methods=["GET"])
@login_required
async def get_note_by_title_route(): async def get_note_by_title_route():
uid = get_current_user_id()
title = request.args.get("title", "") title = request.args.get("title", "")
if not title: if not title:
return jsonify({"error": "title parameter is required"}), 400 return jsonify({"error": "title parameter is required"}), 400
note = await get_note_by_title(title) note = await get_note_by_title(uid, title)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict()) return jsonify(note.to_dict())
@notes_bp.route("/resolve-title", methods=["POST"]) @notes_bp.route("/resolve-title", methods=["POST"])
@login_required
async def resolve_title_route(): async def resolve_title_route():
uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
title = data.get("title", "").strip() title = data.get("title", "").strip()
if not title: if not title:
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
note = await get_or_create_note_by_title(title) note = await get_or_create_note_by_title(uid, title)
return jsonify(note.to_dict()) return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["GET"]) @notes_bp.route("/<int:note_id>", methods=["GET"])
@login_required
async def get_note_route(note_id: int): async def get_note_route(note_id: int):
note = await get_note(note_id) uid = get_current_user_id()
note = await get_note(uid, note_id)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict()) return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["PUT"]) @notes_bp.route("/<int:note_id>", methods=["PUT"])
@login_required
async def update_note_route(note_id: int): async def update_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
fields = {} fields = {}
for key in ("title", "body", "parent_id", "status", "priority"): for key in ("title", "body", "parent_id", "status", "priority"):
@@ -118,39 +134,47 @@ async def update_note_route(note_id: int):
if "body" in fields: if "body" in fields:
fields["tags"] = extract_tags(fields["body"]) fields["tags"] = extract_tags(fields["body"])
note = await update_note(note_id, **fields) note = await update_note(uid, note_id, **fields)
if note is None: if note is None:
return jsonify({"error": "Note not found"}), 404 return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict()) return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["DELETE"]) @notes_bp.route("/<int:note_id>", methods=["DELETE"])
@login_required
async def delete_note_route(note_id: int): async def delete_note_route(note_id: int):
deleted = await delete_note(note_id) uid = get_current_user_id()
deleted = await delete_note(uid, note_id)
if not deleted: if not deleted:
return jsonify({"error": "Note not found"}), 404 return jsonify({"error": "Note not found"}), 404
return "", 204 return "", 204
@notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"]) @notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"])
@login_required
async def convert_note_to_task_route(note_id: int): async def convert_note_to_task_route(note_id: int):
uid = get_current_user_id()
try: try:
note = await convert_note_to_task(note_id) note = await convert_note_to_task(uid, note_id)
return jsonify(note.to_dict()), 200 return jsonify(note.to_dict()), 200
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 404 return jsonify({"error": str(e)}), 404
@notes_bp.route("/<int:note_id>/convert-to-note", methods=["POST"]) @notes_bp.route("/<int:note_id>/convert-to-note", methods=["POST"])
@login_required
async def convert_task_to_note_route(note_id: int): async def convert_task_to_note_route(note_id: int):
uid = get_current_user_id()
try: try:
note = await convert_task_to_note(note_id) note = await convert_task_to_note(uid, note_id)
return jsonify(note.to_dict()), 200 return jsonify(note.to_dict()), 200
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 404 return jsonify({"error": str(e)}), 404
@notes_bp.route("/<int:note_id>/backlinks", methods=["GET"]) @notes_bp.route("/<int:note_id>/backlinks", methods=["GET"])
@login_required
async def get_backlinks_route(note_id: int): async def get_backlinks_route(note_id: int):
links = await get_backlinks(note_id) uid = get_current_user_id()
links = await get_backlinks(uid, note_id)
return jsonify({"backlinks": links}) return jsonify({"backlinks": links})
+8 -3
View File
@@ -1,5 +1,6 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.llm import get_installed_models from fabledassistant.services.llm import get_installed_models
from fabledassistant.services.settings import get_all_settings, set_settings_batch from fabledassistant.services.settings import get_all_settings, set_settings_batch
@@ -7,13 +8,17 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
@settings_bp.route("", methods=["GET"]) @settings_bp.route("", methods=["GET"])
@login_required
async def get_settings_route(): async def get_settings_route():
settings = await get_all_settings() uid = get_current_user_id()
settings = await get_all_settings(uid)
return jsonify(settings) return jsonify(settings)
@settings_bp.route("", methods=["PUT"]) @settings_bp.route("", methods=["PUT"])
@login_required
async def update_settings_route(): async def update_settings_route():
uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
if not isinstance(data, dict): if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400 return jsonify({"error": "Expected a JSON object"}), 400
@@ -24,6 +29,6 @@ async def update_settings_route():
if installed and model not in installed: if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400 return jsonify({"error": f"Model '{model}' is not installed"}), 400
await set_settings_batch({k: str(v) for k, v in data.items()}) await set_settings_batch(uid, {k: str(v) for k, v in data.items()})
settings = await get_all_settings() settings = await get_all_settings(uid)
return jsonify(settings) return jsonify(settings)
+19 -4
View File
@@ -2,6 +2,7 @@ from datetime import date
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.services.notes import ( from fabledassistant.services.notes import (
create_note, create_note,
@@ -16,7 +17,9 @@ tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
@tasks_bp.route("", methods=["GET"]) @tasks_bp.route("", methods=["GET"])
@login_required
async def list_tasks_route(): async def list_tasks_route():
uid = get_current_user_id()
q = request.args.get("q") q = request.args.get("q")
tag = request.args.getlist("tag") tag = request.args.getlist("tag")
status = request.args.get("status") status = request.args.get("status")
@@ -27,6 +30,7 @@ async def list_tasks_route():
offset = request.args.get("offset", 0, type=int) offset = request.args.get("offset", 0, type=int)
tasks, total = await list_notes( tasks, total = await list_notes(
uid,
q=q, q=q,
tags=tag or None, tags=tag or None,
is_task=True, is_task=True,
@@ -41,7 +45,9 @@ async def list_tasks_route():
@tasks_bp.route("", methods=["POST"]) @tasks_bp.route("", methods=["POST"])
@login_required
async def create_task_route(): async def create_task_route():
uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
body = data.get("body", "") or data.get("description", "") body = data.get("body", "") or data.get("description", "")
tags = extract_tags(body) tags = extract_tags(body)
@@ -56,6 +62,7 @@ async def create_task_route():
) )
task = await create_note( task = await create_note(
uid,
title=data.get("title", ""), title=data.get("title", ""),
body=body, body=body,
status=status, status=status,
@@ -67,15 +74,19 @@ async def create_task_route():
@tasks_bp.route("/<int:task_id>", methods=["GET"]) @tasks_bp.route("/<int:task_id>", methods=["GET"])
@login_required
async def get_task_route(task_id: int): async def get_task_route(task_id: int):
task = await get_note(task_id) uid = get_current_user_id()
task = await get_note(uid, task_id)
if task is None: if task is None:
return jsonify({"error": "Task not found"}), 404 return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict()) return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>", methods=["PUT"]) @tasks_bp.route("/<int:task_id>", methods=["PUT"])
@login_required
async def update_task_route(task_id: int): async def update_task_route(task_id: int):
uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
fields = {} fields = {}
for key in ("title", "status", "priority"): for key in ("title", "status", "priority"):
@@ -96,14 +107,16 @@ async def update_task_route(task_id: int):
if "body" in fields: if "body" in fields:
fields["tags"] = extract_tags(fields["body"]) fields["tags"] = extract_tags(fields["body"])
task = await update_note(task_id, **fields) task = await update_note(uid, task_id, **fields)
if task is None: if task is None:
return jsonify({"error": "Task not found"}), 404 return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict()) return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>/status", methods=["PATCH"]) @tasks_bp.route("/<int:task_id>/status", methods=["PATCH"])
@login_required
async def patch_task_status(task_id: int): async def patch_task_status(task_id: int):
uid = get_current_user_id()
data = await request.get_json() data = await request.get_json()
status_val = data.get("status") status_val = data.get("status")
if not status_val: if not status_val:
@@ -114,15 +127,17 @@ async def patch_task_status(task_id: int):
except ValueError: except ValueError:
return jsonify({"error": f"Invalid status: {status_val}"}), 400 return jsonify({"error": f"Invalid status: {status_val}"}), 400
task = await update_note(task_id, status=status_val) task = await update_note(uid, task_id, status=status_val)
if task is None: if task is None:
return jsonify({"error": "Task not found"}), 404 return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict()) return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>", methods=["DELETE"]) @tasks_bp.route("/<int:task_id>", methods=["DELETE"])
@login_required
async def delete_task_route(task_id: int): async def delete_task_route(task_id: int):
deleted = await delete_note(task_id) uid = get_current_user_id()
deleted = await delete_note(uid, task_id)
if not deleted: if not deleted:
return jsonify({"error": "Task not found"}), 404 return jsonify({"error": "Task not found"}), 404
return "", 204 return "", 204
+85
View File
@@ -0,0 +1,85 @@
import logging
import bcrypt
from sqlalchemy import func, select, update
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation
from fabledassistant.models.note import Note
from fabledassistant.models.setting import Setting
from fabledassistant.models.user import User
logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode(), password_hash.encode())
async def get_user_count() -> int:
async with async_session() as session:
return await session.scalar(select(func.count(User.id))) or 0
async def create_user(
username: str, password: str, email: str | None = None
) -> User:
user_count = await get_user_count()
role = "admin" if user_count == 0 else "user"
async with async_session() as session:
user = User(
username=username,
email=email,
password_hash=hash_password(password),
role=role,
)
session.add(user)
await session.commit()
await session.refresh(user)
# First user claims all pre-existing data (migration assigns user_id=1,
# which matches SERIAL first insert; also handles any NULLs)
if role == "admin":
await session.execute(
update(Note)
.where((Note.user_id.is_(None)) | (Note.user_id == user.id))
.values(user_id=user.id)
)
await session.execute(
update(Conversation)
.where(
(Conversation.user_id.is_(None))
| (Conversation.user_id == user.id)
)
.values(user_id=user.id)
)
await session.execute(
update(Setting)
.where(Setting.user_id == user.id)
.values(user_id=user.id)
)
await session.commit()
logger.info("First user '%s' created as admin, claimed orphaned data", username)
return user
async def authenticate(username: str, password: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(User.username == username)
)
user = result.scalars().first()
if user and verify_password(password, user.password_hash):
return user
return None
async def get_user_by_id(user_id: int) -> User | None:
async with async_session() as session:
return await session.get(User, user_id)
+255
View File
@@ -0,0 +1,255 @@
import logging
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.models.note import Note
from fabledassistant.models.setting import Setting
from fabledassistant.models.user import User
logger = logging.getLogger(__name__)
async def export_full_backup() -> dict:
"""Export all users, notes, conversations+messages, and settings as JSON."""
async with async_session() as session:
users = (await session.execute(select(User))).scalars().all()
notes = (await session.execute(select(Note))).scalars().all()
conversations = (
await session.execute(
select(Conversation).options(selectinload(Conversation.messages))
)
).scalars().all()
settings = (await session.execute(select(Setting))).scalars().all()
return {
"version": 1,
"scope": "full",
"users": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"password_hash": u.password_hash,
"role": u.role,
"created_at": u.created_at.isoformat(),
}
for u in users
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"conversations": [
{
"id": c.id,
"user_id": c.user_id,
"title": c.title,
"model": c.model,
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"context_note_id": m.context_note_id,
"created_at": m.created_at.isoformat(),
}
for m in c.messages
],
}
for c in conversations
],
"settings": [
{
"user_id": s.user_id,
"key": s.key,
"value": s.value,
}
for s in settings
],
}
async def export_user_backup(user_id: int) -> dict:
"""Export a single user's data as JSON."""
async with async_session() as session:
user = await session.get(User, user_id)
notes = (
await session.execute(select(Note).where(Note.user_id == user_id))
).scalars().all()
conversations = (
await session.execute(
select(Conversation)
.options(selectinload(Conversation.messages))
.where(Conversation.user_id == user_id)
)
).scalars().all()
settings = (
await session.execute(
select(Setting).where(Setting.user_id == user_id)
)
).scalars().all()
return {
"version": 1,
"scope": "user",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"created_at": user.created_at.isoformat(),
} if user else None,
"notes": [
{
"id": n.id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"conversations": [
{
"id": c.id,
"title": c.title,
"model": c.model,
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"context_note_id": m.context_note_id,
"created_at": m.created_at.isoformat(),
}
for m in c.messages
],
}
for c in conversations
],
"settings": [
{"key": s.key, "value": s.value}
for s in settings
],
}
async def restore_full_backup(data: dict) -> dict:
"""Restore from a full backup JSON. Returns stats about what was restored."""
from datetime import date, datetime, timezone
stats = {"users": 0, "notes": 0, "conversations": 0, "messages": 0, "settings": 0}
async with async_session() as session:
# Restore users
user_id_map: dict[int, int] = {}
for u_data in data.get("users", []):
old_id = u_data["id"]
user = User(
username=u_data["username"],
email=u_data.get("email"),
password_hash=u_data["password_hash"],
role=u_data.get("role", "user"),
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
)
session.add(user)
await session.flush()
user_id_map[old_id] = user.id
stats["users"] += 1
# Restore notes
note_id_map: dict[int, int] = {}
for n_data in data.get("notes", []):
old_id = n_data.get("id")
mapped_user_id = user_id_map.get(n_data.get("user_id", 0))
if mapped_user_id is None:
continue
due = None
if n_data.get("due_date"):
due = date.fromisoformat(n_data["due_date"])
note = Note(
user_id=mapped_user_id,
title=n_data.get("title", ""),
body=n_data.get("body", ""),
tags=n_data.get("tags", []),
parent_id=n_data.get("parent_id"),
status=n_data.get("status"),
priority=n_data.get("priority"),
due_date=due,
created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(note)
await session.flush()
if old_id is not None:
note_id_map[old_id] = note.id
stats["notes"] += 1
# Restore conversations + messages
for c_data in data.get("conversations", []):
mapped_user_id = user_id_map.get(c_data.get("user_id", 0))
if mapped_user_id is None:
continue
conv = Conversation(
user_id=mapped_user_id,
title=c_data.get("title", ""),
model=c_data.get("model", ""),
created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(conv)
await session.flush()
stats["conversations"] += 1
for m_data in c_data.get("messages", []):
msg = Message(
conversation_id=conv.id,
role=m_data["role"],
content=m_data.get("content", ""),
context_note_id=note_id_map.get(m_data.get("context_note_id")) if m_data.get("context_note_id") else None,
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
)
session.add(msg)
stats["messages"] += 1
# Restore settings
for s_data in data.get("settings", []):
mapped_user_id = user_id_map.get(s_data.get("user_id", 0))
if mapped_user_id is None:
continue
setting = Setting(
user_id=mapped_user_id,
key=s_data["key"],
value=s_data.get("value", ""),
)
session.add(setting)
stats["settings"] += 1
await session.commit()
logger.info("Restored backup: %s", stats)
return stats
+68 -20
View File
@@ -12,9 +12,11 @@ from fabledassistant.services.notes import create_note
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def create_conversation(title: str = "", model: str = "") -> Conversation: async def create_conversation(
user_id: int, title: str = "", model: str = ""
) -> Conversation:
async with async_session() as session: async with async_session() as session:
conv = Conversation(title=title, model=model) conv = Conversation(user_id=user_id, title=title, model=model)
session.add(conv) session.add(conv)
await session.commit() await session.commit()
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context # Re-fetch with messages eagerly loaded to avoid lazy-load in async context
@@ -26,22 +28,29 @@ async def create_conversation(title: str = "", model: str = "") -> Conversation:
return result.scalars().first() return result.scalars().first()
async def get_conversation(conversation_id: int) -> Conversation | None: async def get_conversation(
user_id: int, conversation_id: int
) -> Conversation | None:
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Conversation) select(Conversation)
.options(selectinload(Conversation.messages)) .options(selectinload(Conversation.messages))
.where(Conversation.id == conversation_id) .where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
) )
return result.scalars().first() return result.scalars().first()
async def list_conversations( async def list_conversations(
limit: int = 50, offset: int = 0 user_id: int, limit: int = 50, offset: int = 0
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int]:
async with async_session() as session: async with async_session() as session:
total = await session.scalar( total = await session.scalar(
select(func.count(Conversation.id)) select(func.count(Conversation.id)).where(
Conversation.user_id == user_id
)
) or 0 ) or 0
# Subquery for message count — avoids loading all messages # Subquery for message count — avoids loading all messages
@@ -54,6 +63,7 @@ async def list_conversations(
result = await session.execute( result = await session.execute(
select(Conversation, msg_count.label("message_count")) select(Conversation, msg_count.label("message_count"))
.where(Conversation.user_id == user_id)
.order_by(Conversation.updated_at.desc()) .order_by(Conversation.updated_at.desc())
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
@@ -75,9 +85,15 @@ async def list_conversations(
return conversations, total return conversations, total
async def delete_conversation(conversation_id: int) -> bool: async def delete_conversation(user_id: int, conversation_id: int) -> bool:
async with async_session() as session: async with async_session() as session:
conv = await session.get(Conversation, conversation_id) result = await session.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
)
conv = result.scalars().first()
if conv is None: if conv is None:
return False return False
await session.delete(conv) await session.delete(conv)
@@ -86,10 +102,16 @@ async def delete_conversation(conversation_id: int) -> bool:
async def update_conversation_title( async def update_conversation_title(
conversation_id: int, title: str user_id: int, conversation_id: int, title: str
) -> Conversation | None: ) -> Conversation | None:
async with async_session() as session: async with async_session() as session:
conv = await session.get(Conversation, conversation_id) result = await session.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
)
conv = result.scalars().first()
if conv is None: if conv is None:
return None return None
conv.title = title conv.title = title
@@ -104,14 +126,18 @@ async def add_message(
role: str, role: str,
content: str, content: str,
context_note_id: int | None = None, context_note_id: int | None = None,
status: str | None = None,
) -> Message: ) -> Message:
async with async_session() as session: async with async_session() as session:
msg = Message( kwargs: dict = dict(
conversation_id=conversation_id, conversation_id=conversation_id,
role=role, role=role,
content=content, content=content,
context_note_id=context_note_id, context_note_id=context_note_id,
) )
if status is not None:
kwargs["status"] = status
msg = Message(**kwargs)
session.add(msg) session.add(msg)
# Touch conversation updated_at # Touch conversation updated_at
conv = await session.get(Conversation, conversation_id) conv = await session.get(Conversation, conversation_id)
@@ -127,7 +153,7 @@ async def get_message(message_id: int) -> Message | None:
return await session.get(Message, message_id) return await session.get(Message, message_id)
async def save_response_as_note(message_id: int) -> dict: async def save_response_as_note(user_id: int, message_id: int) -> dict:
"""Create a note from an assistant message. Returns the new note dict.""" """Create a note from an assistant message. Returns the new note dict."""
msg = await get_message(message_id) msg = await get_message(message_id)
if msg is None: if msg is None:
@@ -135,20 +161,42 @@ async def save_response_as_note(message_id: int) -> dict:
if msg.role != "assistant": if msg.role != "assistant":
raise ValueError("Can only save assistant messages as notes") raise ValueError("Can only save assistant messages as notes")
# Use first line as title, rest as body conv = await get_conversation(user_id, msg.conversation_id)
lines = msg.content.strip().split("\n", 1)
title = lines[0].strip().lstrip("# ")[:100]
body = msg.content
note = await create_note(title=title, body=body) # Generate title via LLM using the assistant message content
title = ""
if conv:
model = conv.model or "llama3.2"
try:
prompt_messages = [
{
"role": "system",
"content": (
"Generate a concise 3-8 word title for a note based on "
"this content. Reply with ONLY the title, no quotes or "
"punctuation."
),
},
{"role": "user", "content": msg.content[:2000]},
]
title = await generate_completion(prompt_messages, model)
title = title.strip().strip('"\'').strip()[:100]
except Exception:
logger.warning("Failed to generate note title, using fallback", exc_info=True)
if not title:
lines = msg.content.strip().split("\n", 1)
title = lines[0].strip().lstrip("# ")[:100]
note = await create_note(user_id, title=title, body=msg.content, tags=["chat"])
return note.to_dict() return note.to_dict()
async def summarize_conversation_as_note( async def summarize_conversation_as_note(
conversation_id: int, model: str user_id: int, conversation_id: int, model: str
) -> dict: ) -> dict:
"""Summarize a conversation using the LLM and save as a note.""" """Summarize a conversation using the LLM and save as a note."""
conv = await get_conversation(conversation_id) conv = await get_conversation(user_id, conversation_id)
if conv is None: if conv is None:
raise ValueError("Conversation not found") raise ValueError("Conversation not found")
@@ -178,5 +226,5 @@ async def summarize_conversation_as_note(
title = conv.title or "Conversation Summary" title = conv.title or "Conversation Summary"
title = f"Summary: {title}" title = f"Summary: {title}"
note = await create_note(title=title, body=summary) note = await create_note(user_id, title=title, body=summary, tags=["chat"])
return note.to_dict() return note.to_dict()
@@ -0,0 +1,114 @@
"""In-memory generation event buffer for SSE fan-out.
Each active generation gets a GenerationBuffer keyed by conversation_id.
SSE clients tail the buffer and can reconnect at any point without data loss.
"""
import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
logger = logging.getLogger(__name__)
class GenerationState(str, Enum):
RUNNING = "running"
COMPLETED = "completed"
ERRORED = "errored"
@dataclass
class SSEEvent:
index: int
event_type: str # "context", "chunk", "done", "error"
data: dict
@dataclass
class GenerationBuffer:
conversation_id: int
assistant_message_id: int
state: GenerationState = GenerationState.RUNNING
events: list[SSEEvent] = field(default_factory=list)
content_so_far: str = ""
finished_at: float | None = None
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
_notify: asyncio.Event = field(default_factory=asyncio.Event)
def append_event(self, event_type: str, data: dict) -> SSEEvent:
event = SSEEvent(index=len(self.events), event_type=event_type, data=data)
self.events.append(event)
# Wake all waiting SSE clients, then reset for next wait
old = self._notify
self._notify = asyncio.Event()
old.set()
return event
async def wait_for_event(self, after_index: int, timeout: float = 30.0) -> bool:
"""Wait until there are events beyond after_index. Returns True if new events exist."""
if after_index + 1 < len(self.events):
return True
try:
await asyncio.wait_for(self._notify.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
def events_after(self, index: int) -> list[SSEEvent]:
"""Return events with index > given index (for replay on reconnect)."""
start = index + 1
if start >= len(self.events):
return []
return self.events[start:]
@staticmethod
def format_sse(event: SSEEvent) -> str:
return f"id: {event.index}\nevent: {event.event_type}\ndata: {json.dumps(event.data)}\n\n"
# Module-level singleton registry
_buffers: dict[int, GenerationBuffer] = {}
_cleanup_task: asyncio.Task | None = None
_GRACE_PERIOD = 60.0 # seconds to keep completed buffers
def create_buffer(conv_id: int, msg_id: int) -> GenerationBuffer:
existing = _buffers.get(conv_id)
if existing and existing.state == GenerationState.RUNNING:
raise RuntimeError(f"Generation already running for conversation {conv_id}")
buf = GenerationBuffer(conversation_id=conv_id, assistant_message_id=msg_id)
_buffers[conv_id] = buf
return buf
def get_buffer(conv_id: int) -> GenerationBuffer | None:
return _buffers.get(conv_id)
def remove_buffer(conv_id: int) -> None:
_buffers.pop(conv_id, None)
async def _cleanup_loop() -> None:
"""Remove completed/errored buffers after grace period."""
while True:
await asyncio.sleep(15)
now = time.monotonic()
to_remove = [
cid for cid, buf in _buffers.items()
if buf.state != GenerationState.RUNNING
and buf.finished_at is not None
and now - buf.finished_at > _GRACE_PERIOD
]
for cid in to_remove:
_buffers.pop(cid, None)
logger.debug("Cleaned up generation buffer for conversation %d", cid)
def start_cleanup_loop() -> None:
global _cleanup_task
if _cleanup_task is None or _cleanup_task.done():
_cleanup_task = asyncio.create_task(_cleanup_loop())
@@ -0,0 +1,136 @@
"""Background asyncio task for LLM generation.
Streams from Ollama into a GenerationBuffer, periodically flushing to DB.
Runs independently of any HTTP connection.
"""
import logging
import time
from sqlalchemy import update
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Message
from fabledassistant.services.generation_buffer import GenerationBuffer, GenerationState
from fabledassistant.services.llm import generate_completion, stream_chat
from fabledassistant.services.chat import update_conversation_title
logger = logging.getLogger(__name__)
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
async def _generate_title(messages: list[dict], model: str) -> str:
"""Ask the LLM for a concise conversation title."""
# Build conversation text like summarize_conversation_as_note
conv_lines = []
for m in messages:
if m["role"] == "system":
continue
label = "User" if m["role"] == "user" else "Assistant"
conv_lines.append(f"{label}: {m['content']}")
# Keep only last 6 pairs worth of text
conv_lines = conv_lines[-12:]
prompt_messages = [
{
"role": "system",
"content": (
"Generate a concise 3-8 word title for this conversation. "
"Reply with ONLY the title, no quotes or punctuation."
),
},
{"role": "user", "content": "\n\n".join(conv_lines)},
]
title = await generate_completion(prompt_messages, model)
title = title.strip().strip('"\'').strip()
return title[:100] if title else ""
async def _update_message(message_id: int, content: str, status: str) -> None:
async with async_session() as session:
await session.execute(
update(Message)
.where(Message.id == message_id)
.values(content=content, status=status)
)
await session.commit()
async def run_generation(
buf: GenerationBuffer,
messages: list[dict],
model: str,
context_meta: dict,
user_id: int,
conv_id: int,
conv_title: str,
user_content: str,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
msg_id = buf.assistant_message_id
# Emit context event
buf.append_event("context", {"context": context_meta})
last_flush = time.monotonic()
try:
cancelled = False
async for chunk in stream_chat(messages, model):
if buf.cancel_event.is_set():
cancelled = True
break
buf.content_so_far += chunk
buf.append_event("chunk", {"chunk": chunk})
# Periodic DB flush
now = time.monotonic()
if now - last_flush >= DB_FLUSH_INTERVAL:
try:
await _update_message(msg_id, buf.content_so_far, "generating")
except Exception:
logger.warning("Failed periodic flush for message %d", msg_id, exc_info=True)
last_flush = now
# Final save (partial content on cancel is still valid)
await _update_message(msg_id, buf.content_so_far, "complete")
# Count non-system messages to decide on title generation
non_system = [m for m in messages if m["role"] != "system"]
msg_count = len(non_system)
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
if should_gen_title:
# Include the just-generated assistant reply for context
title_messages = messages + [
{"role": "assistant", "content": buf.content_so_far}
]
try:
title = await _generate_title(title_messages, model)
if title:
await update_conversation_title(user_id, conv_id, title)
except Exception:
logger.warning("Failed to generate title for conversation %d", conv_id, exc_info=True)
# Fallback for first message only
if not conv_title:
fallback = user_content[:80]
if len(user_content) > 80:
fallback += "..."
await update_conversation_title(user_id, conv_id, fallback)
buf.state = GenerationState.COMPLETED
buf.finished_at = time.monotonic()
buf.append_event("done", {"done": True, "message_id": msg_id})
except Exception as e:
logger.exception("Error in generation task for conversation %d", conv_id)
# Save partial content with error status
try:
await _update_message(msg_id, buf.content_so_far, "error")
except Exception:
logger.warning("Failed to save error state for message %d", msg_id, exc_info=True)
buf.state = GenerationState.ERRORED
buf.finished_at = time.monotonic()
buf.append_event("error", {"error": str(e)})
+4 -3
View File
@@ -149,6 +149,7 @@ def _find_urls(text: str) -> list[str]:
async def build_context( async def build_context(
user_id: int,
history: list[dict], history: list[dict],
current_note_id: int | None, current_note_id: int | None,
user_message: str, user_message: str,
@@ -160,7 +161,7 @@ async def build_context(
which notes were included as context. which notes were included as context.
""" """
exclude_set = set(exclude_note_ids or []) exclude_set = set(exclude_note_ids or [])
assistant_name = await get_setting("assistant_name", "Fable") assistant_name = await get_setting(user_id, "assistant_name", "Fable")
system_parts = [ system_parts = [
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. " f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
"Help users with their notes, tasks, and general questions. " "Help users with their notes, tasks, and general questions. "
@@ -175,7 +176,7 @@ async def build_context(
# Include current note context if provided # Include current note context if provided
if current_note_id: if current_note_id:
note = await get_note(current_note_id) note = await get_note(user_id, current_note_id)
if note: if note:
context_meta["context_note_id"] = note.id context_meta["context_note_id"] = note.id
context_meta["context_note_title"] = note.title context_meta["context_note_title"] = note.title
@@ -194,7 +195,7 @@ async def build_context(
search_exclude.add(current_note_id) search_exclude.add(current_note_id)
try: try:
notes = await search_notes_for_context( notes = await search_notes_for_context(
keywords, exclude_ids=search_exclude or None, limit=3 user_id, keywords, exclude_ids=search_exclude or None, limit=3
) )
snippets: list[str] = [] snippets: list[str] = []
for n in notes: for n in notes:
+48 -22
View File
@@ -10,6 +10,7 @@ logger = logging.getLogger(__name__)
async def create_note( async def create_note(
user_id: int,
title: str = "", title: str = "",
body: str = "", body: str = "",
tags: list[str] | None = None, tags: list[str] | None = None,
@@ -20,6 +21,7 @@ async def create_note(
) -> Note: ) -> Note:
async with async_session() as session: async with async_session() as session:
note = Note( note = Note(
user_id=user_id,
title=title, title=title,
body=body, body=body,
tags=tags or [], tags=tags or [],
@@ -34,12 +36,16 @@ async def create_note(
return note return note
async def get_note(note_id: int) -> Note | None: async def get_note(user_id: int, note_id: int) -> Note | None:
async with async_session() as session: async with async_session() as session:
return await session.get(Note, note_id) result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
return result.scalars().first()
async def list_notes( async def list_notes(
user_id: int,
q: str | None = None, q: str | None = None,
tags: list[str] | None = None, tags: list[str] | None = None,
is_task: bool | None = None, is_task: bool | None = None,
@@ -51,8 +57,8 @@ async def list_notes(
offset: int = 0, offset: int = 0,
) -> tuple[list[Note], int]: ) -> tuple[list[Note], int]:
async with async_session() as session: async with async_session() as session:
query = select(Note) query = select(Note).where(Note.user_id == user_id)
count_query = select(func.count(Note.id)) count_query = select(func.count(Note.id)).where(Note.user_id == user_id)
# Filter by task vs note # Filter by task vs note
if is_task is True: if is_task is True:
@@ -104,25 +110,31 @@ async def list_notes(
return notes, total return notes, total
async def get_note_by_title(title: str) -> Note | None: async def get_note_by_title(user_id: int, title: str) -> Note | None:
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Note).where(func.lower(Note.title) == func.lower(title.strip())).limit(1) select(Note).where(
Note.user_id == user_id,
func.lower(Note.title) == func.lower(title.strip()),
).limit(1)
) )
return result.scalars().first() return result.scalars().first()
async def get_or_create_note_by_title(title: str) -> Note: async def get_or_create_note_by_title(user_id: int, title: str) -> Note:
title = title.strip() title = title.strip()
note = await get_note_by_title(title) note = await get_note_by_title(user_id, title)
if note: if note:
return note return note
return await create_note(title=title) return await create_note(user_id, title=title)
async def update_note(note_id: int, **fields: object) -> Note | None: async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None:
async with async_session() as session: async with async_session() as session:
note = await session.get(Note, note_id) result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None: if note is None:
return None return None
for key, value in fields.items(): for key, value in fields.items():
@@ -139,9 +151,12 @@ async def update_note(note_id: int, **fields: object) -> Note | None:
return note return note
async def delete_note(note_id: int) -> bool: async def delete_note(user_id: int, note_id: int) -> bool:
async with async_session() as session: async with async_session() as session:
note = await session.get(Note, note_id) result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None: if note is None:
return False return False
await session.delete(note) await session.delete(note)
@@ -149,10 +164,13 @@ async def delete_note(note_id: int) -> bool:
return True return True
async def get_all_tags(q: str | None = None) -> list[str]: async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
text("SELECT DISTINCT unnest(tags) AS tag FROM notes WHERE tags != '{}'") text(
"SELECT DISTINCT unnest(tags) AS tag FROM notes"
" WHERE tags != '{}' AND user_id = :user_id"
).bindparams(user_id=user_id)
) )
all_tags = [row[0] for row in result.fetchall()] all_tags = [row[0] for row in result.fetchall()]
@@ -163,9 +181,12 @@ async def get_all_tags(q: str | None = None) -> list[str]:
return sorted(all_tags) return sorted(all_tags)
async def convert_note_to_task(note_id: int) -> Note: async def convert_note_to_task(user_id: int, note_id: int) -> Note:
async with async_session() as session: async with async_session() as session:
note = await session.get(Note, note_id) result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None: if note is None:
logger.warning("convert_note_to_task: note %d not found", note_id) logger.warning("convert_note_to_task: note %d not found", note_id)
raise ValueError("Note not found") raise ValueError("Note not found")
@@ -178,9 +199,12 @@ async def convert_note_to_task(note_id: int) -> Note:
return note return note
async def convert_task_to_note(note_id: int) -> Note: async def convert_task_to_note(user_id: int, note_id: int) -> Note:
async with async_session() as session: async with async_session() as session:
note = await session.get(Note, note_id) result = await session.execute(
select(Note).where(Note.id == note_id, Note.user_id == user_id)
)
note = result.scalars().first()
if note is None: if note is None:
logger.warning("convert_task_to_note: note %d not found", note_id) logger.warning("convert_task_to_note: note %d not found", note_id)
raise ValueError("Note not found") raise ValueError("Note not found")
@@ -195,6 +219,7 @@ async def convert_task_to_note(note_id: int) -> Note:
async def search_notes_for_context( async def search_notes_for_context(
user_id: int,
keywords: list[str], keywords: list[str],
exclude_ids: set[int] | None = None, exclude_ids: set[int] | None = None,
limit: int = 3, limit: int = 3,
@@ -207,7 +232,7 @@ async def search_notes_for_context(
pattern = f"%{escaped}%" pattern = f"%{escaped}%"
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern))) keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
query = select(Note).where(or_(*keyword_filters)) query = select(Note).where(Note.user_id == user_id, or_(*keyword_filters))
if exclude_ids: if exclude_ids:
query = query.where(Note.id.notin_(exclude_ids)) query = query.where(Note.id.notin_(exclude_ids))
query = query.order_by(Note.updated_at.desc()).limit(limit) query = query.order_by(Note.updated_at.desc()).limit(limit)
@@ -216,8 +241,8 @@ async def search_notes_for_context(
return list(result.scalars().all()) return list(result.scalars().all())
async def get_backlinks(note_id: int) -> list[dict]: async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
note = await get_note(note_id) note = await get_note(user_id, note_id)
if note is None: if note is None:
return [] return []
@@ -232,6 +257,7 @@ async def get_backlinks(note_id: int) -> list[dict]:
results = await session.execute( results = await session.execute(
select(Note.id, Note.title, Note.status).where( select(Note.id, Note.title, Note.status).where(
Note.user_id == user_id,
Note.id != note_id, Note.id != note_id,
or_(Note.body.like(pattern), Note.body.like(pattern_alias)), or_(Note.body.like(pattern), Note.body.like(pattern_alias)),
) )
+19 -11
View File
@@ -8,39 +8,47 @@ from fabledassistant.models.setting import Setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def get_setting(key: str, default: str = "") -> str: async def get_setting(user_id: int, key: str, default: str = "") -> str:
async with async_session() as session: async with async_session() as session:
result = await session.execute(select(Setting).where(Setting.key == key)) result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none() setting = result.scalar_one_or_none()
return setting.value if setting else default return setting.value if setting else default
async def set_setting(key: str, value: str) -> None: async def set_setting(user_id: int, key: str, value: str) -> None:
async with async_session() as session: async with async_session() as session:
result = await session.execute(select(Setting).where(Setting.key == key)) result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none() setting = result.scalar_one_or_none()
if setting: if setting:
setting.value = value setting.value = value
else: else:
session.add(Setting(key=key, value=value)) session.add(Setting(user_id=user_id, key=key, value=value))
await session.commit() await session.commit()
async def set_settings_batch(settings: dict[str, str]) -> None: async def set_settings_batch(user_id: int, settings: dict[str, str]) -> None:
"""Update multiple settings in a single transaction.""" """Update multiple settings in a single transaction."""
async with async_session() as session: async with async_session() as session:
for key, value in settings.items(): for key, value in settings.items():
result = await session.execute(select(Setting).where(Setting.key == key)) result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none() setting = result.scalar_one_or_none()
if setting: if setting:
setting.value = value setting.value = value
else: else:
session.add(Setting(key=key, value=value)) session.add(Setting(user_id=user_id, key=key, value=value))
await session.commit() await session.commit()
logger.info("Batch-updated %d settings", len(settings)) logger.info("Batch-updated %d settings for user %d", len(settings), user_id)
async def get_all_settings() -> dict[str, str]: async def get_all_settings(user_id: int) -> dict[str, str]:
async with async_session() as session: async with async_session() as session:
result = await session.execute(select(Setting)) result = await session.execute(
select(Setting).where(Setting.user_id == user_id)
)
return {s.key: s.value for s in result.scalars().all()} return {s.key: s.value for s in result.scalars().all()}
+133 -79
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-11 — Phase 4.8: Backend efficiency & consistency pass 2026-02-11 — Phase 5.1: Chat UX Improvements
## 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
@@ -61,10 +61,15 @@ for AI-assisted features.
- **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown - **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown
bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create
missing notes. missing notes.
- **SSE over WebSockets for LLM streaming:** Uses `POST` with `text/event-stream` - **Background generation architecture:** LLM streaming runs in a detached
response (not EventSource). Frontend uses `fetch()` + `ReadableStream` since we `asyncio.Task` that writes into an in-memory `GenerationBuffer`. SSE clients
need to POST the message body. Simpler than WebSockets; Quart supports async tail the buffer and can reconnect mid-stream without data loss. Buffer has
generators natively for SSE. `cancel_event` for user-initiated stop. Completed buffers are cleaned up after
60s grace period. Periodic DB flushes every 5s preserve partial content.
- **SSE over WebSockets for LLM streaming:** SSE clients connect via
`GET /api/chat/conversations/:id/generation/stream` with `Last-Event-ID`
reconnection support. Frontend uses `fetch()` + `ReadableStream`. Simpler than
WebSockets; Quart supports async generators natively for SSE.
- **Context building server-side:** Backend fetches URL content and searches notes — - **Context building server-side:** Backend fetches URL content and searches notes —
frontend just sends the message text + optional note ID + optional exclude list. frontend just sends the message text + optional note ID + optional exclude list.
Keyword extraction uses simple word splitting with stopword filtering (no embeddings). Keyword extraction uses simple word splitting with stopword filtering (no embeddings).
@@ -115,9 +120,18 @@ for AI-assisted features.
## Data Model ## Data Model
### Users
- `id` (int PK), `username` (text UNIQUE NOT NULL), `email` (text nullable),
`password_hash` (text NOT NULL), `role` (text NOT NULL DEFAULT 'user'),
`created_at` (timestamptz)
- First registered user auto-assigned `role='admin'`; claims orphaned data
- Passwords hashed with bcrypt
- `to_dict()` excludes `password_hash`
### Notes (unified — includes tasks) ### Notes (unified — includes tasks)
- `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]), - `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]),
`parent_id` (nullable FK to self), `status` (nullable str — `todo`/`in_progress`/`done`), `parent_id` (nullable FK to self), `user_id` (nullable FK to users, CASCADE),
`status` (nullable str — `todo`/`in_progress`/`done`),
`priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date), `priority` (nullable str — `none`/`low`/`medium`/`high`), `due_date` (nullable date),
`created_at`, `updated_at` `created_at`, `updated_at`
- **A note is a task when `status IS NOT NULL`** — the `is_task` property checks this - **A note is a task when `status IS NOT NULL`** — the `is_task` property checks this
@@ -138,24 +152,26 @@ for AI-assisted features.
- Task body field is `body` (not `description`) — standardized with notes - Task body field is `body` (not `description`) — standardized with notes
### Settings ### Settings
- `key` (text PK), `value` (text) - Composite PK: `(user_id, key)` `user_id` FK to users (CASCADE), `key` (text)
- Key-value store for app-wide settings (assistant name, default model, etc.) - `value` (text)
- CRUD via `services/settings.py`: `get_setting(key, default)`, `set_setting(key, value)`, `set_settings_batch(dict)`, `get_all_settings()` - Per-user key-value store for settings (assistant name, default model, etc.)
- CRUD via `services/settings.py`: all functions take `user_id` as first parameter
### Conversations ### Conversations
- `id` (int PK), `title` (str), `model` (str), `created_at`, `updated_at` - `id` (int PK), `title` (str), `model` (str), `user_id` (nullable FK to users, CASCADE), `created_at`, `updated_at`
- Has many messages (cascade delete) - Has many messages (cascade delete)
- Title auto-generated from first user message if not set - Title auto-generated by LLM on first exchange and re-generated every 10th message
- Index on `updated_at` for list ordering - Index on `updated_at` for list ordering
- `to_dict()` returns: `id`, `title`, `model`, `message_count`, `created_at`, `updated_at` - `to_dict()` returns: `id`, `title`, `model`, `message_count`, `created_at`, `updated_at`
- `list_conversations()` uses a subquery for `message_count` instead of eager-loading all messages - `list_conversations()` uses a subquery for `message_count` instead of eager-loading all messages
### Messages ### Messages
- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant), - `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant),
`content` (str), `context_note_id` (nullable FK to notes, SET NULL), `created_at` `content` (str), `status` (str, default `'complete'``complete`/`generating`/`error`),
`context_note_id` (nullable FK to notes, SET NULL), `created_at`
- Index on `conversation_id` - Index on `conversation_id`
- `context_note_id` tracks which note was attached as context when message was sent - `context_note_id` tracks which note was attached as context when message was sent
- `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `context_note_id`, `created_at` - `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `status`, `context_note_id`, `created_at`
## Project Structure (Current) ## Project Structure (Current)
``` ```
@@ -164,6 +180,7 @@ fabledassistant/
├── pyproject.toml # Python project config ├── pyproject.toml # Python project config
├── Dockerfile # Multi-stage build (Node → Python) ├── Dockerfile # Multi-stage build (Node → Python)
├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama) ├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama)
├── docker-compose.prod.yml # Production stack (Docker Swarm, secrets, network isolation)
├── alembic.ini # Alembic config (prepend_sys_path = src) ├── alembic.ini # Alembic config (prepend_sys_path = src)
├── alembic/ ├── alembic/
│ ├── env.py # Async migration runner │ ├── env.py # Async migration runner
@@ -174,29 +191,39 @@ fabledassistant/
│ ├── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table │ ├── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table
│ ├── 0005_add_chat_tables.py # Conversations + messages tables with FKs and indexes │ ├── 0005_add_chat_tables.py # Conversations + messages tables with FKs and indexes
│ ├── 0006_add_settings_table.py # Settings key-value table │ ├── 0006_add_settings_table.py # Settings key-value table
── 0007_add_title_and_updated_at_indexes.py # B-tree indexes on notes.title and conversations.updated_at ── 0007_add_title_and_updated_at_indexes.py # B-tree indexes on notes.title and conversations.updated_at
│ ├── 0008_add_users_and_user_id.py # Users table, user_id FKs on notes/conversations/settings, composite PK for settings
│ └── 0009_add_message_status.py # Add status column to messages table (default 'complete')
├── src/ ├── src/
│ └── fabledassistant/ │ └── fabledassistant/
│ ├── __init__.py │ ├── __init__.py
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API │ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging middleware
│ ├── config.py # Config from env vars │ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret)
│ ├── models/ │ ├── models/
│ │ ├── __init__.py # async_session factory, Base, imports Note + TaskStatus + TaskPriority + Conversation + Message + Setting │ │ ├── __init__.py # async_session factory, Base, imports all models
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property │ │ ├── user.py # User model (id, username, email, password_hash, role, created_at)
│ │ ├── conversation.py # Conversation + Message models with relationships and to_dict() │ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, user_id, status, priority, due_date, timestamps)
│ │ ── setting.py # Setting model (key TEXT PK, value TEXT) │ │ ── conversation.py # Conversation + Message models with user_id
│ │ └── setting.py # Setting model (composite PK: user_id + key, value TEXT)
│ ├── routes/ │ ├── routes/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint │ │ ├── api.py # /api blueprint with /health endpoint (public)
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list/pull/delete, status check │ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks │ │ ├── admin.py # /api/admin blueprint: backup (user/full), restore (admin only)
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (imports directly from services.notes, accepts body not description) │ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
│ │ ── settings.py # /api/settings GET/PUT — app-wide settings │ │ ── notes.py # /api/notes CRUD + wikilinks + backlinks (all @login_required)
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
│ │ └── settings.py # /api/settings GET/PUT — per-user settings (@login_required)
│ ├── services/ │ ├── services/
│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks, search_notes_for_context (OR-keyword search), get_all_tags (SQL unnest) │ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id
│ │ ├── llm.py # Ollama interaction: get_installed_models (shared helper), ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, single OR-query note search, exclude_note_ids support, returns (messages, context_meta) tuple, URL fetching, configurable assistant name) │ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
│ │ ├── chat.py # Conversation CRUD (list uses subquery count), add_message, save_response_as_note, summarize_conversation_as_note │ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
│ │ ── settings.py # Settings CRUD: get_setting, set_setting, set_settings_batch, get_all_settings │ │ ── llm.py # Ollama interaction: build_context with user_id, streaming, URL fetching
│ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged)
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup
│ │ ├── generation_task.py # Background asyncio task: streams LLM into buffer, periodic DB flush, LLM title generation
│ │ └── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
│ ├── utils/ │ ├── utils/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences │ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
@@ -209,19 +236,21 @@ fabledassistant/
│ ├── App.vue # Shell: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification; starts status polling + loads settings on mount │ ├── App.vue # Shell: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification; starts status polling + loads settings on mount
│ ├── main.ts # App init, imports theme.css │ ├── main.ts # App init, imports theme.css
│ ├── assets/ │ ├── assets/
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset, design tokens (radius, semantic colors, focus ring, overlay), global focus-visible styles │ │ └── theme.css # CSS custom properties: light/dark themes, body reset, design tokens, responsive breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), mobile touch targets
│ ├── api/ │ ├── api/
│ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete + apiStreamPost (SSE via fetch + ReadableStream) │ │ └── client.ts # ApiError class, apiGet/apiPost/apiPut/apiPatch/apiDelete + apiSSEStream (EventSource-based), auto 401→login redirect
│ ├── composables/ │ ├── composables/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme │ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search │ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
│ ├── stores/ │ ├── stores/
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags │ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, login/register/logout/checkAuth
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description) │ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming, handles context event, accepts excludeNoteIds), lastContextMeta ref, saveMessageAsNote, summarizeAsNote, fetchModels, status polling (ollamaStatus, modelStatus, chatReady) │ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (with toast errors)
│ │ ├── settings.ts # App settings: assistantName, defaultModel, fetchInstalledModels, pullModel (SSE streaming with onProgress callback), deleteModel │ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling (with toast errors)
│ │ ── toast.ts # Toast notification state, 3s auto-dismiss │ │ ── settings.ts # App settings: assistantName, defaultModel, pullModel, deleteModel (with toast errors)
│ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id)
│ ├── types/ │ ├── types/
│ │ ├── 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, OllamaStatus interfaces
│ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category) │ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
@@ -230,17 +259,19 @@ fabledassistant/
│ │ ├── tags.ts # extractTags(), linkifyTags() (with (?<!&) to skip HTML entities), linkifyWikilinks() │ │ ├── tags.ts # extractTags(), linkifyTags() (with (?<!&) to skip HTML entities), linkifyWikilinks()
│ │ └── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces &#39; after sanitization │ │ └── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces &#39; after sanitization
│ ├── views/ │ ├── views/
│ │ ├── ChatView.vue # Dedicated /chat page: sidebar + bubble-style messages (user right, assistant left) + floating dark input bar + auto-focus + note picker + context pills (auto-found notes with promote/exclude) + exclude tracking │ │ ├── LoginView.vue # Login form with error display, link to register
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats (3 most recent) + new chat button │ │ ├── RegisterView.vue # Register form (username, password, email), first-user setup
│ │ ├── SettingsView.vue # Settings page: assistant name config + model catalog (18 models in 3 categories) with download/select/remove │ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar (overlay on mobile), bubble messages, note picker, context pills
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats
│ │ ├── 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: Ctrl+S, unsaved guard, toasts, autocomplete │ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task (only when !is_task), backlinks │ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle │ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
│ │ ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete │ │ ├── TaskEditorView.vue # Create/edit task: Ctrl+S, dirty guard, autocomplete
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks │ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note, backlinks
│ ├── components/ │ ├── components/
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks + Chat + Settings links, status indicator dot, chat panel toggle, theme toggle │ │ ├── AppHeader.vue # Nav bar: brand, nav links, 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
│ │ ├── 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 │ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit
@@ -251,9 +282,9 @@ fabledassistant/
│ │ ├── SearchBar.vue # Debounced search input │ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill │ │ ├── TagPill.vue # Clickable/dismissible tag pill
│ │ ├── PaginationBar.vue # Prev/next + page numbers │ │ ├── PaginationBar.vue # Prev/next + page numbers
│ │ └── ToastNotification.vue # Fixed-position toast container │ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
│ └── router/ │ └── router/
│ └── index.ts # Routes: /, /notes/*, /tasks/*, /chat, /chat/:id, /settings │ └── index.ts # Routes: /, /login, /register, /notes/*, /tasks/*, /chat, /chat/:id, /settings; beforeEach auth guard
└── public/ └── public/
``` ```
@@ -261,7 +292,14 @@ fabledassistant/
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/health` | Health check | | GET | `/api/health` | Health check (public) |
| GET | `/api/auth/status` | Check if any users exist (public) |
| POST | `/api/auth/register` | Register new user (first user becomes admin) |
| POST | `/api/auth/login` | Login with username/password |
| POST | `/api/auth/logout` | Logout (clear session) |
| GET | `/api/auth/me` | Get current user info |
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data, full requires admin) |
| POST | `/api/admin/restore` | Restore from JSON backup (admin only) |
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) | | GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`; defaults to `is_task=false` — plain notes only; `?is_task=true` for tasks, `?all=true` for everything) |
| POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) | | POST | `/api/notes` | Create note (body: `{title, body, status?, priority?, due_date?}` — tags auto-extracted) |
| GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) | | GET | `/api/notes/tags` | List all tags from notes table (param: `q` for filter) |
@@ -284,8 +322,10 @@ fabledassistant/
| 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 (body: `{title}`) |
| POST | `/api/chat/conversations/:id/messages` | Send message + stream LLM response via SSE (body: `{content, context_note_id?, exclude_note_ids?}`); emits `{context: ContextMeta}` SSE event before streaming chunks | | 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/messages/:id/save-as-note` | Save assistant message as a new note | | GET | `/api/chat/conversations/:id/generation/stream` | SSE endpoint tailing generation buffer; supports `Last-Event-ID` reconnection; emits `context`, `chunk`, `done`, `error` events |
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation (sets cancel_event, saves partial content) |
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note (LLM-generated title, tagged `chat`) |
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note | | POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note |
| 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 |
@@ -303,7 +343,7 @@ container startup.
### Migration Chain ### Migration Chain
``` ```
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py → 0007_add_title_and_updated_at_indexes.py 0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py → 0006_add_settings_table.py → 0007_add_title_and_updated_at_indexes.py → 0008_add_users_and_user_id.py → 0009_add_message_status.py
``` ```
### How Migrations Run ### How Migrations Run
@@ -438,13 +478,13 @@ When adding a new migration, follow these conventions:
- [x] **Context building:** System prompt with note-aware context — includes current note (via `context_note_id`), keyword-extracted related note search (top 3, 5 keywords, 2000-char previews), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars), returns `(messages, context_meta)` tuple with auto-found note IDs/titles, accepts `exclude_note_ids` to skip notes during auto-search - [x] **Context building:** System prompt with note-aware context — includes current note (via `context_note_id`), keyword-extracted related note search (top 3, 5 keywords, 2000-char previews), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars), returns `(messages, context_meta)` tuple with auto-found note IDs/titles, accepts `exclude_note_ids` to skip notes during auto-search
- [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD - [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD
- [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish - [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish
- [x] **Save as note:** Save any assistant message as a new note (first line as title) - [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] **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] **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] **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] **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] **Frontend SSE client:** `apiStreamPost()` uses fetch + ReadableStream to parse SSE data lines
- [x] **Auto-title:** Conversation title auto-set from first user message content - [x] **Auto-title:** Conversation title generated by LLM (concise 3-8 words)
### Phase 4.5 — Chat UX + Settings + Model Management ✓ ### Phase 4.5 — Chat UX + Settings + Model Management ✓
- [x] **Settings infrastructure:** Key-value `settings` table, `GET/PUT /api/settings`, Pinia store with defaults - [x] **Settings infrastructure:** Key-value `settings` table, `GET/PUT /api/settings`, Pinia store with defaults
@@ -495,12 +535,28 @@ When adding a new migration, follow these conventions:
- [x] **Database indexes:** Added B-tree indexes on `notes.title` (for `get_note_by_title` + backlinks) and `conversations.updated_at` (for list ordering) - [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 - [x] **Safe `Conversation.to_dict()`:** Handles case where messages relationship is not loaded
### Phase 5 — Polish & Production Hardening ### Phase 5 — Polish & Production Hardening
- [ ] Authentication (single-user or multi-user, TBD) - [x] **Multi-user authentication:** Session cookie auth with bcrypt, first-user-is-admin pattern, orphaned data claiming
- [ ] Docker Swarm production stack (secrets, volumes, networking) - [x] **Per-user data isolation:** All notes, conversations, settings filtered by `user_id`
- [ ] Backup/restore strategy for data - [x] **Auth decorators:** `@login_required`, `@admin_required` on all API routes (except health + auth status)
- [ ] UI/UX refinements, responsive design - [x] **Frontend auth flow:** Login/register views, router beforeEach guard, ApiError with auto-redirect on 401
- [ ] Error handling, logging, monitoring - [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`
### Future / Stretch ### Future / Stretch
- Tagging/labeling system with LLM-suggested tags - Tagging/labeling system with LLM-suggested tags
@@ -516,27 +572,25 @@ 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`
## Open Questions
- Authentication model: single-user (password-only) vs multi-user?
## Current Status ## Current Status
**Phase:** Phase 4.8 complete. Backend efficiency & consistency pass. **Phase:** Phase 5.1 complete. Chat UX Improvements.
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags - Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
- Unified note/task model (a task is a note with `status IS NOT NULL`) - Unified note/task model (a task is a note with `status IS NOT NULL`)
- LLM chat via Ollama with SSE streaming responses - **Multi-user authentication** with session cookies, bcrypt passwords, admin role
- Note-aware context: auto-includes current note + searches related notes by keyword (5 keywords, 2000-char previews) - **Per-user data isolation** across notes, conversations, and settings
- Context visibility: auto-found notes shown as pills with title links, promote (+) and exclude (x) controls - **First-user-is-admin** pattern; orphaned pre-auth data claimed by first user
- Note picker: attach any note as context from chat input via search dropdown - LLM chat via Ollama with background generation task + SSE streaming
- Exclude tracking: session-scoped per conversation, excluded notes skipped in future auto-searches - **LLM-generated titles** on first exchange and every 10th message
- Multi-word search: splits query into per-term ILIKE with AND logic (words match independently) - **Stop generation** button with partial content preservation
- URL content fetching: detects URLs in messages, fetches and includes content - **Relative timestamps** in sidebar (5m ago, 3h ago, then dates)
- Save assistant messages as notes; summarize entire conversations as notes - **Empty chat cleanup** on navigation away
- Dedicated `/chat` page with bubble-style messages (user right, assistant left), floating dark input bar - Note-aware context: auto-includes current note + searches related notes by keyword
- Slide-out chat panel accessible from any page, auto-includes note/task context - Context pills with promote/exclude controls; note picker in chat input
- Settings page: configurable assistant name (default "Fable"), model catalog with 18 models in 3 categories - Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
- Model management: download (with live SSE progress percentage), select, and remove models from the settings page - Dedicated `/chat` page with responsive sidebar (overlay on mobile)
- Ollama status indicator in global nav bar (green/yellow/red dot) with 30s polling - Settings page: assistant name, model catalog, **data export/restore (admin)**
- Recent chats section on home page with quick "New Chat" button - **Request logging** with timing, sanitized 500 errors, configurable LOG_LEVEL
- HTML entity rendering fix (apostrophes in marked → DOMPurify pipeline) - **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
- Dark/light theme with CSS custom properties - **Toast notifications** with success/error/warning types and dismiss button
- Ready for Phase 5: Polish & Production Hardening - **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
- Dark/light theme with CSS custom properties and design tokens