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
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>
<meta charset="UTF-8" />
<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>
</head>
<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">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute } from "vue-router";
import AppHeader from "@/components/AppHeader.vue";
import ChatPanel from "@/components/ChatPanel.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
useTheme();
const route = useRoute();
const authStore = useAuthStore();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const chatPanelOpen = ref(false);
@@ -29,23 +31,50 @@ function toggleChatPanel() {
chatPanelOpen.value = !chatPanelOpen.value;
}
onMounted(() => {
function startAppServices() {
chatStore.startStatusPolling();
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(() => {
chatStore.stopStatusPolling();
stopAppServices();
});
</script>
<template>
<AppHeader @toggle-chat-panel="toggleChatPanel" />
<router-view />
<ChatPanel
v-if="chatPanelOpen"
:context-note-id="contextNoteId"
@close="chatPanelOpen = false"
/>
<template v-if="authStore.isAuthenticated">
<AppHeader @toggle-chat-panel="toggleChatPanel" />
<router-view />
<ChatPanel
v-if="chatPanelOpen"
:context-note-id="contextNoteId"
@close="chatPanelOpen = false"
/>
</template>
<template v-else>
<router-view />
</template>
<ToastNotification />
</template>
+203 -34
View File
@@ -1,21 +1,55 @@
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
export class ApiError extends Error {
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) {
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>;
}
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> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
return handleResponse<T>(res, path);
}
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" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
return handleResponse<T>(res, path);
}
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" },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
return res.json() as Promise<T>;
return handleResponse<T>(res, path);
}
export async function apiDelete(path: string): Promise<void> {
const res = await fetch(path, { method: "DELETE" });
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
return handleResponse<void>(res, path);
}
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(
@@ -60,7 +206,19 @@ export async function apiStreamPost(
body: JSON.stringify(body),
});
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();
if (!reader) throw new Error("No response body");
@@ -68,35 +226,46 @@ export async function apiStreamPost(
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
function processLines(text: string) {
const lines = text.split("\n");
// Keep the last (possibly incomplete) line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("data: ")) {
let data;
try {
const data = JSON.parse(trimmed.slice(6));
onChunk(data);
data = JSON.parse(trimmed.slice(6));
} catch {
// Skip malformed JSON lines
continue; // Skip malformed JSON lines
}
onChunk(data);
}
}
}
// Process any remaining buffer
if (buffer.trim().startsWith("data: ")) {
try {
const data = JSON.parse(buffer.trim().slice(6));
onChunk(data);
} catch {
// Skip malformed JSON
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
processLines(buffer);
}
} 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;
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">
import { computed } from "vue";
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import { useTheme } from "@/composables/useTheme";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue";
const { theme, toggleTheme } = useTheme();
const authStore = useAuthStore();
const chatStore = useChatStore();
const router = useRouter();
const mobileMenuOpen = ref(false);
const emit = defineEmits<{
toggleChatPanel: [];
@@ -23,13 +29,38 @@ const statusClass = computed(() => {
if (chatStore.chatReady) return "status-green";
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>
<template>
<header class="app-header">
<nav class="nav">
<router-link to="/" class="nav-brand">Fabled Assistant</router-link>
<div class="nav-links">
<router-link to="/" class="nav-brand">
<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="/tasks" class="nav-link">Tasks</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`">
{{ theme === "dark" ? "\u2600" : "\u263E" }}
</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>
</nav>
</header>
@@ -60,11 +98,30 @@ const statusClass = computed(() => {
justify-content: space-between;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 1.1rem;
color: var(--color-primary);
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 {
display: flex;
align-items: center;
@@ -139,4 +196,84 @@ const statusClass = computed(() => {
.theme-toggle:hover {
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>
+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--${toast.type}`"
>
{{ toast.message }}
<span class="toast-msg">{{ toast.message }}</span>
<button class="toast-close" @click="toastStore.dismiss(toast.id)">&times;</button>
</div>
</transition-group>
</div>
@@ -30,19 +31,47 @@ const toastStore = useToastStore();
gap: 0.5rem;
}
.toast {
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
box-shadow: 0 2px 8px var(--color-shadow);
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 {
background: var(--color-toast-success);
}
.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-leave-active {
transition: all 0.3s ease;
+28
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "@/views/HomeView.vue";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({
history: createWebHistory(),
@@ -9,6 +10,18 @@ const router = createRouter({
name: "home",
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",
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;
+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,
apiPatch,
apiDelete,
apiStreamPost,
apiSSEStream,
} from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type {
Conversation,
ConversationDetail,
@@ -14,6 +15,7 @@ import type {
Message,
OllamaModel,
OllamaStatus,
SendMessageResponse,
} from "@/types/chat";
export const useChatStore = defineStore("chat", () => {
@@ -42,6 +44,9 @@ export const useChatStore = defineStore("chat", () => {
);
conversations.value = data.conversations;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load conversations", "error");
throw e;
} finally {
loading.value = false;
}
@@ -51,12 +56,17 @@ export const useChatStore = defineStore("chat", () => {
title = "",
model = ""
): Promise<Conversation> {
const conv = await apiPost<Conversation>("/api/chat/conversations", {
title,
model,
});
conversations.value.unshift(conv);
return conv;
try {
const conv = await apiPost<Conversation>("/api/chat/conversations", {
title,
model,
});
conversations.value.unshift(conv);
return conv;
} catch (e) {
useToastStore().show("Failed to create conversation", "error");
throw e;
}
}
async function fetchConversation(id: number) {
@@ -65,30 +75,43 @@ export const useChatStore = defineStore("chat", () => {
currentConversation.value = await apiGet<ConversationDetail>(
`/api/chat/conversations/${id}`
);
} catch (e) {
useToastStore().show("Failed to load conversation", "error");
throw e;
} finally {
loading.value = false;
}
}
async function deleteConversation(id: number) {
await apiDelete(`/api/chat/conversations/${id}`);
conversations.value = conversations.value.filter((c) => c.id !== id);
if (currentConversation.value?.id === id) {
currentConversation.value = null;
try {
await apiDelete(`/api/chat/conversations/${id}`);
conversations.value = conversations.value.filter((c) => c.id !== id);
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) {
const updated = await apiPatch<Conversation>(
`/api/chat/conversations/${id}`,
{ title }
);
const idx = conversations.value.findIndex((c) => c.id === id);
if (idx !== -1) {
conversations.value[idx] = { ...conversations.value[idx], ...updated };
}
if (currentConversation.value?.id === id) {
currentConversation.value.title = updated.title;
try {
const updated = await apiPatch<Conversation>(
`/api/chat/conversations/${id}`,
{ title }
);
const idx = conversations.value.findIndex((c) => c.id === id);
if (idx !== -1) {
conversations.value[idx] = { ...conversations.value[idx], ...updated };
}
if (currentConversation.value?.id === id) {
currentConversation.value.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;
streamingContent.value = "";
// Phase 1: POST to start generation
let assistantMessageId: number;
try {
await apiStreamPost(
const resp = await apiPost<SendMessageResponse>(
`/api/chat/conversations/${convId}/messages`,
{
content,
context_note_id: contextNoteId,
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;
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,
updateTitle,
sendMessage,
cancelGeneration,
saveMessageAsNote,
summarizeAsNote,
fetchModels,
+55 -25
View File
@@ -1,6 +1,7 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Note, NoteListResponse } from "@/types/note";
export const useNotesStore = defineStore("notes", () => {
@@ -36,6 +37,9 @@ export const useNotesStore = defineStore("notes", () => {
);
notes.value = data.notes;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load notes", "error");
throw e;
} finally {
loading.value = false;
}
@@ -62,6 +66,9 @@ export const useNotesStore = defineStore("notes", () => {
loading.value = true;
try {
currentNote.value = await apiGet<Note>(`/api/notes/${id}`);
} catch (e) {
useToastStore().show("Failed to load note", "error");
throw e;
} finally {
loading.value = false;
}
@@ -71,26 +78,40 @@ export const useNotesStore = defineStore("notes", () => {
title: string;
body: string;
}): Promise<Note> {
const note = await apiPost<Note>("/api/notes", data);
return note;
try {
return await apiPost<Note>("/api/notes", data);
} catch (e) {
useToastStore().show("Failed to create note", "error");
throw e;
}
}
async function updateNote(
id: number,
data: Partial<Pick<Note, "title" | "body">>
): Promise<Note> {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
if (currentNote.value?.id === id) {
currentNote.value = note;
try {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
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) {
await apiDelete(`/api/notes/${id}`);
notes.value = notes.value.filter((n) => n.id !== id);
if (currentNote.value?.id === id) {
currentNote.value = null;
try {
await apiDelete(`/api/notes/${id}`);
notes.value = notes.value.filter((n) => n.id !== id);
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> {
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) {
currentNote.value = note;
try {
const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-task`,
{}
);
if (currentNote.value?.id === noteId) {
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> {
const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-note`,
{}
);
if (currentNote.value?.id === noteId) {
currentNote.value = note;
try {
const note = await apiPost<Note>(
`/api/notes/${noteId}/convert-to-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(
+11 -2
View File
@@ -1,6 +1,7 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPut, apiPost, apiStreamPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { AppSettings } from "@/types/settings";
export const useSettingsStore = defineStore("settings", () => {
@@ -31,6 +32,9 @@ export const useSettingsStore = defineStore("settings", () => {
loading.value = true;
try {
settings.value = await apiPut<AppSettings>("/api/settings", updates);
} catch (e) {
useToastStore().show("Failed to save settings", "error");
throw e;
} finally {
loading.value = false;
}
@@ -55,8 +59,13 @@ export const useSettingsStore = defineStore("settings", () => {
}
async function deleteModel(model: string) {
await apiPost("/api/chat/models/delete", { model });
await fetchInstalledModels();
try {
await apiPost("/api/chat/models/delete", { model });
await fetchInstalledModels();
} catch (e) {
useToastStore().show("Failed to delete model", "error");
throw e;
}
}
return {
+44 -18
View File
@@ -1,6 +1,7 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task";
export const useTasksStore = defineStore("tasks", () => {
@@ -41,6 +42,9 @@ export const useTasksStore = defineStore("tasks", () => {
);
tasks.value = data.tasks;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load tasks", "error");
throw e;
} finally {
loading.value = false;
}
@@ -50,6 +54,9 @@ export const useTasksStore = defineStore("tasks", () => {
loading.value = true;
try {
currentTask.value = await apiGet<Task>(`/api/tasks/${id}`);
} catch (e) {
useToastStore().show("Failed to load task", "error");
throw e;
} finally {
loading.value = false;
}
@@ -62,7 +69,12 @@ export const useTasksStore = defineStore("tasks", () => {
priority?: TaskPriority;
due_date?: string | null;
}): 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(
@@ -71,31 +83,45 @@ export const useTasksStore = defineStore("tasks", () => {
Pick<Task, "title" | "body" | "status" | "priority" | "due_date">
>
): Promise<Task> {
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
if (currentTask.value?.id === id) {
currentTask.value = task;
try {
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
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> {
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
// Update in list if present
const idx = tasks.value.findIndex((t) => t.id === id);
if (idx !== -1) {
tasks.value[idx] = task;
try {
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
const idx = tasks.value.findIndex((t) => t.id === id);
if (idx !== -1) {
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) {
await apiDelete(`/api/tasks/${id}`);
tasks.value = tasks.value.filter((t) => t.id !== id);
if (currentTask.value?.id === id) {
currentTask.value = null;
try {
await apiDelete(`/api/tasks/${id}`);
tasks.value = tasks.value.filter((t) => t.id !== id);
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 {
id: number;
message: string;
type: "success" | "error";
type: "success" | "error" | "warning";
}
let nextId = 0;
@@ -12,13 +12,17 @@ let nextId = 0;
export const useToastStore = defineStore("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++;
toasts.value.push({ id, message, type });
setTimeout(() => {
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;
role: "system" | "user" | "assistant";
content: string;
status?: "complete" | "generating" | "error";
context_note_id: number | null;
created_at: string;
}
export interface SendMessageResponse {
assistant_message_id: number;
status: string;
}
export interface Conversation {
id: number;
title: string;
+161 -10
View File
@@ -18,6 +18,7 @@ const messagesEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
const sending = ref(false);
const summarizing = ref(false);
const sidebarOpen = ref(false);
// Note picker state
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)
const excludedNoteIds = ref<Set<number>>(new Set());
let prevConvId: number | null = null;
const convId = computed(() => {
const id = route.params.id;
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(() => {
if (!store.streamingContent) return "";
return renderMarkdown(store.streamingContent);
@@ -54,11 +76,24 @@ onMounted(async () => {
});
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();
attachedNote.value = null;
store.lastContextMeta = null;
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();
} else {
store.currentConversation = null;
@@ -79,7 +114,12 @@ function scrollToBottom() {
});
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value;
}
async function selectConversation(id: number) {
sidebarOpen.value = false;
router.push(`/chat/${id}`);
}
@@ -228,7 +268,12 @@ function excludeAutoNote(noteId: number) {
<template>
<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">
+ New Chat
</button>
@@ -240,7 +285,10 @@ function excludeAutoNote(noteId: number) {
:class="{ active: convId === 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
class="btn-delete-conv"
@click.stop="removeConversation(conv.id)"
@@ -258,6 +306,13 @@ function excludeAutoNote(noteId: number) {
<section class="chat-main">
<template v-if="store.currentConversation">
<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>
<button
v-if="store.currentConversation.messages.length"
@@ -383,9 +438,20 @@ function excludeAutoNote(noteId: number) {
rows="1"
></textarea>
<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"
@click="sendMessage"
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
:disabled="!messageInput.trim() || !store.chatReady"
>
&uarr;
</button>
@@ -394,6 +460,13 @@ function excludeAutoNote(noteId: number) {
</template>
<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>
<button class="btn-new-conv" @click="newConversation">
+ New Chat
@@ -455,13 +528,26 @@ function excludeAutoNote(noteId: number) {
background: var(--color-primary);
color: #fff;
}
.conv-title {
.conv-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.conv-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
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 {
background: none;
border: none;
@@ -526,6 +612,9 @@ function excludeAutoNote(noteId: number) {
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
max-width: 848px;
margin: 0 auto;
width: 100%;
}
.messages-inner {
margin-top: auto;
@@ -643,7 +732,11 @@ function excludeAutoNote(noteId: number) {
/* 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 */
@@ -789,6 +882,23 @@ function excludeAutoNote(noteId: number) {
opacity: 0.35;
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 {
flex: 1;
@@ -807,16 +917,57 @@ function excludeAutoNote(noteId: number) {
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 {
width: 200px;
min-width: 160px;
position: fixed;
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 {
padding: 0.75rem;
}
.input-wrapper {
margin: 0 0.5rem 0.5rem;
padding: 0 0.5rem;
margin-bottom: 0.5rem;
}
}
</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">
import { ref, computed, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import type { ModelInfo } from "@/types/settings";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const assistantName = ref("");
const saving = ref(false);
const saved = ref(false);
const pullProgress = ref<{ model: string; percent: number } | null>(null);
const deleting = 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[] = [
// — General Purpose —
@@ -244,6 +251,63 @@ async function removeModel(name: string) {
function cancelDelete() {
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>
<template>
@@ -345,6 +409,45 @@ function cancelDelete() {
</div>
</div>
</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>
</template>
@@ -598,4 +701,48 @@ function cancelDelete() {
color: var(--color-text);
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>
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"alembic>=1.13",
"httpx>=0.27",
"hypercorn>=0.17",
"bcrypt>=4.0",
]
[project.optional-dependencies]
+33 -4
View File
@@ -1,10 +1,13 @@
import logging
import time
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.routes.admin import admin_bp
from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.settings import settings_bp
@@ -15,21 +18,49 @@ logger = logging.getLogger(__name__)
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.secret_key = Config.SECRET_KEY
app.register_blueprint(admin_bp)
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(settings_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
async def startup():
import asyncio
from fabledassistant.services.generation_buffer import start_cleanup_loop
from fabledassistant.services.llm import ensure_model
start_cleanup_loop()
async def _pull_model():
try:
await ensure_model(Config.OLLAMA_MODEL)
@@ -70,11 +101,9 @@ def create_app() -> Quart:
@app.errorhandler(500)
async def handle_500(error):
import traceback
traceback.print_exc()
logger.exception("Internal server error on %s %s", request.method, request.path)
if request.path.startswith("/api/"):
return jsonify({"error": str(error)}), 500
return jsonify({"error": "Internal server error"}), 500
return "Internal Server Error", 500
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
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:
DATABASE_URL: str = os.environ.get(
DATABASE_URL: str = _read_secret(
"DATABASE_URL",
"DATABASE_URL_FILE",
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
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.conversation import Conversation, Message # 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"
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="")
model: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(
@@ -30,6 +33,7 @@ class Conversation(Base):
__table_args__ = (
Index("ix_conversations_updated_at", "updated_at"),
Index("ix_conversations_user_id", "user_id"),
)
def to_dict(self) -> dict:
@@ -57,6 +61,7 @@ class Message(Base):
)
role: Mapped[str] = mapped_column(Text)
content: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="complete")
context_note_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
@@ -76,6 +81,7 @@ class Message(Base):
"conversation_id": self.conversation_id,
"role": self.role,
"content": self.content,
"status": self.status,
"context_note_id": self.context_note_id,
"created_at": self.created_at.isoformat(),
}
+5 -1
View File
@@ -1,7 +1,7 @@
import enum
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.orm import Mapped, mapped_column
@@ -25,6 +25,9 @@ class Note(Base):
__tablename__ = "notes"
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="")
body: Mapped[str] = mapped_column(Text, default="")
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_status", "status"),
Index("ix_notes_title", "title"),
Index("ix_notes_user_id", "user_id"),
)
@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 fabledassistant.models import Base
@@ -7,6 +7,9 @@ from fabledassistant.models import Base
class Setting(Base):
__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)
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 logging
import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
@@ -15,7 +17,13 @@ from fabledassistant.services.chat import (
summarize_conversation_as_note,
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
logger = logging.getLogger(__name__)
@@ -24,10 +32,12 @@ chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
@chat_bp.route("/conversations", methods=["GET"])
@login_required
async def list_conversations_route():
uid = get_current_user_id()
limit = request.args.get("limit", 50, 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({
"conversations": conversations,
"total": total,
@@ -35,17 +45,21 @@ async def list_conversations_route():
@chat_bp.route("/conversations", methods=["POST"])
@login_required
async def create_conversation_route():
uid = get_current_user_id()
data = await request.get_json(force=True, silent=True) or {}
title = data.get("title", "")
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
@chat_bp.route("/conversations/<int:conv_id>", methods=["GET"])
@login_required
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:
return jsonify({"error": "Conversation not found"}), 404
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"])
@login_required
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:
return jsonify({"error": "Conversation not found"}), 404
return "", 204
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
@login_required
async def update_conversation_route(conv_id: int):
uid = get_current_user_id()
data = await request.get_json()
title = data.get("title")
if title is None:
return jsonify({"error": "title is required"}), 400
conv = await update_conversation_title(conv_id, title)
conv = await update_conversation_title(uid, conv_id, title)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
return jsonify(conv.to_dict())
@chat_bp.route("/conversations/<int:conv_id>/messages", methods=["POST"])
@login_required
async def send_message_route(conv_id: int):
"""Send a user message, stream the LLM response via SSE."""
conv = await get_conversation(conv_id)
"""Start generation: save user message, launch background task, return 202."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
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")
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
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 = []
for msg in conv.messages:
if msg.role != "system":
@@ -98,43 +128,60 @@ async def send_message_route(conv_id: int):
# Build context with note search, URL fetching, etc.
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():
# Emit context metadata before streaming LLM response
context_event = json.dumps({"context": context_meta})
yield f"data: {context_event}\n\n"
# Launch background generation task
asyncio.create_task(run_generation(
buf, messages, model, context_meta,
uid, conv_id, conv.title, content,
))
full_response = []
try:
async for chunk in stream_chat(messages, model):
full_response.append(chunk)
event_data = json.dumps({"chunk": chunk})
yield f"data: {event_data}\n\n"
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
}), 202
# 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
if not conv.title:
title = content[:80]
if len(content) > 80:
title += "..."
await update_conversation_title(conv_id, title)
@chat_bp.route("/conversations/<int:conv_id>/generation/stream", methods=["GET"])
@login_required
async def generation_stream_route(conv_id: int):
"""SSE endpoint that tails the generation buffer 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
done_data = json.dumps({"done": True, "message_id": msg.id})
yield f"data: {done_data}\n\n"
except Exception as e:
logger.exception("Error streaming LLM response")
error_data = json.dumps({"error": str(e)})
yield f"data: {error_data}\n\n"
buf = get_buffer(conv_id)
if buf is None:
return jsonify({"error": "No active generation"}), 404
# Determine starting point from Last-Event-ID header or query param
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(
generate(),
stream(),
content_type="text/event-stream",
headers={
"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"])
@login_required
async def save_message_as_note_route(message_id: int):
uid = get_current_user_id()
try:
note = await save_response_as_note(message_id)
note = await save_response_as_note(uid, message_id)
return jsonify(note), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
@chat_bp.route("/conversations/<int:conv_id>/summarize", methods=["POST"])
@login_required
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:
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:
note = await summarize_conversation_as_note(conv_id, model)
note = await summarize_conversation_as_note(uid, conv_id, model)
return jsonify(note), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
@chat_bp.route("/status", methods=["GET"])
@login_required
async def chat_status_route():
"""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 = {
"ollama": "unavailable",
"model": "not_found",
@@ -190,6 +260,7 @@ async def chat_status_route():
@chat_bp.route("/models", methods=["GET"])
@login_required
async def list_models_route():
try:
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"])
@login_required
async def pull_model_route():
"""Pull a model from Ollama, streaming progress via SSE."""
data = await request.get_json()
@@ -245,6 +317,7 @@ async def pull_model_route():
@chat_bp.route("/models/delete", methods=["POST"])
@login_required
async def delete_model_route():
"""Delete a model from Ollama."""
data = await request.get_json()
+34 -10
View File
@@ -2,6 +2,7 @@ from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.notes import (
convert_note_to_task,
convert_task_to_note,
@@ -21,7 +22,9 @@ notes_bp = Blueprint("notes", __name__, url_prefix="/api/notes")
@notes_bp.route("", methods=["GET"])
@login_required
async def list_notes_route():
uid = get_current_user_id()
q = request.args.get("q")
tag = request.args.getlist("tag")
sort = request.args.get("sort", "updated_at")
@@ -37,13 +40,15 @@ async def list_notes_route():
is_task = True
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})
@notes_bp.route("", methods=["POST"])
@login_required
async def create_note_route():
uid = get_current_user_id()
data = await request.get_json()
body = data.get("body", "")
tags = extract_tags(body)
@@ -56,6 +61,7 @@ async def create_note_route():
due_date = date.fromisoformat(data["due_date"])
note = await create_note(
uid,
title=data.get("title", ""),
body=body,
tags=tags,
@@ -68,43 +74,53 @@ async def create_note_route():
@notes_bp.route("/tags", methods=["GET"])
@login_required
async def list_tags_route():
uid = get_current_user_id()
q = request.args.get("q")
tags = await get_all_tags(q=q)
tags = await get_all_tags(uid, q=q)
return jsonify({"tags": tags})
@notes_bp.route("/by-title", methods=["GET"])
@login_required
async def get_note_by_title_route():
uid = get_current_user_id()
title = request.args.get("title", "")
if not title:
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:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/resolve-title", methods=["POST"])
@login_required
async def resolve_title_route():
uid = get_current_user_id()
data = await request.get_json()
title = data.get("title", "").strip()
if not title:
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())
@notes_bp.route("/<int:note_id>", methods=["GET"])
@login_required
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:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["PUT"])
@login_required
async def update_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
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:
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:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["DELETE"])
@login_required
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:
return jsonify({"error": "Note not found"}), 404
return "", 204
@notes_bp.route("/<int:note_id>/convert-to-task", methods=["POST"])
@login_required
async def convert_note_to_task_route(note_id: int):
uid = get_current_user_id()
try:
note = await convert_note_to_task(note_id)
note = await convert_note_to_task(uid, note_id)
return jsonify(note.to_dict()), 200
except ValueError as e:
return jsonify({"error": str(e)}), 404
@notes_bp.route("/<int:note_id>/convert-to-note", methods=["POST"])
@login_required
async def convert_task_to_note_route(note_id: int):
uid = get_current_user_id()
try:
note = await convert_task_to_note(note_id)
note = await convert_task_to_note(uid, note_id)
return jsonify(note.to_dict()), 200
except ValueError as e:
return jsonify({"error": str(e)}), 404
@notes_bp.route("/<int:note_id>/backlinks", methods=["GET"])
@login_required
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})
+8 -3
View File
@@ -1,5 +1,6 @@
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.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"])
@login_required
async def get_settings_route():
settings = await get_all_settings()
uid = get_current_user_id()
settings = await get_all_settings(uid)
return jsonify(settings)
@settings_bp.route("", methods=["PUT"])
@login_required
async def update_settings_route():
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
@@ -24,6 +29,6 @@ async def update_settings_route():
if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400
await set_settings_batch({k: str(v) for k, v in data.items()})
settings = await get_all_settings()
await set_settings_batch(uid, {k: str(v) for k, v in data.items()})
settings = await get_all_settings(uid)
return jsonify(settings)
+19 -4
View File
@@ -2,6 +2,7 @@ from datetime import date
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.services.notes import (
create_note,
@@ -16,7 +17,9 @@ tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
@tasks_bp.route("", methods=["GET"])
@login_required
async def list_tasks_route():
uid = get_current_user_id()
q = request.args.get("q")
tag = request.args.getlist("tag")
status = request.args.get("status")
@@ -27,6 +30,7 @@ async def list_tasks_route():
offset = request.args.get("offset", 0, type=int)
tasks, total = await list_notes(
uid,
q=q,
tags=tag or None,
is_task=True,
@@ -41,7 +45,9 @@ async def list_tasks_route():
@tasks_bp.route("", methods=["POST"])
@login_required
async def create_task_route():
uid = get_current_user_id()
data = await request.get_json()
body = data.get("body", "") or data.get("description", "")
tags = extract_tags(body)
@@ -56,6 +62,7 @@ async def create_task_route():
)
task = await create_note(
uid,
title=data.get("title", ""),
body=body,
status=status,
@@ -67,15 +74,19 @@ async def create_task_route():
@tasks_bp.route("/<int:task_id>", methods=["GET"])
@login_required
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:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>", methods=["PUT"])
@login_required
async def update_task_route(task_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "status", "priority"):
@@ -96,14 +107,16 @@ async def update_task_route(task_id: int):
if "body" in fields:
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:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>/status", methods=["PATCH"])
@login_required
async def patch_task_status(task_id: int):
uid = get_current_user_id()
data = await request.get_json()
status_val = data.get("status")
if not status_val:
@@ -114,15 +127,17 @@ async def patch_task_status(task_id: int):
except ValueError:
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:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
@login_required
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:
return jsonify({"error": "Task not found"}), 404
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__)
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:
conv = Conversation(title=title, model=model)
conv = Conversation(user_id=user_id, title=title, model=model)
session.add(conv)
await session.commit()
# 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()
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:
result = await session.execute(
select(Conversation)
.options(selectinload(Conversation.messages))
.where(Conversation.id == conversation_id)
.where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
)
return result.scalars().first()
async def list_conversations(
limit: int = 50, offset: int = 0
user_id: int, limit: int = 50, offset: int = 0
) -> tuple[list[dict], int]:
async with async_session() as session:
total = await session.scalar(
select(func.count(Conversation.id))
select(func.count(Conversation.id)).where(
Conversation.user_id == user_id
)
) or 0
# Subquery for message count — avoids loading all messages
@@ -54,6 +63,7 @@ async def list_conversations(
result = await session.execute(
select(Conversation, msg_count.label("message_count"))
.where(Conversation.user_id == user_id)
.order_by(Conversation.updated_at.desc())
.limit(limit)
.offset(offset)
@@ -75,9 +85,15 @@ async def list_conversations(
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:
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:
return False
await session.delete(conv)
@@ -86,10 +102,16 @@ async def delete_conversation(conversation_id: int) -> bool:
async def update_conversation_title(
conversation_id: int, title: str
user_id: int, conversation_id: int, title: str
) -> Conversation | None:
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:
return None
conv.title = title
@@ -104,14 +126,18 @@ async def add_message(
role: str,
content: str,
context_note_id: int | None = None,
status: str | None = None,
) -> Message:
async with async_session() as session:
msg = Message(
kwargs: dict = dict(
conversation_id=conversation_id,
role=role,
content=content,
context_note_id=context_note_id,
)
if status is not None:
kwargs["status"] = status
msg = Message(**kwargs)
session.add(msg)
# Touch conversation updated_at
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)
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."""
msg = await get_message(message_id)
if msg is None:
@@ -135,20 +161,42 @@ async def save_response_as_note(message_id: int) -> dict:
if msg.role != "assistant":
raise ValueError("Can only save assistant messages as notes")
# Use first line as title, rest as body
lines = msg.content.strip().split("\n", 1)
title = lines[0].strip().lstrip("# ")[:100]
body = msg.content
conv = await get_conversation(user_id, msg.conversation_id)
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()
async def summarize_conversation_as_note(
conversation_id: int, model: str
user_id: int, conversation_id: int, model: str
) -> dict:
"""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:
raise ValueError("Conversation not found")
@@ -178,5 +226,5 @@ async def summarize_conversation_as_note(
title = conv.title or "Conversation Summary"
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()
@@ -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(
user_id: int,
history: list[dict],
current_note_id: int | None,
user_message: str,
@@ -160,7 +161,7 @@ async def build_context(
which notes were included as context.
"""
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 = [
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. "
@@ -175,7 +176,7 @@ async def build_context(
# Include current note context if provided
if current_note_id:
note = await get_note(current_note_id)
note = await get_note(user_id, current_note_id)
if note:
context_meta["context_note_id"] = note.id
context_meta["context_note_title"] = note.title
@@ -194,7 +195,7 @@ async def build_context(
search_exclude.add(current_note_id)
try:
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] = []
for n in notes:
+48 -22
View File
@@ -10,6 +10,7 @@ logger = logging.getLogger(__name__)
async def create_note(
user_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
@@ -20,6 +21,7 @@ async def create_note(
) -> Note:
async with async_session() as session:
note = Note(
user_id=user_id,
title=title,
body=body,
tags=tags or [],
@@ -34,12 +36,16 @@ async def create_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:
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(
user_id: int,
q: str | None = None,
tags: list[str] | None = None,
is_task: bool | None = None,
@@ -51,8 +57,8 @@ async def list_notes(
offset: int = 0,
) -> tuple[list[Note], int]:
async with async_session() as session:
query = select(Note)
count_query = select(func.count(Note.id))
query = select(Note).where(Note.user_id == user_id)
count_query = select(func.count(Note.id)).where(Note.user_id == user_id)
# Filter by task vs note
if is_task is True:
@@ -104,25 +110,31 @@ async def list_notes(
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:
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()
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()
note = await get_note_by_title(title)
note = await get_note_by_title(user_id, title)
if 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:
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:
return None
for key, value in fields.items():
@@ -139,9 +151,12 @@ async def update_note(note_id: int, **fields: object) -> Note | None:
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:
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:
return False
await session.delete(note)
@@ -149,10 +164,13 @@ async def delete_note(note_id: int) -> bool:
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:
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()]
@@ -163,9 +181,12 @@ async def get_all_tags(q: str | None = None) -> list[str]:
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:
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:
logger.warning("convert_note_to_task: note %d not found", note_id)
raise ValueError("Note not found")
@@ -178,9 +199,12 @@ async def convert_note_to_task(note_id: int) -> 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:
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:
logger.warning("convert_task_to_note: note %d not found", note_id)
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(
user_id: int,
keywords: list[str],
exclude_ids: set[int] | None = None,
limit: int = 3,
@@ -207,7 +232,7 @@ async def search_notes_for_context(
pattern = f"%{escaped}%"
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:
query = query.where(Note.id.notin_(exclude_ids))
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())
async def get_backlinks(note_id: int) -> list[dict]:
note = await get_note(note_id)
async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
note = await get_note(user_id, note_id)
if note is None:
return []
@@ -232,6 +257,7 @@ async def get_backlinks(note_id: int) -> list[dict]:
results = await session.execute(
select(Note.id, Note.title, Note.status).where(
Note.user_id == user_id,
Note.id != note_id,
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__)
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:
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()
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:
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()
if setting:
setting.value = value
else:
session.add(Setting(key=key, value=value))
session.add(Setting(user_id=user_id, key=key, value=value))
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."""
async with async_session() as session:
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()
if setting:
setting.value = value
else:
session.add(Setting(key=key, value=value))
session.add(Setting(user_id=user_id, key=key, value=value))
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:
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()}
+133 -79
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-11 — Phase 4.8: Backend efficiency & consistency pass
2026-02-11 — Phase 5.1: Chat UX Improvements
## Project Overview
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
bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create
missing notes.
- **SSE over WebSockets for LLM streaming:** Uses `POST` with `text/event-stream`
response (not EventSource). Frontend uses `fetch()` + `ReadableStream` since we
need to POST the message body. Simpler than WebSockets; Quart supports async
generators natively for SSE.
- **Background generation architecture:** LLM streaming runs in a detached
`asyncio.Task` that writes into an in-memory `GenerationBuffer`. SSE clients
tail the buffer and can reconnect mid-stream without data loss. Buffer has
`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 —
frontend just sends the message text + optional note ID + optional exclude list.
Keyword extraction uses simple word splitting with stopword filtering (no embeddings).
@@ -115,9 +120,18 @@ for AI-assisted features.
## 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)
- `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),
`created_at`, `updated_at`
- **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
### Settings
- `key` (text PK), `value` (text)
- Key-value store for app-wide settings (assistant name, default model, etc.)
- CRUD via `services/settings.py`: `get_setting(key, default)`, `set_setting(key, value)`, `set_settings_batch(dict)`, `get_all_settings()`
- Composite PK: `(user_id, key)` `user_id` FK to users (CASCADE), `key` (text)
- `value` (text)
- 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
- `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)
- 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
- `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
### Messages
- `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`
- `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)
```
@@ -164,6 +180,7 @@ fabledassistant/
├── pyproject.toml # Python project config
├── Dockerfile # Multi-stage build (Node → Python)
├── 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/
│ ├── 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
│ ├── 0005_add_chat_tables.py # Conversations + messages tables with FKs and indexes
│ ├── 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/
│ └── fabledassistant/
│ ├── __init__.py
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API
│ ├── config.py # Config from env vars
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging middleware
│ ├── 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/
│ │ ├── __init__.py # async_session factory, Base, imports Note + TaskStatus + TaskPriority + Conversation + Message + Setting
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property
│ │ ├── conversation.py # Conversation + Message models with relationships and to_dict()
│ │ ── setting.py # Setting model (key TEXT PK, value TEXT)
│ │ ├── __init__.py # async_session factory, Base, imports all models
│ │ ├── user.py # User model (id, username, email, password_hash, role, created_at)
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, user_id, status, priority, due_date, timestamps)
│ │ ── conversation.py # Conversation + Message models with user_id
│ │ └── setting.py # Setting model (composite PK: user_id + key, value TEXT)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list/pull/delete, status check
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (imports directly from services.notes, accepts body not description)
│ │ ── settings.py # /api/settings GET/PUT — app-wide settings
│ │ ├── api.py # /api blueprint with /health endpoint (public)
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, status
│ │ ├── admin.py # /api/admin blueprint: backup (user/full), restore (admin only)
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
│ │ ── 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/
│ │ ├── 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)
│ │ ├── 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)
│ │ ├── chat.py # Conversation CRUD (list uses subquery count), add_message, save_response_as_note, summarize_conversation_as_note
│ │ ── settings.py # Settings CRUD: get_setting, set_setting, set_settings_batch, get_all_settings
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user, authenticate, get_user_by_id
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
│ │ ── 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/
│ │ ├── __init__.py
│ │ └── 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
│ ├── main.ts # App init, imports theme.css
│ ├── 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/
│ │ └── 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/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
│ ├── stores/
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming, handles context event, accepts excludeNoteIds), lastContextMeta ref, saveMessageAsNote, summarizeAsNote, fetchModels, status polling (ollamaStatus, modelStatus, chatReady)
│ │ ├── settings.ts # App settings: assistantName, defaultModel, fetchInstalledModels, pullModel (SSE streaming with onProgress callback), deleteModel
│ │ ── toast.ts # Toast notification state, 3s auto-dismiss
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, login/register/logout/checkAuth
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (with toast errors)
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), status polling (with toast errors)
│ │ ── settings.ts # App settings: assistantName, defaultModel, pullModel, deleteModel (with toast errors)
│ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id)
│ ├── types/
│ │ ├── auth.ts # User interface, AuthStatus interface
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, OllamaStatus interfaces
│ │ ├── 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()
│ │ └── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces &#39; after sanitization
│ ├── 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
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats (3 most recent) + new chat button
│ │ ├── SettingsView.vue # Settings page: assistant name config + model catalog (18 models in 3 categories) with download/select/remove
│ │ ├── LoginView.vue # Login form with error display, link to register
│ │ ├── RegisterView.vue # Register form (username, password, email), first-user setup
│ │ ├── 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
│ │ ├── 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
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle
│ │ ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
│ │ ├── TaskEditorView.vue # Create/edit task: Ctrl+S, dirty guard, autocomplete
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note, backlinks
│ ├── 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
│ │ ├── 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
@@ -251,9 +282,9 @@ fabledassistant/
│ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
│ │ ├── PaginationBar.vue # Prev/next + page numbers
│ │ └── ToastNotification.vue # Fixed-position toast container
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
│ └── 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/
```
@@ -261,7 +292,14 @@ fabledassistant/
| 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) |
| 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) |
@@ -284,8 +322,10 @@ fabledassistant/
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
| PATCH | `/api/chat/conversations/:id` | Update conversation title (body: `{title}`) |
| 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/messages/:id/save-as-note` | Save assistant message as a new note |
| POST | `/api/chat/conversations/:id/messages` | Start generation: save user message, launch background task, return 202 (body: `{content, context_note_id?, exclude_note_ids?}`) |
| GET | `/api/chat/conversations/:id/generation/stream` | SSE endpoint tailing generation buffer; supports `Last-Event-ID` reconnection; emits `context`, `chunk`, `done`, `error` events |
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation (sets cancel_event, saves partial content) |
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note (LLM-generated title, tagged `chat`) |
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note |
| GET | `/api/chat/status` | Check Ollama availability and model readiness (`{ollama, model, default_model}`) |
| GET | `/api/chat/models` | List available Ollama models |
@@ -303,7 +343,7 @@ container startup.
### 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
@@ -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] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD
- [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish
- [x] **Save as note:** Save any assistant message as a new note (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] **Model management:** `GET /api/chat/models` lists available Ollama models; auto-pull default model (`llama3.1`) on app startup
- [x] **Dedicated chat page:** `/chat` with sidebar (conversation list, create/delete) + message thread + markdown rendering + streaming indicator
- [x] **Slide-out chat panel:** Toggle from header, receives `contextNoteId` from current route (`/notes/:id` or `/tasks/:id`), auto-creates conversation on first message
- [x] **Frontend SSE client:** `apiStreamPost()` uses fetch + ReadableStream to parse SSE data lines
- [x] **Auto-title:** Conversation title 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 ✓
- [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] **Safe `Conversation.to_dict()`:** Handles case where messages relationship is not loaded
### Phase 5 — Polish & Production Hardening
- [ ] Authentication (single-user or multi-user, TBD)
- [ ] Docker Swarm production stack (secrets, volumes, networking)
- [ ] Backup/restore strategy for data
- [ ] UI/UX refinements, responsive design
- [ ] Error handling, logging, monitoring
### Phase 5 — Polish & Production Hardening
- [x] **Multi-user authentication:** Session cookie auth with bcrypt, first-user-is-admin pattern, orphaned data claiming
- [x] **Per-user data isolation:** All notes, conversations, settings filtered by `user_id`
- [x] **Auth decorators:** `@login_required`, `@admin_required` on all API routes (except health + auth status)
- [x] **Frontend auth flow:** Login/register views, router beforeEach guard, ApiError with auto-redirect on 401
- [x] **Error handling & logging:** Request logging with timing, sanitized 500 errors, LOG_LEVEL config, toast errors on all store actions
- [x] **Toast improvements:** Warning type, dismiss button, 4s auto-dismiss
- [x] **Responsive design:** Breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), 44px mobile touch targets
- [x] **Responsive header:** Hamburger menu at ≤768px, vertical nav dropdown, close on route change
- [x] **Responsive chat:** Sidebar overlay on mobile with toggle button, slides in/out
- [x] **Backup/restore:** Export user data (any user), full backup (admin), restore from JSON (admin)
- [x] **Docker Swarm production stack:** `docker-compose.prod.yml` with Docker secrets, internal network isolation, health checks, resource limits, restart policies
- [x] **Docker secrets support:** `_read_secret()` helper in config.py for SECRET_KEY_FILE, DATABASE_URL_FILE
### Phase 5.1 — Chat UX Improvements ✓
- [x] **LLM-generated titles:** Conversation titles generated by LLM (3-8 words) on first exchange and re-generated every 10th message to reflect evolved topics; falls back to truncation on failure
- [x] **Background generation architecture:** `GenerationBuffer` (in-memory event buffer with SSE fan-out, reconnect via `Last-Event-ID`, auto-cleanup after 60s) + `run_generation()` asyncio task (streams from Ollama, periodic DB flushes every 5s)
- [x] **Stop generation:** `cancel_event` on `GenerationBuffer`; `POST .../generation/cancel` endpoint; red stop button replaces send button during streaming; partial content saved as complete
- [x] **Date/time in sidebar:** Relative time for recent (`Just now`, `5m ago`, `3h ago` up to 10h), then date (`Jan 15` this year, `Jan 15, 2025` older)
- [x] **Empty chat cleanup:** Navigating away from an empty conversation (message_count === 0) auto-deletes it
- [x] **Save-as-note LLM titles:** `save_response_as_note` generates title via LLM (same pattern as chat titles); falls back to first-line extraction on failure
- [x] **Chat tag on saved notes:** Both `save_response_as_note` and `summarize_conversation_as_note` tag created notes with `chat`
### Future / Stretch
- Tagging/labeling system with LLM-suggested tags
@@ -516,27 +572,25 @@ When adding a new migration, follow these conventions:
Quart serves them
- To reset database: `docker compose down -v && docker compose up --build`
## Open Questions
- Authentication model: single-user (password-only) vs multi-user?
## 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
- Unified note/task model (a task is a note with `status IS NOT NULL`)
- LLM chat via Ollama with SSE streaming responses
- Note-aware context: auto-includes current note + searches related notes by keyword (5 keywords, 2000-char previews)
- Context visibility: auto-found notes shown as pills with title links, promote (+) and exclude (x) controls
- Note picker: attach any note as context from chat input via search dropdown
- Exclude tracking: session-scoped per conversation, excluded notes skipped in future auto-searches
- Multi-word search: splits query into per-term ILIKE with AND logic (words match independently)
- URL content fetching: detects URLs in messages, fetches and includes content
- Save assistant messages as notes; summarize entire conversations as notes
- Dedicated `/chat` page with bubble-style messages (user right, assistant left), floating dark input bar
- Slide-out chat panel accessible from any page, auto-includes note/task context
- Settings page: configurable assistant name (default "Fable"), model catalog with 18 models in 3 categories
- Model management: download (with live SSE progress percentage), select, and remove models from the settings page
- Ollama status indicator in global nav bar (green/yellow/red dot) with 30s polling
- Recent chats section on home page with quick "New Chat" button
- HTML entity rendering fix (apostrophes in marked → DOMPurify pipeline)
- Dark/light theme with CSS custom properties
- Ready for Phase 5: Polish & Production Hardening
- **Multi-user authentication** with session cookies, bcrypt passwords, admin role
- **Per-user data isolation** across notes, conversations, and settings
- **First-user-is-admin** pattern; orphaned pre-auth data claimed by first user
- LLM chat via Ollama with background generation task + SSE streaming
- **LLM-generated titles** on first exchange and every 10th message
- **Stop generation** button with partial content preservation
- **Relative timestamps** in sidebar (5m ago, 3h ago, then dates)
- **Empty chat cleanup** on navigation away
- Note-aware context: auto-includes current note + searches related notes by keyword
- Context pills with promote/exclude controls; note picker in chat input
- Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations as notes
- Dedicated `/chat` page with responsive sidebar (overlay on mobile)
- Settings page: assistant name, model catalog, **data export/restore (admin)**
- **Request logging** with timing, sanitized 500 errors, configurable LOG_LEVEL
- **Responsive design**: hamburger menu, mobile touch targets, responsive breakpoints
- **Toast notifications** with success/error/warning types and dismiss button
- **Docker Swarm production stack** with secrets, network isolation, health checks, resource limits
- Dark/light theme with CSS custom properties and design tokens