diff --git a/app/api/[...slug]/route.ts b/app/api/[...slug]/route.ts
new file mode 100644
index 0000000..a915688
--- /dev/null
+++ b/app/api/[...slug]/route.ts
@@ -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;
diff --git a/app/globals.css b/app/globals.css
index a2dc41e..1a9268d 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,26 +1,87 @@
@import "tailwindcss";
:root {
- --background: #ffffff;
- --foreground: #171717;
+ --bg: #faf9f6;
+ --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 {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+[data-theme="dark"] {
+ --bg: #1a1a18;
+ --bg2: #222220;
+ --bg3: #2a2a28;
+ --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) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
-}
+[data-font="small"] { --font-size: 13px; }
+[data-font="medium"] { --font-size: 15px; }
+[data-font="large"] { --font-size: 17px; }
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ 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; }
diff --git a/app/layout.tsx b/app/layout.tsx
index 976eb90..5ea5c50 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,33 +1,48 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
+import type { Metadata, Viewport } from 'next';
+import { IBM_Plex_Sans, IBM_Plex_Mono } from 'next/font/google';
+import './globals.css';
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
+const sans = IBM_Plex_Sans({
+ subsets: ['latin'],
+ weight: ['400', '500'],
+ variable: '--font-sans',
});
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
+const mono = IBM_Plex_Mono({
+ subsets: ['latin'],
+ weight: ['400'],
+ variable: '--font-mono',
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: 'Aaron AI',
+ 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({
children,
-}: Readonly<{
+}: {
children: React.ReactNode;
-}>) {
+}) {
return (
-
-
{children}
+
+
+ {children}
+
);
}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..f88bfe4
--- /dev/null
+++ b/app/login/page.tsx
@@ -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 (
+
+
+
+
+ Aaron AI
+
+
+ personal knowledge assistant
+
+
+
+
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 && (
+
+ {error}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 3f36f7c..0eb223e 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -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() {
+ 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 (
-
-
-
+ {/* Mobile sidebar overlay */}
+ {sidebarOpen && (
+ setSidebarOpen(false)}
/>
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
+
+
+
+ {/* Main */}
+
+ {/* Topbar */}
+
+
+ {/* Hamburger — mobile only */}
+
-
+
-
+
+ {/* Messages */}
+
+
+ {/* Input */}
+
+
+
+ {/* Settings panel */}
+
);
}
diff --git a/components/MessageInput.tsx b/components/MessageInput.tsx
new file mode 100644
index 0000000..854eb31
--- /dev/null
+++ b/components/MessageInput.tsx
@@ -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(null);
+ const mediaRecorderRef = useRef(null);
+ const chunksRef = useRef([]);
+
+ 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 (
+
+ {/* Voice button */}
+
+
+ {/* Text input */}
+
+
+
+ {/* Send button */}
+
+
+ );
+}
diff --git a/components/MessageList.tsx b/components/MessageList.tsx
new file mode 100644
index 0000000..0171a08
--- /dev/null
+++ b/components/MessageList.tsx
@@ -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(null);
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages, isLoading]);
+
+ if (!messages.length && !isLoading) {
+ return (
+
+
+ What are you working on?
+
+
+ Ask about your documents, projects, research, or anything else.
+ Your entire corpus is available.
+
+
+ );
+ }
+
+ return (
+
+ {messages.map((m, i) => (
+
+
+ {m.role === 'user' ? 'you' : 'aaron ai'}
+
+
+ {m.role === 'assistant' ? (
+
+ ) : (
+
{m.content}
+ )}
+
+ {m.sources && m.sources.length > 0 && (
+
+ Sources: {[...new Set(m.sources)].join(', ')}
+
+ )}
+
+ ))}
+
+ {isLoading && (
+
+
aaron ai
+
+ Thinking...
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx
new file mode 100644
index 0000000..507617c
--- /dev/null
+++ b/components/SettingsPanel.tsx
@@ -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(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 && (
+ setSettingsOpen(false)}
+ />
+ )}
+
+
+ {/* Header */}
+
+
Settings
+
+
+
+ {/* Body */}
+
+
+ {/* Appearance */}
+
+
+ updateSetting('theme', v ? 'dark' : 'light')}
+ />
+
+
+
+
+
+
+ {/* Corpus */}
+
+
+
+
+
+
+
+ {reindexing ? 'Starting...' : 'Re-index'}
+
+
+
+ updateSetting('web_search', v)} />
+
+
+ updateSetting('show_sources', v)} />
+
+
+
+ {/* Memory */}
+
+ {editingMemory ? (
+ <>
+
+
+ {/* Conversations */}
+
+
+
+ {status?.conversation_count || 0}
+
+
+
+ Export
+
+
+ Clear
+
+
+
+ {/* System */}
+
+
+
+
+ >
+ );
+}
+
+// Sub-components
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+
+ {title}
+
+
{children}
+
+ );
+}
+
+function Row({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) {
+ return (
+
+
+
{label}
+ {desc &&
{desc}
}
+
+ {children}
+
+ );
+}
+
+function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
+ return (
+
+ );
+}
+
+function SBtn({ children, onClick, primary, danger, disabled }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ primary?: boolean;
+ danger?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function StatCard({ number, label }: { number: string; label: string }) {
+ return (
+
+ );
+}
+
+function StatusRow({ label, value, ok, yellow }: { label: string; value: string; ok: boolean; yellow?: boolean }) {
+ return (
+
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
new file mode 100644
index 0000000..496405a
--- /dev/null
+++ b/components/Sidebar.tsx
@@ -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
= { 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 = { today: 'Today', yesterday: 'Yesterday', week: 'This week', older: 'Older' };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {/* Conversation list */}
+
+ {Object.entries(labels).map(([key, label]) => {
+ const group = groups[key];
+ if (!group.length) return null;
+ return (
+
+
+ {label}
+
+ {group.map(c => (
+
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'; }}
+ >
+
+
+ {c.title}
+
+
+ {formatDate(c.updated_at)}
+
+
+
+
+ ))}
+
+ );
+ })}
+ {conversations.length === 0 && (
+
+ No conversations yet
+
+ )}
+
+
+ );
+}
diff --git a/lib/api.ts b/lib/api.ts
index 1cae2dd..2fd8862 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -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(path: string, options?: RequestInit): Promise {
- const res = await fetch(`${API_BASE}${path}`, {
+ const url = API_BASE ? `${API_BASE}${path}` : `/api${path}`;
+ const res = await fetch(url, {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
...options,
@@ -11,12 +12,9 @@ async function request(path: string, options?: RequestInit): Promise {
}
export const api = {
- // Settings
getSettings: () => request('/api/settings'),
updateSettings: (s: Partial) =>
request('/api/settings', { method: 'POST', body: JSON.stringify(s) }),
-
- // Conversations
getConversations: () => request('/api/conversations'),
newConversation: (title = 'New conversation') =>
request('/api/conversations', { method: 'POST', body: JSON.stringify({ title }) }),
@@ -27,60 +25,49 @@ export const api = {
request<{ deleted: string }>(`/api/conversations/${id}`, { method: 'DELETE' }),
clearAllConversations: () =>
request<{ cleared: boolean }>('/api/conversations', { method: 'DELETE' }),
-
- // Chat
sendMessage: (message: string, conversation_id: string) =>
request('/api/chat', { method: 'POST', body: JSON.stringify({ message, conversation_id }) }),
-
- // Memory
getMemory: () => request<{ content: string }>('/api/memory'),
updateMemory: (content: string) =>
request<{ saved: boolean }>('/api/memory', { method: 'POST', body: JSON.stringify({ content }) }),
-
- // Status
getStatus: () => request('/api/status'),
-
- // Reindex
reindex: () => request<{ started: boolean }>('/api/reindex', { method: 'POST' }),
-
- // Transcribe
transcribe: async (audio: Blob): Promise<{ text: string }> => {
const form = new FormData();
form.append('audio', audio, 'recording.webm');
- const res = await fetch(`${API_BASE}/api/transcribe`, {
- method: 'POST',
- credentials: 'include',
- body: form,
- });
+ const url = API_BASE ? `${API_BASE}/api/transcribe` : '/api/api/transcribe';
+ const res = await fetch(url, { method: 'POST', credentials: 'include', body: form });
if (!res.ok) throw new Error(`Transcribe error: ${res.status}`);
return res.json();
},
-
- // Capture
capture: async (data: CapturePayload): Promise<{ saved: boolean }> => {
const form = new FormData();
if (data.audio) form.append('audio', data.audio, 'capture.webm');
if (data.image) form.append('image', data.image);
if (data.text) form.append('text', data.text);
if (data.project) form.append('project', data.project);
- const res = await fetch(`${API_BASE}/api/capture`, {
- method: 'POST',
- credentials: 'include',
- body: form,
- });
+ const url = API_BASE ? `${API_BASE}/api/capture` : '/api/api/capture';
+ const res = await fetch(url, { method: 'POST', credentials: 'include', body: form });
if (!res.ok) throw new Error(`Capture error: ${res.status}`);
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 {
theme: 'light' | 'dark';
font_size: 'small' | 'medium' | 'large';
web_search: boolean;
show_sources: boolean;
}
-
export interface Conversation {
id: string;
title: string;
@@ -88,20 +75,17 @@ export interface Conversation {
updated_at: string;
message_count: number;
}
-
export interface Message {
role: 'user' | 'assistant';
content: string;
sources: string[];
timestamp: string;
}
-
export interface ChatResponse {
response: string;
sources: string[];
conversation_id: string;
}
-
export interface Status {
aaron_ai: string;
watcher: string;
@@ -112,7 +96,6 @@ export interface Status {
model: string;
nextcloud_path: string;
}
-
export interface CapturePayload {
audio?: Blob;
image?: File;
diff --git a/proxy.ts b/proxy.ts
new file mode 100644
index 0000000..b7d2849
--- /dev/null
+++ b/proxy.ts
@@ -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).*)'],
+};
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..8383c00
--- /dev/null
+++ b/public/manifest.json
@@ -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"
+ }
+ ]
+}