Next.js app — chat interface, auth, settings, PWA manifest
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const API_BASE = process.env.API_URL || 'https://ai.aaronnelson.studio';
|
||||||
|
|
||||||
|
async function handler(request: NextRequest, { params }: { params: Promise<{ slug: string[] }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const path = '/' + slug.join('/');
|
||||||
|
const url = `${API_BASE}${path}${request.nextUrl.search}`;
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
const contentType = request.headers.get('content-type');
|
||||||
|
if (contentType) headers.set('content-type', contentType);
|
||||||
|
const cookie = request.headers.get('cookie');
|
||||||
|
if (cookie) headers.set('cookie', cookie);
|
||||||
|
|
||||||
|
const body = request.method !== 'GET' && request.method !== 'HEAD'
|
||||||
|
? await request.blob()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
const setCookie = response.headers.get('set-cookie');
|
||||||
|
if (setCookie) responseHeaders.set('set-cookie', setCookie);
|
||||||
|
responseHeaders.set('content-type', response.headers.get('content-type') || 'application/json');
|
||||||
|
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = handler;
|
||||||
|
export const POST = handler;
|
||||||
|
export const PUT = handler;
|
||||||
|
export const PATCH = handler;
|
||||||
|
export const DELETE = handler;
|
||||||
|
export const OPTIONS = handler;
|
||||||
+77
-16
@@ -1,26 +1,87 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--bg: #faf9f6;
|
||||||
--foreground: #171717;
|
--bg2: #f0ede6;
|
||||||
|
--bg3: #e8e4dc;
|
||||||
|
--sidebar-bg: #eceae4;
|
||||||
|
--border: #dddad2;
|
||||||
|
--border2: #ccc9c0;
|
||||||
|
--text: #1a1a18;
|
||||||
|
--text2: #555550;
|
||||||
|
--text3: #999990;
|
||||||
|
--accent: #2d5a3d;
|
||||||
|
--accent-light: #edf5f0;
|
||||||
|
--accent-border: #c8dece;
|
||||||
|
--accent-text: #1a3a26;
|
||||||
|
--user-bg: #e8e4dc;
|
||||||
|
--font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
[data-theme="dark"] {
|
||||||
--color-background: var(--background);
|
--bg: #1a1a18;
|
||||||
--color-foreground: var(--foreground);
|
--bg2: #222220;
|
||||||
--font-sans: var(--font-geist-sans);
|
--bg3: #2a2a28;
|
||||||
--font-mono: var(--font-geist-mono);
|
--sidebar-bg: #111110;
|
||||||
|
--border: #2a2a28;
|
||||||
|
--border2: #383836;
|
||||||
|
--text: #e8e8e0;
|
||||||
|
--text2: #aaa89e;
|
||||||
|
--text3: #555550;
|
||||||
|
--accent-light: #1e2e22;
|
||||||
|
--accent-border: #2a3e2e;
|
||||||
|
--accent-text: #a8d5b5;
|
||||||
|
--user-bg: #2a2a28;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
[data-font="small"] { --font-size: 13px; }
|
||||||
:root {
|
[data-font="medium"] { --font-size: 15px; }
|
||||||
--background: #0a0a0a;
|
[data-font="large"] { --font-size: 17px; }
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--bg);
|
||||||
color: var(--foreground);
|
color: var(--text);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-size: var(--font-size);
|
||||||
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown styles */
|
||||||
|
.prose p { margin-bottom: 0.75em; }
|
||||||
|
.prose p:last-child { margin-bottom: 0; }
|
||||||
|
.prose strong { font-weight: 500; color: var(--text); }
|
||||||
|
.prose em { font-style: italic; }
|
||||||
|
.prose code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.88em;
|
||||||
|
background: var(--bg3);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.prose pre {
|
||||||
|
background: var(--bg3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
}
|
||||||
|
.prose pre code { background: none; padding: 0; }
|
||||||
|
.prose h1, .prose h2, .prose h3 {
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0.75em 0 0.4em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.prose ul, .prose ol { padding-left: 1.4em; margin: 0.5em 0; }
|
||||||
|
.prose li { margin-bottom: 0.3em; }
|
||||||
|
.prose hr { border: none; border-top: 1px solid var(--border); margin: 1em 0; }
|
||||||
|
.prose blockquote {
|
||||||
|
border-left: 3px solid var(--accent-border);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--text2);
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.prose table { border-collapse: collapse; width: 100%; margin: 0.75em 0; font-size: 0.9em; }
|
||||||
|
.prose th, .prose td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
|
||||||
|
.prose th { background: var(--bg3); font-weight: 500; }
|
||||||
|
|||||||
+33
-18
@@ -1,33 +1,48 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { IBM_Plex_Sans, IBM_Plex_Mono } from 'next/font/google';
|
||||||
import "./globals.css";
|
import './globals.css';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const sans = IBM_Plex_Sans({
|
||||||
variable: "--font-geist-sans",
|
subsets: ['latin'],
|
||||||
subsets: ["latin"],
|
weight: ['400', '500'],
|
||||||
|
variable: '--font-sans',
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const mono = IBM_Plex_Mono({
|
||||||
variable: "--font-geist-mono",
|
subsets: ['latin'],
|
||||||
subsets: ["latin"],
|
weight: ['400'],
|
||||||
|
variable: '--font-mono',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'Aaron AI',
|
||||||
description: "Generated by create next app",
|
description: 'Personal knowledge assistant',
|
||||||
|
manifest: '/manifest.json',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
title: 'Aaron AI',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
themeColor: '#2d5a3d',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="en" suppressHydrationWarning>
|
||||||
lang="en"
|
<body className={`${sans.variable} ${mono.variable}`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
{children}
|
||||||
>
|
</body>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!password || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Invalid password');
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('Invalid password');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') handleLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100dvh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '16px',
|
||||||
|
background: '#faf9f6',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '360px',
|
||||||
|
background: '#f0ede6',
|
||||||
|
border: '1px solid #dddad2',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||||
|
<div style={{ fontSize: '22px', fontWeight: 500, color: '#1a1a18', marginBottom: '4px' }}>
|
||||||
|
Aaron AI
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', color: '#999990' }}>
|
||||||
|
personal knowledge assistant
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Password"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: '#faf9f6',
|
||||||
|
border: `1px solid ${error ? '#a32d2d' : '#ccc9c0'}`,
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#1a1a18',
|
||||||
|
outline: 'none',
|
||||||
|
marginBottom: error ? '6px' : '16px',
|
||||||
|
display: 'block',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#a32d2d', marginBottom: '16px' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: '#2d5a3d',
|
||||||
|
color: '#e8f5ed',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '12px',
|
||||||
|
fontSize: '15px',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: loading ? 0.5 : 1,
|
||||||
|
display: 'block',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+101
-53
@@ -1,65 +1,113 @@
|
|||||||
import Image from "next/image";
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import MessageList from '@/components/MessageList';
|
||||||
|
import MessageInput from '@/components/MessageInput';
|
||||||
|
import SettingsPanel from '@/components/SettingsPanel';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
setSettings,
|
||||||
|
setConversations,
|
||||||
|
setCurrentId,
|
||||||
|
setMessages,
|
||||||
|
currentId,
|
||||||
|
settingsOpen,
|
||||||
|
sidebarOpen,
|
||||||
|
setSidebarOpen,
|
||||||
|
} = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load settings from server
|
||||||
|
api.getSettings().then(setSettings).catch(console.error);
|
||||||
|
// Load conversations
|
||||||
|
api.getConversations().then(setConversations).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load messages when conversation changes
|
||||||
|
if (currentId) {
|
||||||
|
api.getMessages(currentId).then(setMessages).catch(console.error);
|
||||||
|
} else {
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
}, [currentId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
className="flex h-dvh overflow-hidden"
|
||||||
<Image
|
style={{ background: 'var(--bg)' }}
|
||||||
className="dark:invert"
|
data-theme={settings.theme}
|
||||||
src="/next.svg"
|
data-font={settings.font_size}
|
||||||
alt="Next.js logo"
|
>
|
||||||
width={100}
|
{/* Mobile sidebar overlay */}
|
||||||
height={20}
|
{sidebarOpen && (
|
||||||
priority
|
<div
|
||||||
|
className="fixed inset-0 z-20 bg-black/40 md:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
)}
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
{/* Sidebar */}
|
||||||
</h1>
|
<div
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
className={`
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
fixed inset-y-0 left-0 z-30 w-72 transform transition-transform duration-200
|
||||||
<a
|
md:relative md:translate-x-0 md:w-64 md:flex-shrink-0
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
`}
|
||||||
|
style={{ background: 'var(--sidebar-bg)', borderRight: '1px solid var(--border)' }}
|
||||||
>
|
>
|
||||||
Templates
|
<Sidebar />
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
{/* Main */}
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
{/* Topbar */}
|
||||||
target="_blank"
|
<div
|
||||||
rel="noopener noreferrer"
|
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
|
||||||
|
style={{ borderBottom: '1px solid var(--border)' }}
|
||||||
>
|
>
|
||||||
<Image
|
<div className="flex items-center gap-3">
|
||||||
className="dark:invert"
|
{/* Hamburger — mobile only */}
|
||||||
src="/vercel.svg"
|
<button
|
||||||
alt="Vercel logomark"
|
className="md:hidden p-1 rounded"
|
||||||
width={16}
|
style={{ color: 'var(--text3)' }}
|
||||||
height={16}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
/>
|
aria-label="Toggle sidebar"
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
Documentation
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||||
</a>
|
<rect y="3" width="20" height="2" rx="1"/>
|
||||||
|
<rect y="9" width="20" height="2" rx="1"/>
|
||||||
|
<rect y="15" width="20" height="2" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: '13px', color: 'var(--text3)' }}>
|
||||||
|
<strong style={{ color: 'var(--text)', fontWeight: 500 }}>Aaron AI</strong>
|
||||||
|
{' '}— personal knowledge assistant
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<button
|
||||||
|
onClick={() => useStore.getState().setSettingsOpen(!settingsOpen)}
|
||||||
|
style={{ color: 'var(--text3)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: '6px', fontSize: '18px' }}
|
||||||
|
aria-label="Settings"
|
||||||
|
>
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<MessageList />
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<MessageInput />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings panel */}
|
||||||
|
<SettingsPanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Message } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function MessageInput() {
|
||||||
|
const { currentId, setCurrentId, addMessage, setIsLoading, isLoading, setConversations } = useStore();
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, [currentId]);
|
||||||
|
|
||||||
|
function autoResize() {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const message = text.trim();
|
||||||
|
if (!message || isLoading) return;
|
||||||
|
setText('');
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||||
|
|
||||||
|
let convId = currentId;
|
||||||
|
if (!convId) {
|
||||||
|
const conv = await api.newConversation();
|
||||||
|
convId = conv.id;
|
||||||
|
setCurrentId(convId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsg: Message = {
|
||||||
|
role: 'user',
|
||||||
|
content: message,
|
||||||
|
sources: [],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
addMessage(userMsg);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.sendMessage(message, convId);
|
||||||
|
setCurrentId(data.conversation_id);
|
||||||
|
const assistantMsg: Message = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: data.response,
|
||||||
|
sources: data.sources || [],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
addMessage(assistantMsg);
|
||||||
|
const updated = await api.getConversations();
|
||||||
|
setConversations(updated);
|
||||||
|
} catch (e) {
|
||||||
|
addMessage({
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Error — please try again.',
|
||||||
|
sources: [],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const mr = new MediaRecorder(stream);
|
||||||
|
chunksRef.current = [];
|
||||||
|
mr.ondataavailable = e => chunksRef.current.push(e.data);
|
||||||
|
mr.onstop = async () => {
|
||||||
|
stream.getTracks().forEach(t => t.stop());
|
||||||
|
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||||
|
try {
|
||||||
|
const { text: transcript } = await api.transcribe(blob);
|
||||||
|
setText(prev => prev ? prev + ' ' + transcript : transcript);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
} catch {
|
||||||
|
console.error('Transcription failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mr.start();
|
||||||
|
mediaRecorderRef.current = mr;
|
||||||
|
setRecording(true);
|
||||||
|
} catch {
|
||||||
|
alert('Microphone access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
mediaRecorderRef.current?.stop();
|
||||||
|
setRecording(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex gap-2 items-end px-4 py-3 flex-shrink-0"
|
||||||
|
style={{ borderTop: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{/* Voice button */}
|
||||||
|
<button
|
||||||
|
onMouseDown={startRecording}
|
||||||
|
onMouseUp={stopRecording}
|
||||||
|
onTouchStart={startRecording}
|
||||||
|
onTouchEnd={stopRecording}
|
||||||
|
className="flex-shrink-0 rounded-lg flex items-center justify-center transition-all"
|
||||||
|
style={{
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
background: recording ? 'var(--accent)' : 'var(--bg3)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: recording ? '#e8f5ed' : 'var(--text3)',
|
||||||
|
}}
|
||||||
|
aria-label={recording ? 'Recording...' : 'Hold to record'}
|
||||||
|
title="Hold to record voice"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-7 8a7 7 0 0 0 14 0h2a9 9 0 0 1-8 8.94V22h-2v-2.06A9 9 0 0 1 3 11h2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Text input */}
|
||||||
|
<div
|
||||||
|
className="flex-1 rounded-xl"
|
||||||
|
style={{ background: 'var(--bg2)', border: '1px solid var(--border2)' }}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={e => { setText(e.target.value); autoResize(); }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask anything... (Shift+Enter for new line)"
|
||||||
|
rows={1}
|
||||||
|
className="w-full block resize-none outline-none bg-transparent px-3 py-3 leading-relaxed"
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--font-size)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
minHeight: '44px',
|
||||||
|
maxHeight: '160px',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send button */}
|
||||||
|
<button
|
||||||
|
onClick={send}
|
||||||
|
disabled={isLoading || !text.trim()}
|
||||||
|
className="flex-shrink-0 rounded-lg px-4 py-3 text-sm font-medium transition-opacity"
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: '#e8f5ed',
|
||||||
|
border: 'none',
|
||||||
|
cursor: isLoading || !text.trim() ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading || !text.trim() ? 0.4 : 1,
|
||||||
|
minHeight: '44px',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { renderMarkdown } from '@/lib/markdown';
|
||||||
|
|
||||||
|
export default function MessageList() {
|
||||||
|
const { messages, isLoading } = useStore();
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
|
if (!messages.length && !isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-center px-8">
|
||||||
|
<h2 className="text-lg font-medium mb-2" style={{ color: 'var(--text2)' }}>
|
||||||
|
What are you working on?
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-relaxed max-w-sm" style={{ color: 'var(--text3)' }}>
|
||||||
|
Ask about your documents, projects, research, or anything else.
|
||||||
|
Your entire corpus is available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-6 flex flex-col gap-5">
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex flex-col max-w-3xl w-full ${
|
||||||
|
m.role === 'user' ? 'self-end items-end max-w-xl' : 'self-start items-start'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-xs mb-1"
|
||||||
|
style={{ color: 'var(--text3)', letterSpacing: '0.03em' }}
|
||||||
|
>
|
||||||
|
{m.role === 'user' ? 'you' : 'aaron ai'}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 leading-relaxed"
|
||||||
|
style={{
|
||||||
|
background: m.role === 'user' ? 'var(--user-bg)' : 'var(--accent-light)',
|
||||||
|
color: m.role === 'user' ? 'var(--text)' : 'var(--accent-text)',
|
||||||
|
border: m.role === 'assistant' ? '1px solid var(--accent-border)' : 'none',
|
||||||
|
borderRadius: m.role === 'user' ? '12px 12px 3px 12px' : '12px 12px 12px 3px',
|
||||||
|
fontSize: 'var(--font-size)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.role === 'assistant' ? (
|
||||||
|
<div
|
||||||
|
className="prose"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.content) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ whiteSpace: 'pre-wrap' }}>{m.content}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{m.sources && m.sources.length > 0 && (
|
||||||
|
<div className="text-xs mt-1.5 italic" style={{ color: 'var(--text3)' }}>
|
||||||
|
Sources: {[...new Set(m.sources)].join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="self-start flex flex-col items-start max-w-3xl">
|
||||||
|
<div className="text-xs mb-1" style={{ color: 'var(--text3)' }}>aaron ai</div>
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 rounded-xl text-sm italic"
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
color: 'var(--text3)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
animation: 'pulse 1.5s infinite',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Thinking...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Status } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function SettingsPanel() {
|
||||||
|
const { settingsOpen, setSettingsOpen, settings, setSettings } = useStore();
|
||||||
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
|
const [memory, setMemory] = useState('');
|
||||||
|
const [editingMemory, setEditingMemory] = useState(false);
|
||||||
|
const [reindexing, setReindexing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsOpen) return;
|
||||||
|
api.getStatus().then(setStatus).catch(console.error);
|
||||||
|
api.getMemory().then(d => setMemory(d.content)).catch(console.error);
|
||||||
|
}, [settingsOpen]);
|
||||||
|
|
||||||
|
async function updateSetting(key: string, value: unknown) {
|
||||||
|
const updated = { ...settings, [key]: value };
|
||||||
|
setSettings(updated);
|
||||||
|
await api.updateSettings(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMemory() {
|
||||||
|
await api.updateMemory(memory);
|
||||||
|
setEditingMemory(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerReindex() {
|
||||||
|
setReindexing(true);
|
||||||
|
await api.reindex();
|
||||||
|
setTimeout(() => setReindexing(false), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportConversation() {
|
||||||
|
const messages = useStore.getState().messages;
|
||||||
|
if (!messages.length) { alert('No messages to export.'); return; }
|
||||||
|
let md = `# Conversation Export\n\nExported: ${new Date().toLocaleString()}\n\n---\n\n`;
|
||||||
|
messages.forEach(m => {
|
||||||
|
md += `**${m.role === 'user' ? 'You' : 'Aaron AI'}**\n\n${m.content}\n\n`;
|
||||||
|
if (m.sources?.length) md += `*Sources: ${m.sources.join(', ')}*\n\n`;
|
||||||
|
md += '---\n\n';
|
||||||
|
});
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(new Blob([md], { type: 'text/markdown' }));
|
||||||
|
a.download = `conversation-${Date.now()}.md`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAll() {
|
||||||
|
if (!confirm('Delete all conversations permanently?')) return;
|
||||||
|
await api.clearAllConversations();
|
||||||
|
useStore.getState().setConversations([]);
|
||||||
|
useStore.getState().setCurrentId(null);
|
||||||
|
useStore.getState().setMessages([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop on mobile */}
|
||||||
|
{settingsOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`fixed top-0 right-0 bottom-0 z-50 flex flex-col transition-transform duration-200 ${
|
||||||
|
settingsOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: 'min(340px, 100vw)',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
borderLeft: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
|
||||||
|
style={{ borderBottom: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-medium" style={{ color: 'var(--text)' }}>Settings</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--text3)', fontSize: '20px', cursor: 'pointer', padding: '2px 6px', lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<Section title="Appearance">
|
||||||
|
<Row label="Dark mode" desc="Switch between light and dark theme">
|
||||||
|
<Toggle
|
||||||
|
on={settings.theme === 'dark'}
|
||||||
|
onChange={v => updateSetting('theme', v ? 'dark' : 'light')}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row label="Font size">
|
||||||
|
<select
|
||||||
|
value={settings.font_size}
|
||||||
|
onChange={e => updateSetting('font_size', e.target.value)}
|
||||||
|
className="rounded-md px-2 py-1 text-sm"
|
||||||
|
style={{ background: 'var(--bg3)', border: '1px solid var(--border2)', color: 'var(--text)', fontFamily: 'var(--font-sans)' }}
|
||||||
|
>
|
||||||
|
<option value="small">Small (13px)</option>
|
||||||
|
<option value="medium">Medium (15px)</option>
|
||||||
|
<option value="large">Large (17px)</option>
|
||||||
|
</select>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Corpus */}
|
||||||
|
<Section title="Corpus">
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
<StatCard number={(status?.chunk_count || 0).toLocaleString()} label="total chunks" />
|
||||||
|
<StatCard number={(status?.file_count || 0).toLocaleString()} label="files indexed" />
|
||||||
|
</div>
|
||||||
|
<Row label="Last indexed" desc={status?.last_indexed || 'Unknown'}>
|
||||||
|
<SBtn primary onClick={triggerReindex} disabled={reindexing}>
|
||||||
|
{reindexing ? 'Starting...' : 'Re-index'}
|
||||||
|
</SBtn>
|
||||||
|
</Row>
|
||||||
|
<Row label="Web search" desc="Search the web for current information">
|
||||||
|
<Toggle on={settings.web_search} onChange={v => updateSetting('web_search', v)} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Show sources" desc="Display document sources under responses">
|
||||||
|
<Toggle on={settings.show_sources} onChange={v => updateSetting('show_sources', v)} />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Memory */}
|
||||||
|
<Section title="Memory">
|
||||||
|
{editingMemory ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
value={memory}
|
||||||
|
onChange={e => setMemory(e.target.value)}
|
||||||
|
className="w-full rounded-lg px-3 py-2 text-xs leading-relaxed resize-y mb-2"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg3)',
|
||||||
|
border: '1px solid var(--border2)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
minHeight: '140px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<SBtn primary onClick={saveMemory}>Save</SBtn>
|
||||||
|
<SBtn onClick={() => setEditingMemory(false)}>Cancel</SBtn>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="rounded-lg px-3 py-2 text-xs leading-relaxed mb-2 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg3)',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
maxHeight: '90px',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{memory.split('\n').slice(0, 6).join('\n') || 'No memory yet'}
|
||||||
|
</div>
|
||||||
|
<SBtn primary onClick={() => setEditingMemory(true)}>Edit memory</SBtn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Conversations */}
|
||||||
|
<Section title="Conversations">
|
||||||
|
<Row label="Stored" desc="">
|
||||||
|
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||||
|
{status?.conversation_count || 0}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="Export current" desc="Download as markdown">
|
||||||
|
<SBtn onClick={exportConversation}>Export</SBtn>
|
||||||
|
</Row>
|
||||||
|
<Row label="Clear all" desc="Permanently delete history">
|
||||||
|
<SBtn danger onClick={clearAll}>Clear</SBtn>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* System */}
|
||||||
|
<Section title="System">
|
||||||
|
<StatusRow label="Aaron AI service" value={status?.aaron_ai || 'unknown'} ok={status?.aaron_ai === 'running'} />
|
||||||
|
<StatusRow label="File watcher" value={status?.watcher || 'unknown'} ok={status?.watcher === 'running'} />
|
||||||
|
<StatusRow label="Nextcloud files" value={`${(status?.file_count || 0).toLocaleString()} files`} ok={true} />
|
||||||
|
<StatusRow label="Model" value={status?.model || 'unknown'} ok={true} yellow />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-components
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--text3)', letterSpacing: '0.06em' }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2 gap-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm" style={{ color: 'var(--text)' }}>{label}</div>
|
||||||
|
{desc && <div className="text-xs mt-0.5" style={{ color: 'var(--text3)' }}>{desc}</div>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(!on)}
|
||||||
|
className="flex-shrink-0 rounded-full relative transition-colors"
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '20px',
|
||||||
|
background: on ? 'var(--accent)' : 'var(--border2)',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute rounded-full bg-white transition-all"
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
top: '2px',
|
||||||
|
left: on ? '18px' : '2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SBtn({ children, onClick, primary, danger, disabled }: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
primary?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded-md px-3 py-1 text-xs whitespace-nowrap transition-opacity"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: `1px solid ${danger ? '#f7c1c1' : primary ? 'var(--accent-border)' : 'var(--border2)'}`,
|
||||||
|
color: danger ? '#a32d2d' : primary ? 'var(--accent)' : 'var(--text2)',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ number, label }: { number: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg px-3 py-2" style={{ background: 'var(--accent-light)', border: '1px solid var(--accent-border)' }}>
|
||||||
|
<div className="text-xl font-medium" style={{ color: 'var(--accent)' }}>{number}</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: 'var(--text3)' }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusRow({ label, value, ok, yellow }: { label: string; value: string; ok: boolean; yellow?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-2" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<span
|
||||||
|
className="rounded-full flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
background: yellow ? '#c8821a' : ok ? '#2d7a3d' : '#a32d2d',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm flex-1" style={{ color: 'var(--text)' }}>{label}</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text3)' }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = (now.getTime() - d.getTime()) / 1000;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
if (diff < 172800) return 'yesterday';
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupConversations(convs: { id: string; title: string; updated_at: string; created_at: string; message_count: number }[]) {
|
||||||
|
const now = new Date();
|
||||||
|
const groups: Record<string, typeof convs> = { today: [], yesterday: [], week: [], older: [] };
|
||||||
|
convs.forEach(c => {
|
||||||
|
const diff = (now.getTime() - new Date(c.updated_at).getTime()) / 86400000;
|
||||||
|
if (diff < 1) groups.today.push(c);
|
||||||
|
else if (diff < 2) groups.yesterday.push(c);
|
||||||
|
else if (diff < 7) groups.week.push(c);
|
||||||
|
else groups.older.push(c);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const { conversations, currentId, setCurrentId, setMessages, setConversations, setSidebarOpen } = useStore();
|
||||||
|
|
||||||
|
async function newConversation() {
|
||||||
|
const conv = await api.newConversation();
|
||||||
|
const updated = await api.getConversations();
|
||||||
|
setConversations(updated);
|
||||||
|
setCurrentId(conv.id);
|
||||||
|
setMessages([]);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConversation(id: string) {
|
||||||
|
setCurrentId(id);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteConv(e: React.MouseEvent, id: string) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm('Delete this conversation?')) return;
|
||||||
|
await api.deleteConversation(id);
|
||||||
|
const updated = await api.getConversations();
|
||||||
|
setConversations(updated);
|
||||||
|
if (currentId === id) { setCurrentId(null); setMessages([]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupConversations(conversations);
|
||||||
|
const labels: Record<string, string> = { today: 'Today', yesterday: 'Yesterday', week: 'This week', older: 'Older' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<button
|
||||||
|
onClick={newConversation}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-85"
|
||||||
|
style={{ background: 'var(--accent)', color: '#e8f5ed', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px', lineHeight: 1 }}>+</span>
|
||||||
|
New conversation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversation list */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-1 px-2">
|
||||||
|
{Object.entries(labels).map(([key, label]) => {
|
||||||
|
const group = groups[key];
|
||||||
|
if (!group.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<div
|
||||||
|
className="px-2 py-2 text-xs uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--text3)', letterSpacing: '0.06em' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{group.map(c => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => loadConversation(c.id)}
|
||||||
|
className="flex items-center gap-2 px-2 py-2 rounded-lg cursor-pointer mb-0.5 group"
|
||||||
|
style={{
|
||||||
|
background: c.id === currentId ? 'var(--bg3)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (c.id !== currentId) (e.currentTarget as HTMLElement).style.background = 'var(--bg3)'; }}
|
||||||
|
onMouseLeave={e => { if (c.id !== currentId) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm truncate" style={{ color: 'var(--text)' }} title={c.title}>
|
||||||
|
{c.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: 'var(--text3)' }}>
|
||||||
|
{formatDate(c.updated_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={e => deleteConv(e, c.id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 px-1 py-0.5 rounded text-sm transition-opacity"
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--text3)', cursor: 'pointer', minWidth: '24px', minHeight: '24px' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{conversations.length === 0 && (
|
||||||
|
<div className="px-3 py-4 text-sm" style={{ color: 'var(--text3)' }}>
|
||||||
|
No conversations yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
-33
@@ -1,7 +1,8 @@
|
|||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://api.aaronnelson.studio';
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '';
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const url = API_BASE ? `${API_BASE}${path}` : `/api${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
...options,
|
...options,
|
||||||
@@ -11,12 +12,9 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Settings
|
|
||||||
getSettings: () => request<Settings>('/api/settings'),
|
getSettings: () => request<Settings>('/api/settings'),
|
||||||
updateSettings: (s: Partial<Settings>) =>
|
updateSettings: (s: Partial<Settings>) =>
|
||||||
request<Settings>('/api/settings', { method: 'POST', body: JSON.stringify(s) }),
|
request<Settings>('/api/settings', { method: 'POST', body: JSON.stringify(s) }),
|
||||||
|
|
||||||
// Conversations
|
|
||||||
getConversations: () => request<Conversation[]>('/api/conversations'),
|
getConversations: () => request<Conversation[]>('/api/conversations'),
|
||||||
newConversation: (title = 'New conversation') =>
|
newConversation: (title = 'New conversation') =>
|
||||||
request<Conversation>('/api/conversations', { method: 'POST', body: JSON.stringify({ title }) }),
|
request<Conversation>('/api/conversations', { method: 'POST', body: JSON.stringify({ title }) }),
|
||||||
@@ -27,60 +25,49 @@ export const api = {
|
|||||||
request<{ deleted: string }>(`/api/conversations/${id}`, { method: 'DELETE' }),
|
request<{ deleted: string }>(`/api/conversations/${id}`, { method: 'DELETE' }),
|
||||||
clearAllConversations: () =>
|
clearAllConversations: () =>
|
||||||
request<{ cleared: boolean }>('/api/conversations', { method: 'DELETE' }),
|
request<{ cleared: boolean }>('/api/conversations', { method: 'DELETE' }),
|
||||||
|
|
||||||
// Chat
|
|
||||||
sendMessage: (message: string, conversation_id: string) =>
|
sendMessage: (message: string, conversation_id: string) =>
|
||||||
request<ChatResponse>('/api/chat', { method: 'POST', body: JSON.stringify({ message, conversation_id }) }),
|
request<ChatResponse>('/api/chat', { method: 'POST', body: JSON.stringify({ message, conversation_id }) }),
|
||||||
|
|
||||||
// Memory
|
|
||||||
getMemory: () => request<{ content: string }>('/api/memory'),
|
getMemory: () => request<{ content: string }>('/api/memory'),
|
||||||
updateMemory: (content: string) =>
|
updateMemory: (content: string) =>
|
||||||
request<{ saved: boolean }>('/api/memory', { method: 'POST', body: JSON.stringify({ content }) }),
|
request<{ saved: boolean }>('/api/memory', { method: 'POST', body: JSON.stringify({ content }) }),
|
||||||
|
|
||||||
// Status
|
|
||||||
getStatus: () => request<Status>('/api/status'),
|
getStatus: () => request<Status>('/api/status'),
|
||||||
|
|
||||||
// Reindex
|
|
||||||
reindex: () => request<{ started: boolean }>('/api/reindex', { method: 'POST' }),
|
reindex: () => request<{ started: boolean }>('/api/reindex', { method: 'POST' }),
|
||||||
|
|
||||||
// Transcribe
|
|
||||||
transcribe: async (audio: Blob): Promise<{ text: string }> => {
|
transcribe: async (audio: Blob): Promise<{ text: string }> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('audio', audio, 'recording.webm');
|
form.append('audio', audio, 'recording.webm');
|
||||||
const res = await fetch(`${API_BASE}/api/transcribe`, {
|
const url = API_BASE ? `${API_BASE}/api/transcribe` : '/api/api/transcribe';
|
||||||
method: 'POST',
|
const res = await fetch(url, { method: 'POST', credentials: 'include', body: form });
|
||||||
credentials: 'include',
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Transcribe error: ${res.status}`);
|
if (!res.ok) throw new Error(`Transcribe error: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Capture
|
|
||||||
capture: async (data: CapturePayload): Promise<{ saved: boolean }> => {
|
capture: async (data: CapturePayload): Promise<{ saved: boolean }> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
if (data.audio) form.append('audio', data.audio, 'capture.webm');
|
if (data.audio) form.append('audio', data.audio, 'capture.webm');
|
||||||
if (data.image) form.append('image', data.image);
|
if (data.image) form.append('image', data.image);
|
||||||
if (data.text) form.append('text', data.text);
|
if (data.text) form.append('text', data.text);
|
||||||
if (data.project) form.append('project', data.project);
|
if (data.project) form.append('project', data.project);
|
||||||
const res = await fetch(`${API_BASE}/api/capture`, {
|
const url = API_BASE ? `${API_BASE}/api/capture` : '/api/api/capture';
|
||||||
method: 'POST',
|
const res = await fetch(url, { method: 'POST', credentials: 'include', body: form });
|
||||||
credentials: 'include',
|
|
||||||
body: form,
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Capture error: ${res.status}`);
|
if (!res.ok) throw new Error(`Capture error: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Types
|
export const auth = {
|
||||||
|
login: (password: string) =>
|
||||||
|
request<{ ok: boolean }>('/auth/login', { method: 'POST', body: JSON.stringify({ password }) }),
|
||||||
|
logout: () =>
|
||||||
|
request<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
|
||||||
|
check: () =>
|
||||||
|
request<{ authenticated: boolean }>('/auth/check'),
|
||||||
|
};
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
font_size: 'small' | 'medium' | 'large';
|
font_size: 'small' | 'medium' | 'large';
|
||||||
web_search: boolean;
|
web_search: boolean;
|
||||||
show_sources: boolean;
|
show_sources: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -88,20 +75,17 @@ export interface Conversation {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
message_count: number;
|
message_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
sources: string[];
|
sources: string[];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
response: string;
|
response: string;
|
||||||
sources: string[];
|
sources: string[];
|
||||||
conversation_id: string;
|
conversation_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Status {
|
export interface Status {
|
||||||
aaron_ai: string;
|
aaron_ai: string;
|
||||||
watcher: string;
|
watcher: string;
|
||||||
@@ -112,7 +96,6 @@ export interface Status {
|
|||||||
model: string;
|
model: string;
|
||||||
nextcloud_path: string;
|
nextcloud_path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CapturePayload {
|
export interface CapturePayload {
|
||||||
audio?: Blob;
|
audio?: Blob;
|
||||||
image?: File;
|
image?: File;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Allow login page and public assets
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/login') ||
|
||||||
|
pathname.startsWith('/_next') ||
|
||||||
|
pathname.startsWith('/manifest.json') ||
|
||||||
|
pathname.startsWith('/icon') ||
|
||||||
|
pathname.startsWith('/favicon')
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session cookie
|
||||||
|
const session = request.cookies.get('aaronai_session');
|
||||||
|
if (!session?.value) {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "Aaron AI",
|
||||||
|
"short_name": "Aaron AI",
|
||||||
|
"description": "Personal knowledge assistant",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#faf9f6",
|
||||||
|
"theme_color": "#2d5a3d",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Capture",
|
||||||
|
"url": "/capture",
|
||||||
|
"description": "Quick capture voice, photo or note"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user