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