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:
+39
-10
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import AppHeader from "@/components/AppHeader.vue";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import ToastNotification from "@/components/ToastNotification.vue";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
|
||||
useTheme();
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const chatPanelOpen = ref(false);
|
||||
@@ -29,23 +31,50 @@ function toggleChatPanel() {
|
||||
chatPanelOpen.value = !chatPanelOpen.value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function startAppServices() {
|
||||
chatStore.startStatusPolling();
|
||||
settingsStore.fetchSettings();
|
||||
}
|
||||
|
||||
function stopAppServices() {
|
||||
chatStore.stopStatusPolling();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.checkAuth();
|
||||
if (authStore.isAuthenticated) {
|
||||
startAppServices();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
(authenticated) => {
|
||||
if (authenticated) {
|
||||
startAppServices();
|
||||
} else {
|
||||
stopAppServices();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
chatStore.stopStatusPolling();
|
||||
stopAppServices();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppHeader @toggle-chat-panel="toggleChatPanel" />
|
||||
<router-view />
|
||||
<ChatPanel
|
||||
v-if="chatPanelOpen"
|
||||
:context-note-id="contextNoteId"
|
||||
@close="chatPanelOpen = false"
|
||||
/>
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<AppHeader @toggle-chat-panel="toggleChatPanel" />
|
||||
<router-view />
|
||||
<ChatPanel
|
||||
v-if="chatPanelOpen"
|
||||
:context-note-id="contextNoteId"
|
||||
@close="chatPanelOpen = false"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-view />
|
||||
</template>
|
||||
<ToastNotification />
|
||||
</template>
|
||||
|
||||
+203
-34
@@ -1,21 +1,55 @@
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, body: Record<string, unknown>) {
|
||||
const msg = (body.error as string) || `API error: ${status}`;
|
||||
super(msg);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status}`);
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = { error: `API error: ${res.status}` };
|
||||
}
|
||||
|
||||
// Redirect to login on 401 (except for auth endpoints)
|
||||
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||
const { default: router } = await import("@/router/index");
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath !== "/login" && currentPath !== "/register") {
|
||||
router.push({ name: "login", query: { redirect: currentPath } });
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiError(res.status, body);
|
||||
}
|
||||
// Handle 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
||||
@@ -24,10 +58,7 @@ export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||
@@ -36,17 +67,132 @@ export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
export async function apiDelete(path: string): Promise<void> {
|
||||
const res = await fetch(path, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status}`);
|
||||
return handleResponse<void>(res, path);
|
||||
}
|
||||
|
||||
export interface SSEStreamHandle {
|
||||
close(): void;
|
||||
/** Resolves when the stream closes (normally or via error/abort). */
|
||||
done: Promise<void>;
|
||||
}
|
||||
|
||||
export interface SSENamedEvent {
|
||||
id: number;
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function apiSSEStream(
|
||||
path: string,
|
||||
onEvent: (event: SSENamedEvent) => void,
|
||||
options?: { lastEventId?: number; signal?: AbortSignal },
|
||||
): SSEStreamHandle {
|
||||
const controller = new AbortController();
|
||||
const combinedSignal = options?.signal
|
||||
? AbortSignal.any([controller.signal, options.signal])
|
||||
: controller.signal;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (options?.lastEventId !== undefined) {
|
||||
headers["Last-Event-ID"] = String(options.lastEventId);
|
||||
}
|
||||
|
||||
const done = (async () => {
|
||||
const res = await fetch(path, { headers, signal: combinedSignal });
|
||||
if (!res.ok) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = { error: `API error: ${res.status}` };
|
||||
}
|
||||
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||
const { default: router } = await import("@/router/index");
|
||||
router.push({ name: "login" });
|
||||
}
|
||||
throw new ApiError(res.status, body);
|
||||
}
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
// SSE field parsing state
|
||||
let currentId = -1;
|
||||
let currentEvent = "message";
|
||||
let currentData = "";
|
||||
|
||||
function dispatch() {
|
||||
if (!currentData) return;
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(currentData);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
onEvent({ id: currentId, event: currentEvent, data: parsed });
|
||||
// Reset for next event
|
||||
currentEvent = "message";
|
||||
currentData = "";
|
||||
}
|
||||
|
||||
function processLine(line: string) {
|
||||
if (line === "") {
|
||||
// Empty line = end of event
|
||||
dispatch();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) return; // comment / keepalive
|
||||
const colonIdx = line.indexOf(":");
|
||||
let field: string;
|
||||
let value: string;
|
||||
if (colonIdx === -1) {
|
||||
field = line;
|
||||
value = "";
|
||||
} else {
|
||||
field = line.slice(0, colonIdx);
|
||||
value = line.slice(colonIdx + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
}
|
||||
switch (field) {
|
||||
case "id":
|
||||
currentId = parseInt(value, 10);
|
||||
break;
|
||||
case "event":
|
||||
currentEvent = value;
|
||||
break;
|
||||
case "data":
|
||||
currentData += value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
processLine(line);
|
||||
}
|
||||
}
|
||||
// Process remaining buffer
|
||||
if (buffer) {
|
||||
processLine(buffer);
|
||||
}
|
||||
dispatch();
|
||||
})().catch(() => {
|
||||
// Stream closed or aborted — handled by caller via reconnection
|
||||
});
|
||||
|
||||
return { close: () => controller.abort(), done };
|
||||
}
|
||||
|
||||
export async function apiStreamPost(
|
||||
@@ -60,7 +206,19 @@ export async function apiStreamPost(
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status}`);
|
||||
let errBody: Record<string, unknown> = {};
|
||||
try {
|
||||
errBody = await res.json();
|
||||
} catch {
|
||||
errBody = { error: `API error: ${res.status}` };
|
||||
}
|
||||
|
||||
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
||||
const { default: router } = await import("@/router/index");
|
||||
router.push({ name: "login" });
|
||||
}
|
||||
|
||||
throw new ApiError(res.status, errBody);
|
||||
}
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
@@ -68,35 +226,46 @@ export async function apiStreamPost(
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
function processLines(text: string) {
|
||||
const lines = text.split("\n");
|
||||
// Keep the last (possibly incomplete) line in the buffer
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("data: ")) {
|
||||
let data;
|
||||
try {
|
||||
const data = JSON.parse(trimmed.slice(6));
|
||||
onChunk(data);
|
||||
data = JSON.parse(trimmed.slice(6));
|
||||
} catch {
|
||||
// Skip malformed JSON lines
|
||||
continue; // Skip malformed JSON lines
|
||||
}
|
||||
onChunk(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining buffer
|
||||
if (buffer.trim().startsWith("data: ")) {
|
||||
try {
|
||||
const data = JSON.parse(buffer.trim().slice(6));
|
||||
onChunk(data);
|
||||
} catch {
|
||||
// Skip malformed JSON
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
processLines(buffer);
|
||||
}
|
||||
} catch {
|
||||
// Stream may close with a network error after all data was sent.
|
||||
// Process whatever remains in the buffer before deciding to throw.
|
||||
}
|
||||
|
||||
// Process any remaining buffer
|
||||
const remaining = buffer.trim();
|
||||
if (remaining.startsWith("data: ")) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(remaining.slice(6));
|
||||
} catch {
|
||||
return; // Skip malformed JSON
|
||||
}
|
||||
onChunk(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,3 +110,21 @@ button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
|
||||
@media (max-width: 768px) {
|
||||
.hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
button,
|
||||
[role="button"],
|
||||
.btn-new-conv,
|
||||
.btn-send {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.hide-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const authStore = useAuthStore();
|
||||
const chatStore = useChatStore();
|
||||
const router = useRouter();
|
||||
const mobileMenuOpen = ref(false);
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleChatPanel: [];
|
||||
@@ -23,13 +29,38 @@ const statusClass = computed(() => {
|
||||
if (chatStore.chatReady) return "status-green";
|
||||
return "status-yellow";
|
||||
});
|
||||
|
||||
function toggleMobileMenu() {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout();
|
||||
mobileMenuOpen.value = false;
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
// Close mobile menu on route change
|
||||
router.afterEach(() => {
|
||||
mobileMenuOpen.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<nav class="nav">
|
||||
<router-link to="/" class="nav-brand">Fabled Assistant</router-link>
|
||||
<div class="nav-links">
|
||||
<router-link to="/" class="nav-brand">
|
||||
<AppLogo />
|
||||
Fabled Assistant
|
||||
</router-link>
|
||||
|
||||
<button class="hamburger" @click="toggleMobileMenu" aria-label="Toggle menu">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
|
||||
<div class="nav-links" :class="{ open: mobileMenuOpen }">
|
||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" class="nav-link">Chat</router-link>
|
||||
@@ -43,6 +74,13 @@ const statusClass = computed(() => {
|
||||
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
||||
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
||||
</button>
|
||||
<div class="user-info">
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
<span v-if="authStore.isAdmin" class="admin-badge">admin</span>
|
||||
<button class="btn-logout" @click="handleLogout" title="Sign out">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -60,11 +98,30 @@ const statusClass = computed(() => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.hamburger {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.hamburger-line {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--color-text);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -139,4 +196,84 @@ const statusClass = computed(() => {
|
||||
.theme-toggle:hover {
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.username {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.admin-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.btn-logout {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-logout:hover {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hamburger {
|
||||
display: flex;
|
||||
}
|
||||
.nav-links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-direction: column;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.25rem;
|
||||
z-index: 100;
|
||||
}
|
||||
.nav-links.open {
|
||||
display: flex;
|
||||
}
|
||||
.nav-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.status-indicator {
|
||||
margin-left: 0;
|
||||
}
|
||||
.user-info {
|
||||
margin-left: 0;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
width: 100%;
|
||||
}
|
||||
.btn-chat-panel,
|
||||
.theme-toggle,
|
||||
.btn-logout {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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--${toast.type}`"
|
||||
>
|
||||
{{ toast.message }}
|
||||
<span class="toast-msg">{{ toast.message }}</span>
|
||||
<button class="toast-close" @click="toastStore.dismiss(toast.id)">×</button>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
@@ -30,19 +31,47 @@ const toastStore = useToastStore();
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.toast {
|
||||
padding: 0.75rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
min-width: 200px;
|
||||
}
|
||||
.toast-msg {
|
||||
flex: 1;
|
||||
}
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.toast-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.toast--success {
|
||||
background: var(--color-toast-success);
|
||||
}
|
||||
.toast--error {
|
||||
background: var(--color-toast-error);
|
||||
}
|
||||
.toast--warning {
|
||||
background: var(--color-warning);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.toast--warning .toast-close {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.toast--warning .toast-close:hover {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HomeView from "@/views/HomeView.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -9,6 +10,18 @@ const router = createRouter({
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("@/views/LoginView.vue"),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
name: "register",
|
||||
component: () => import("@/views/RegisterView.vue"),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: "/notes",
|
||||
name: "notes",
|
||||
@@ -67,4 +80,19 @@ const router = createRouter({
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.meta.public) return;
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Wait for initial auth check if still loading
|
||||
if (authStore.loading && !authStore.isAuthenticated) {
|
||||
await authStore.checkAuth();
|
||||
}
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { name: "login", query: { redirect: to.fullPath } };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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,
|
||||
apiPatch,
|
||||
apiDelete,
|
||||
apiStreamPost,
|
||||
apiSSEStream,
|
||||
} from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationDetail,
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
Message,
|
||||
OllamaModel,
|
||||
OllamaStatus,
|
||||
SendMessageResponse,
|
||||
} from "@/types/chat";
|
||||
|
||||
export const useChatStore = defineStore("chat", () => {
|
||||
@@ -42,6 +44,9 @@ export const useChatStore = defineStore("chat", () => {
|
||||
);
|
||||
conversations.value = data.conversations;
|
||||
total.value = data.total;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load conversations", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -51,12 +56,17 @@ export const useChatStore = defineStore("chat", () => {
|
||||
title = "",
|
||||
model = ""
|
||||
): Promise<Conversation> {
|
||||
const conv = await apiPost<Conversation>("/api/chat/conversations", {
|
||||
title,
|
||||
model,
|
||||
});
|
||||
conversations.value.unshift(conv);
|
||||
return conv;
|
||||
try {
|
||||
const conv = await apiPost<Conversation>("/api/chat/conversations", {
|
||||
title,
|
||||
model,
|
||||
});
|
||||
conversations.value.unshift(conv);
|
||||
return conv;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to create conversation", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConversation(id: number) {
|
||||
@@ -65,30 +75,43 @@ export const useChatStore = defineStore("chat", () => {
|
||||
currentConversation.value = await apiGet<ConversationDetail>(
|
||||
`/api/chat/conversations/${id}`
|
||||
);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load conversation", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConversation(id: number) {
|
||||
await apiDelete(`/api/chat/conversations/${id}`);
|
||||
conversations.value = conversations.value.filter((c) => c.id !== id);
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value = null;
|
||||
try {
|
||||
await apiDelete(`/api/chat/conversations/${id}`);
|
||||
conversations.value = conversations.value.filter((c) => c.id !== id);
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete conversation", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTitle(id: number, title: string) {
|
||||
const updated = await apiPatch<Conversation>(
|
||||
`/api/chat/conversations/${id}`,
|
||||
{ title }
|
||||
);
|
||||
const idx = conversations.value.findIndex((c) => c.id === id);
|
||||
if (idx !== -1) {
|
||||
conversations.value[idx] = { ...conversations.value[idx], ...updated };
|
||||
}
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value.title = updated.title;
|
||||
try {
|
||||
const updated = await apiPatch<Conversation>(
|
||||
`/api/chat/conversations/${id}`,
|
||||
{ title }
|
||||
);
|
||||
const idx = conversations.value.findIndex((c) => c.id === id);
|
||||
if (idx !== -1) {
|
||||
conversations.value[idx] = { ...conversations.value[idx], ...updated };
|
||||
}
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value.title = updated.title;
|
||||
}
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to update title", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,53 +139,124 @@ export const useChatStore = defineStore("chat", () => {
|
||||
streaming.value = true;
|
||||
streamingContent.value = "";
|
||||
|
||||
// Phase 1: POST to start generation
|
||||
let assistantMessageId: number;
|
||||
try {
|
||||
await apiStreamPost(
|
||||
const resp = await apiPost<SendMessageResponse>(
|
||||
`/api/chat/conversations/${convId}/messages`,
|
||||
{
|
||||
content,
|
||||
context_note_id: contextNoteId,
|
||||
exclude_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
|
||||
},
|
||||
(data) => {
|
||||
if (data.context) {
|
||||
lastContextMeta.value = data.context as ContextMeta;
|
||||
}
|
||||
if (data.chunk) {
|
||||
streamingContent.value += data.chunk as string;
|
||||
}
|
||||
if (data.done) {
|
||||
// Add the final assistant message
|
||||
const assistantMsg: Message = {
|
||||
id: data.message_id as number,
|
||||
conversation_id: convId,
|
||||
role: "assistant",
|
||||
content: streamingContent.value,
|
||||
context_note_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
if (currentConversation.value?.id === convId) {
|
||||
currentConversation.value.messages.push(assistantMsg);
|
||||
}
|
||||
streamingContent.value = "";
|
||||
streaming.value = false;
|
||||
|
||||
// Update conversation in list
|
||||
const idx = conversations.value.findIndex((c) => c.id === convId);
|
||||
if (idx !== -1) {
|
||||
conversations.value[idx].message_count += 2;
|
||||
conversations.value[idx].updated_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
if (data.error) {
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
assistantMessageId = resp.assistant_message_id;
|
||||
} catch (e) {
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
useToastStore().show("Failed to send message", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Connect to SSE stream with reconnection
|
||||
await _streamGeneration(convId, assistantMessageId);
|
||||
}
|
||||
|
||||
async function _streamGeneration(
|
||||
convId: number,
|
||||
assistantMessageId: number,
|
||||
initialLastEventId = -1,
|
||||
attempt = 0,
|
||||
) {
|
||||
const MAX_RETRIES = 5;
|
||||
let lastEventId = initialLastEventId;
|
||||
let gotDone = false;
|
||||
|
||||
const handle = apiSSEStream(
|
||||
`/api/chat/conversations/${convId}/generation/stream` +
|
||||
(lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""),
|
||||
(event) => {
|
||||
lastEventId = event.id;
|
||||
|
||||
switch (event.event) {
|
||||
case "context":
|
||||
lastContextMeta.value = event.data.context as ContextMeta;
|
||||
break;
|
||||
case "chunk":
|
||||
streamingContent.value += event.data.chunk as string;
|
||||
break;
|
||||
case "done":
|
||||
gotDone = true;
|
||||
{
|
||||
const assistantMsg: Message = {
|
||||
id: event.data.message_id as number,
|
||||
conversation_id: convId,
|
||||
role: "assistant",
|
||||
content: streamingContent.value,
|
||||
context_note_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
if (currentConversation.value?.id === convId) {
|
||||
currentConversation.value.messages.push(assistantMsg);
|
||||
}
|
||||
streamingContent.value = "";
|
||||
streaming.value = false;
|
||||
|
||||
// Update conversation in list
|
||||
const idx = conversations.value.findIndex((c) => c.id === convId);
|
||||
if (idx !== -1) {
|
||||
conversations.value[idx].message_count += 2;
|
||||
conversations.value[idx].updated_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
gotDone = true;
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
useToastStore().show(
|
||||
"Chat error: " + (event.data.error as string),
|
||||
"error",
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for the stream to close (events are processed via callback above)
|
||||
await handle.done;
|
||||
|
||||
// If stream ended without done/error, attempt reconnection
|
||||
if (!gotDone && attempt < MAX_RETRIES) {
|
||||
const delay = Math.min(1000 * 2 ** attempt, 10_000);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
if (currentConversation.value?.id === convId) {
|
||||
return _streamGeneration(convId, assistantMessageId, lastEventId, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery fallback: re-fetch conversation from DB
|
||||
if (!gotDone && currentConversation.value?.id === convId) {
|
||||
try {
|
||||
await fetchConversation(convId);
|
||||
} catch {
|
||||
// Re-fetch failed — partial content visible on next page load
|
||||
}
|
||||
}
|
||||
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
}
|
||||
|
||||
async function cancelGeneration() {
|
||||
if (!currentConversation.value) return;
|
||||
try {
|
||||
await apiPost(
|
||||
`/api/chat/conversations/${currentConversation.value.id}/generation/cancel`,
|
||||
{}
|
||||
);
|
||||
} catch {
|
||||
// Generation may have already finished — ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +327,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
deleteConversation,
|
||||
updateTitle,
|
||||
sendMessage,
|
||||
cancelGeneration,
|
||||
saveMessageAsNote,
|
||||
summarizeAsNote,
|
||||
fetchModels,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { Note, NoteListResponse } from "@/types/note";
|
||||
|
||||
export const useNotesStore = defineStore("notes", () => {
|
||||
@@ -36,6 +37,9 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
);
|
||||
notes.value = data.notes;
|
||||
total.value = data.total;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load notes", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -62,6 +66,9 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
currentNote.value = await apiGet<Note>(`/api/notes/${id}`);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load note", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -71,26 +78,40 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
title: string;
|
||||
body: string;
|
||||
}): Promise<Note> {
|
||||
const note = await apiPost<Note>("/api/notes", data);
|
||||
return note;
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to create note", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body">>
|
||||
): Promise<Note> {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = note;
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = note;
|
||||
}
|
||||
return note;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to update note", "error");
|
||||
throw e;
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
async function deleteNote(id: number) {
|
||||
await apiDelete(`/api/notes/${id}`);
|
||||
notes.value = notes.value.filter((n) => n.id !== id);
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = null;
|
||||
try {
|
||||
await apiDelete(`/api/notes/${id}`);
|
||||
notes.value = notes.value.filter((n) => n.id !== id);
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete note", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,26 +164,35 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
}
|
||||
|
||||
async function convertToTask(noteId: number): Promise<Note> {
|
||||
const note = await apiPost<Note>(
|
||||
`/api/notes/${noteId}/convert-to-task`,
|
||||
{}
|
||||
);
|
||||
// Update local state — the note is now a task
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = note;
|
||||
try {
|
||||
const note = await apiPost<Note>(
|
||||
`/api/notes/${noteId}/convert-to-task`,
|
||||
{}
|
||||
);
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = note;
|
||||
}
|
||||
return note;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to convert to task", "error");
|
||||
throw e;
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
async function convertToNote(noteId: number): Promise<Note> {
|
||||
const note = await apiPost<Note>(
|
||||
`/api/notes/${noteId}/convert-to-note`,
|
||||
{}
|
||||
);
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = note;
|
||||
try {
|
||||
const note = await apiPost<Note>(
|
||||
`/api/notes/${noteId}/convert-to-note`,
|
||||
{}
|
||||
);
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = note;
|
||||
}
|
||||
return note;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to convert to note", "error");
|
||||
throw e;
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
async function fetchBacklinks(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPut, apiPost, apiStreamPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { AppSettings } from "@/types/settings";
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
@@ -31,6 +32,9 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
settings.value = await apiPut<AppSettings>("/api/settings", updates);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to save settings", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -55,8 +59,13 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
}
|
||||
|
||||
async function deleteModel(model: string) {
|
||||
await apiPost("/api/chat/models/delete", { model });
|
||||
await fetchInstalledModels();
|
||||
try {
|
||||
await apiPost("/api/chat/models/delete", { model });
|
||||
await fetchInstalledModels();
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete model", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task";
|
||||
|
||||
export const useTasksStore = defineStore("tasks", () => {
|
||||
@@ -41,6 +42,9 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
);
|
||||
tasks.value = data.tasks;
|
||||
total.value = data.total;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load tasks", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -50,6 +54,9 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
currentTask.value = await apiGet<Task>(`/api/tasks/${id}`);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load task", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -62,7 +69,12 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
priority?: TaskPriority;
|
||||
due_date?: string | null;
|
||||
}): Promise<Task> {
|
||||
return await apiPost<Task>("/api/tasks", data);
|
||||
try {
|
||||
return await apiPost<Task>("/api/tasks", data);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to create task", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTask(
|
||||
@@ -71,31 +83,45 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
Pick<Task, "title" | "body" | "status" | "priority" | "due_date">
|
||||
>
|
||||
): Promise<Task> {
|
||||
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = task;
|
||||
try {
|
||||
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = task;
|
||||
}
|
||||
return task;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to update task", "error");
|
||||
throw e;
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
|
||||
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
|
||||
// Update in list if present
|
||||
const idx = tasks.value.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) {
|
||||
tasks.value[idx] = task;
|
||||
try {
|
||||
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
|
||||
const idx = tasks.value.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) {
|
||||
tasks.value[idx] = task;
|
||||
}
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = task;
|
||||
}
|
||||
return task;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to update task status", "error");
|
||||
throw e;
|
||||
}
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = task;
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async function deleteTask(id: number) {
|
||||
await apiDelete(`/api/tasks/${id}`);
|
||||
tasks.value = tasks.value.filter((t) => t.id !== id);
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = null;
|
||||
try {
|
||||
await apiDelete(`/api/tasks/${id}`);
|
||||
tasks.value = tasks.value.filter((t) => t.id !== id);
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete task", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineStore } from "pinia";
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
type: "success" | "error" | "warning";
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
@@ -12,13 +12,17 @@ let nextId = 0;
|
||||
export const useToastStore = defineStore("toast", () => {
|
||||
const toasts = ref<Toast[]>([]);
|
||||
|
||||
function show(message: string, type: "success" | "error" = "success") {
|
||||
function show(message: string, type: "success" | "error" | "warning" = "success") {
|
||||
const id = nextId++;
|
||||
toasts.value.push({ id, message, type });
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}, 3000);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
return { toasts, show };
|
||||
function dismiss(id: number) {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
return { toasts, show, dismiss };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
status?: "complete" | "generating" | "error";
|
||||
context_note_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SendMessageResponse {
|
||||
assistant_message_id: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
+161
-10
@@ -18,6 +18,7 @@ const messagesEl = ref<HTMLElement | null>(null);
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||
const sending = ref(false);
|
||||
const summarizing = ref(false);
|
||||
const sidebarOpen = ref(false);
|
||||
|
||||
// Note picker state
|
||||
const attachedNote = ref<{ id: number; title: string } | null>(null);
|
||||
@@ -30,11 +31,32 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Exclude tracking (session-scoped per conversation)
|
||||
const excludedNoteIds = ref<Set<number>>(new Set());
|
||||
|
||||
let prevConvId: number | null = null;
|
||||
|
||||
const convId = computed(() => {
|
||||
const id = route.params.id;
|
||||
return id ? Number(id) : null;
|
||||
});
|
||||
|
||||
function formatConvDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffHrs = Math.floor(diffMs / 3_600_000);
|
||||
|
||||
// Relative time for < 10 hours
|
||||
if (diffMin < 1) return "Just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHrs < 10) return `${diffHrs}h ago`;
|
||||
|
||||
// Date for older
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
const streamingRendered = computed(() => {
|
||||
if (!store.streamingContent) return "";
|
||||
return renderMarkdown(store.streamingContent);
|
||||
@@ -54,11 +76,24 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
watch(convId, async (newId) => {
|
||||
// Clean up empty previous conversation
|
||||
if (prevConvId && prevConvId !== newId) {
|
||||
const prev = store.conversations.find((c) => c.id === prevConvId);
|
||||
if (prev && prev.message_count === 0) {
|
||||
store.deleteConversation(prevConvId);
|
||||
}
|
||||
}
|
||||
prevConvId = newId ?? null;
|
||||
|
||||
excludedNoteIds.value = new Set();
|
||||
attachedNote.value = null;
|
||||
store.lastContextMeta = null;
|
||||
if (newId) {
|
||||
await store.fetchConversation(newId);
|
||||
// Skip re-fetch if this conversation is already loaded (avoids race
|
||||
// condition where a stale GET overwrites messages during streaming)
|
||||
if (store.currentConversation?.id !== newId) {
|
||||
await store.fetchConversation(newId);
|
||||
}
|
||||
scrollToBottom();
|
||||
} else {
|
||||
store.currentConversation = null;
|
||||
@@ -79,7 +114,12 @@ function scrollToBottom() {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value;
|
||||
}
|
||||
|
||||
async function selectConversation(id: number) {
|
||||
sidebarOpen.value = false;
|
||||
router.push(`/chat/${id}`);
|
||||
}
|
||||
|
||||
@@ -228,7 +268,12 @@ function excludeAutoNote(noteId: number) {
|
||||
|
||||
<template>
|
||||
<main class="chat-page">
|
||||
<aside class="chat-sidebar">
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="sidebar-overlay"
|
||||
@click="sidebarOpen = false"
|
||||
></div>
|
||||
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
|
||||
<button class="btn-new-conv" @click="newConversation">
|
||||
+ New Chat
|
||||
</button>
|
||||
@@ -240,7 +285,10 @@ function excludeAutoNote(noteId: number) {
|
||||
:class="{ active: convId === conv.id }"
|
||||
@click="selectConversation(conv.id)"
|
||||
>
|
||||
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
|
||||
<div class="conv-info">
|
||||
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
|
||||
<span class="conv-date">{{ formatConvDate(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn-delete-conv"
|
||||
@click.stop="removeConversation(conv.id)"
|
||||
@@ -258,6 +306,13 @@ function excludeAutoNote(noteId: number) {
|
||||
<section class="chat-main">
|
||||
<template v-if="store.currentConversation">
|
||||
<div class="chat-header">
|
||||
<button class="btn-sidebar-toggle hide-desktop" @click="toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
|
||||
<button
|
||||
v-if="store.currentConversation.messages.length"
|
||||
@@ -383,9 +438,20 @@ function excludeAutoNote(noteId: number) {
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button
|
||||
v-if="store.streaming"
|
||||
class="btn-stop"
|
||||
@click="store.cancelGeneration()"
|
||||
title="Stop generation"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<rect width="14" height="14" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-send"
|
||||
@click="sendMessage"
|
||||
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
|
||||
:disabled="!messageInput.trim() || !store.chatReady"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
@@ -394,6 +460,13 @@ function excludeAutoNote(noteId: number) {
|
||||
</template>
|
||||
|
||||
<div v-else class="no-conversation">
|
||||
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<p>Select a conversation or start a new chat.</p>
|
||||
<button class="btn-new-conv" @click="newConversation">
|
||||
+ New Chat
|
||||
@@ -455,13 +528,26 @@ function excludeAutoNote(noteId: number) {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.conv-title {
|
||||
.conv-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.conv-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.conv-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.conv-item.active .conv-date {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.btn-delete-conv {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -526,6 +612,9 @@ function excludeAutoNote(noteId: number) {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 848px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.messages-inner {
|
||||
margin-top: auto;
|
||||
@@ -643,7 +732,11 @@ function excludeAutoNote(noteId: number) {
|
||||
|
||||
/* Input wrapper */
|
||||
.input-wrapper {
|
||||
margin: 0 1rem 2rem;
|
||||
max-width: 848px;
|
||||
margin: 0 auto 2rem;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Attached note */
|
||||
@@ -789,6 +882,23 @@ function excludeAutoNote(noteId: number) {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-stop {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-danger, #e74c3c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-stop:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.no-conversation {
|
||||
flex: 1;
|
||||
@@ -807,16 +917,57 @@ function excludeAutoNote(noteId: number) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.btn-sidebar-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.no-conv-toggle {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
.no-conversation {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-sidebar {
|
||||
width: 200px;
|
||||
min-width: 160px;
|
||||
position: fixed;
|
||||
top: 49px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
width: 260px;
|
||||
}
|
||||
.chat-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: 49px;
|
||||
background: var(--color-overlay);
|
||||
z-index: 99;
|
||||
}
|
||||
.messages-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.input-wrapper {
|
||||
margin: 0 0.5rem 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { ModelInfo } from "@/types/settings";
|
||||
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
const assistantName = ref("");
|
||||
const saving = ref(false);
|
||||
const saved = ref(false);
|
||||
const pullProgress = ref<{ model: string; percent: number } | null>(null);
|
||||
const deleting = ref<string | null>(null);
|
||||
const confirmDelete = ref<string | null>(null);
|
||||
const exporting = ref(false);
|
||||
const restoring = ref(false);
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const MODEL_CATALOG: ModelInfo[] = [
|
||||
// — General Purpose —
|
||||
@@ -244,6 +251,63 @@ async function removeModel(name: string) {
|
||||
function cancelDelete() {
|
||||
confirmDelete.value = null;
|
||||
}
|
||||
|
||||
async function exportData(scope: "user" | "full") {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||||
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
toastStore.show("Backup downloaded");
|
||||
} catch (e) {
|
||||
toastStore.show("Export failed: " + (e as Error).message, "error");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerRestoreUpload() {
|
||||
restoreFileInput.value?.click();
|
||||
}
|
||||
|
||||
async function handleRestoreFile(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
restoring.value = true;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const res = await fetch("/api/admin/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||||
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
|
||||
}
|
||||
const result = await res.json();
|
||||
toastStore.show(
|
||||
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
|
||||
);
|
||||
} catch (e) {
|
||||
toastStore.show("Restore failed: " + (e as Error).message, "error");
|
||||
} finally {
|
||||
restoring.value = false;
|
||||
// Reset file input
|
||||
if (restoreFileInput.value) restoreFileInput.value.value = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -345,6 +409,45 @@ function cancelDelete() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Data</h2>
|
||||
<p class="section-desc">Export your data or restore from a backup.</p>
|
||||
|
||||
<div class="data-actions">
|
||||
<button
|
||||
class="btn-export"
|
||||
@click="exportData('user')"
|
||||
:disabled="exporting"
|
||||
>
|
||||
{{ exporting ? "Exporting..." : "Export My Data" }}
|
||||
</button>
|
||||
|
||||
<template v-if="authStore.isAdmin">
|
||||
<button
|
||||
class="btn-export"
|
||||
@click="exportData('full')"
|
||||
:disabled="exporting"
|
||||
>
|
||||
{{ exporting ? "Exporting..." : "Export Full Backup" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-restore"
|
||||
@click="triggerRestoreUpload"
|
||||
:disabled="restoring"
|
||||
>
|
||||
{{ restoring ? "Restoring..." : "Restore from Backup" }}
|
||||
</button>
|
||||
<input
|
||||
ref="restoreFileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden-file-input"
|
||||
@change="handleRestoreFile"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -598,4 +701,48 @@ function cancelDelete() {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Data section */
|
||||
.data-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-export {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-export:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-export:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-restore {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-restore:hover:not(:disabled) {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.btn-restore:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user