add frontend

This commit is contained in:
Daniel O'Connell 2025-06-09 00:36:10 +02:00
parent 6ee46d6215
commit d73c5bc928
26 changed files with 5800 additions and 180 deletions

View File

@ -148,6 +148,8 @@ services:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
QDRANT_URL: http://qdrant:6333
SERVER_URL: "${SERVER_URL:-http://localhost:8000}"
VITE_SERVER_URL: "${SERVER_URL:-http://localhost:8000}"
STATIC_DIR: "/app/static"
secrets: [postgres_password]
volumes:
- ./memory_files:/app/memory_files:rw

View File

@ -1,3 +1,13 @@
# Frontend build stage
FROM node:18-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Backend build stage
FROM python:3.11-slim
WORKDIR /app
@ -20,6 +30,9 @@ RUN pip install -e ".[api]"
COPY src/ ./src/
RUN pip install -e ".[api]"
# Copy frontend build output from previous stage
COPY --from=frontend-builder /frontend/dist ./static/
# Run as non-root user
RUN useradd -m appuser
RUN mkdir -p /app/memory_files

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

33
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

16
frontend/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memory</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4023
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.6.2"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"vite": "^6.3.5"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

684
frontend/src/App.css Normal file
View File

@ -0,0 +1,684 @@
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
background: #f8fafc;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Loading state */
.loading {
display: flex;
align-items: center;
min-height: 100vh;
flex-direction: column;
}
.loading h2 {
color: #667eea;
font-weight: 500;
}
.loading-spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Error state */
.error {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
flex-direction: column;
text-align: center;
padding: 2rem;
}
.error h2 {
color: #e53e3e;
margin-bottom: 1rem;
}
.error p {
color: #666;
margin-bottom: 2rem;
max-width: 400px;
}
.error button {
background: #667eea;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.error button:hover {
background: #5a67d8;
}
/* Login prompt */
.login-prompt {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
flex-direction: column;
text-align: center;
padding: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.login-prompt h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
}
.login-prompt p {
font-size: 1.1rem;
margin-bottom: 2rem;
opacity: 0.9;
max-width: 400px;
}
.login-btn {
background: white;
color: #667eea;
border: none;
padding: 1rem 2rem;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
}
/* App header */
.app-header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
color: #667eea;
font-size: 1.5rem;
font-weight: 600;
}
.logout-btn {
background: #f7fafc;
color: #4a5568;
border: 1px solid #e2e8f0;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.logout-btn:hover {
background: #edf2f7;
color: #2d3748;
}
/* Main content */
.app-main {
flex: 1;
padding: 2rem;
margin: 0 auto;
width: 100%;
}
.welcome {
text-align: center;
margin-bottom: 3rem;
padding: 2rem;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.welcome h2 {
color: #2d3748;
font-size: 1.8rem;
margin-bottom: 1rem;
font-weight: 600;
}
.welcome p {
color: #666;
font-size: 1rem;
margin-bottom: 0.5rem;
}
/* Features grid */
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.feature-card {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
text-decoration: none;
color: inherit;
display: block;
}
.feature-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.feature-card h3 {
color: #2d3748;
font-size: 1.2rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.feature-card p {
color: #666;
font-size: 0.95rem;
}
/* Search View */
.search-view {
min-height: 100vh;
background: #f8fafc;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.search-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.back-btn {
background: #f7fafc;
color: #4a5568;
border: 1px solid #e2e8f0;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.back-btn:hover {
background: #edf2f7;
color: #2d3748;
}
.search-header h2 {
color: #2d3748;
font-size: 1.8rem;
font-weight: 600;
}
/* Search Form */
.search-form {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.search-input-group {
display: flex;
gap: 1rem;
align-items: center;
}
.search-input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.search-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.search-btn:hover:not(:disabled) {
background: #5a67d8;
}
.search-btn:disabled {
background: #a0aec0;
cursor: not-allowed;
}
/* Search Results */
.search-results {
margin-top: 2rem;
}
.results-count {
background: white;
padding: 1rem 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
color: #4a5568;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-result-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
border-left: 4px solid #667eea;
}
.result-score {
font-size: 0.8rem;
color: #718096;
margin-bottom: 0.5rem;
}
.search-result-card h4 {
color: #2d3748;
font-size: 1.1rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.result-content {
color: #4a5568;
line-height: 1.6;
margin-bottom: 1rem;
}
.result-chunks {
border-top: 1px solid #e2e8f0;
padding-top: 1rem;
}
.result-chunks strong {
color: #2d3748;
display: block;
margin-bottom: 0.5rem;
}
.chunk {
background: #f7fafc;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: #4a5568;
border-left: 2px solid #e2e8f0;
}
.no-results {
background: white;
padding: 2rem;
border-radius: 12px;
text-align: center;
color: #718096;
font-size: 1.1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.image-container {
display: flex;
justify-content: center;
align-items: center;
margin: 1rem 0;
}
.search-result-image {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.75rem 0;
}
.tag {
background: #edf2f7;
color: #4a5568;
padding: 0.25rem 0.75rem;
border-radius: 16px;
font-size: 0.8rem;
font-weight: 500;
border: 1px solid #e2e8f0;
transition: background-color 0.2s;
}
.tag:hover {
background: #e2e8f0;
}
/* Responsive design */
@media (max-width: 768px) {
.app-header {
padding: 1rem;
}
.app-header h1 {
font-size: 1.3rem;
}
.app-main {
padding: 1rem;
}
.login-prompt h1 {
font-size: 2rem;
}
.login-prompt p {
font-size: 1rem;
}
.features {
grid-template-columns: 1fr;
gap: 1rem;
}
.feature-card {
padding: 1.5rem;
}
.welcome {
padding: 1.5rem;
}
.search-view {
padding: 1rem;
}
.search-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.search-input-group {
flex-direction: column;
align-items: stretch;
}
.search-form {
padding: 1.5rem;
}
.search-result-card {
padding: 1rem;
}
}
/* Utility classes */
.text-center {
text-align: center;
}
.mt-4 {
margin-top: 1rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.p-4 {
padding: 1rem;
}
/* Markdown content styling */
.markdown-content {
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin: 1rem 0;
line-height: 1.7;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin: 1.5rem 0 0.75rem 0;
color: #1f2937;
}
.markdown-content h1:first-child,
.markdown-content h2:first-child,
.markdown-content h3:first-child,
.markdown-content h4:first-child,
.markdown-content h5:first-child,
.markdown-content h6:first-child {
margin-top: 0;
}
.markdown-content p {
margin: 0.75rem 0;
color: #374151;
}
.markdown-content ul,
.markdown-content ol {
margin: 0.75rem 0;
padding-left: 1.5rem;
}
.markdown-content li {
margin: 0.25rem 0;
color: #374151;
}
.markdown-content blockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
background: #f3f4f6;
border-left: 4px solid #d1d5db;
color: #6b7280;
font-style: italic;
}
.markdown-content code {
background: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.875em;
color: #e53e3e;
}
.markdown-content pre {
background: #1f2937;
color: #f9fafb;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin: 1rem 0;
}
.markdown-content pre code {
background: none;
color: inherit;
padding: 0;
}
.markdown-content a {
color: #3b82f6;
text-decoration: underline;
}
.markdown-content a:hover {
color: #1d4ed8;
}
.markdown-preview {
padding: 0.75rem;
background: #f8fafc;
border-radius: 6px;
border: 1px solid #e2e8f0;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3,
.markdown-preview h4,
.markdown-preview h5,
.markdown-preview h6 {
margin: 0.75rem 0 0.5rem 0;
color: #2d3748;
}
.markdown-preview h1:first-child,
.markdown-preview h2:first-child,
.markdown-preview h3:first-child,
.markdown-preview h4:first-child,
.markdown-preview h5:first-child,
.markdown-preview h6:first-child {
margin-top: 0;
}
.markdown-preview p {
margin: 0.5rem 0;
color: #4a5568;
}
.markdown-preview ul,
.markdown-preview ol {
margin: 0.5rem 0;
padding-left: 1.25rem;
}
.markdown-preview li {
margin: 0.125rem 0;
color: #4a5568;
}
.markdown-preview code {
background: #edf2f7;
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.8em;
color: #c53030;
}
.markdown-preview pre {
background: #2d3748;
color: #e2e8f0;
padding: 0.75rem;
border-radius: 4px;
overflow-x: auto;
margin: 0.5rem 0;
font-size: 0.8rem;
}
.markdown-preview pre code {
background: none;
color: inherit;
padding: 0;
}
.markdown-preview blockquote {
margin: 0.5rem 0;
padding: 0.5rem 0.75rem;
background: #f7fafc;
border-left: 3px solid #cbd5e0;
color: #718096;
font-style: italic;
}
.markdown-preview a {
color: #4299e1;
text-decoration: underline;
}
.markdown-preview a:hover {
color: #2b6cb0;
}

117
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,117 @@
import { useEffect } from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'
import './App.css'
import { useAuth } from './hooks/useAuth'
import { useOAuth } from './hooks/useOAuth'
import { Loading, LoginPrompt, AuthError, Dashboard, Search } from './components'
// AuthWrapper handles redirects based on auth state
const AuthWrapper = () => {
const { isAuthenticated, isLoading, logout, checkAuth } = useAuth()
const { error, startOAuth, handleCallback, clearError } = useOAuth()
const navigate = useNavigate()
const location = useLocation()
// Handle OAuth callback on mount
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.get('code')) {
handleCallback().then(success => {
if (success) {
checkAuth().then(() => {
// Redirect to dashboard after successful OAuth
navigate('/ui/dashboard', { replace: true })
})
}
})
}
}, [handleCallback, checkAuth, navigate])
// Handle redirects based on auth state changes
useEffect(() => {
if (!isLoading) {
if (isAuthenticated) {
// If authenticated and on login page, redirect to dashboard
if (location.pathname === '/ui/login' || location.pathname === '/ui') {
navigate('/ui/dashboard', { replace: true })
}
} else {
// If not authenticated and on protected route, redirect to login
if (location.pathname !== '/ui/login') {
navigate('/ui/login', { replace: true })
}
}
}
}, [isAuthenticated, isLoading, location.pathname, navigate])
// Loading state
if (isLoading) {
return <Loading />
}
// OAuth error state
if (error) {
return (
<AuthError
error={error}
onRetry={() => {
clearError()
startOAuth()
}}
/>
)
}
return (
<Routes>
{/* Public routes */}
<Route path="/ui/login" element={
!isAuthenticated ? (
<LoginPrompt onLogin={startOAuth} />
) : (
<Navigate to="/ui/dashboard" replace />
)
} />
{/* Protected routes */}
<Route path="/ui/dashboard" element={
isAuthenticated ? (
<Dashboard onLogout={logout} />
) : (
<Navigate to="/ui/login" replace />
)
} />
<Route path="/ui/search" element={
isAuthenticated ? (
<Search />
) : (
<Navigate to="/ui/login" replace />
)
} />
{/* Default redirect */}
<Route path="/" element={
<Navigate to={isAuthenticated ? "/ui/dashboard" : "/ui/login"} replace />
} />
{/* Catch-all redirect */}
<Route path="*" element={
<Navigate to={isAuthenticated ? "/ui/dashboard" : "/ui/login"} replace />
} />
</Routes>
)
}
function App() {
return (
<Router>
<div className="app">
<AuthWrapper />
</div>
</Router>
)
}
export default App

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,44 @@
import { Link } from 'react-router-dom'
import { useMCP } from '../hooks/useMCP'
const Dashboard = ({ onLogout }) => {
const { listNotes } = useMCP()
return (
<div className="app">
<header className="app-header">
<h1>Memory App</h1>
<button onClick={onLogout} className="logout-btn">
Logout
</button>
</header>
<main className="app-main">
<div className="welcome">
<h2>Welcome to your Memory Database!</h2>
<p>You are successfully authenticated.</p>
<p>Access token is stored in cookies and ready for API calls.</p>
</div>
<div className="features">
<Link to="/ui/search" className="feature-card">
<h3>🔍 Search</h3>
<p>Search through your knowledge base</p>
</Link>
<div className="feature-card" onClick={async () => console.log(await listNotes())}>
<h3>📝 Notes</h3>
<p>Create and manage your notes</p>
</div>
<div className="feature-card">
<h3>🤖 AI Assistant</h3>
<p>Chat with your memory-enhanced AI</p>
</div>
</div>
</main>
</div>
)
}
export default Dashboard

View File

@ -0,0 +1,12 @@
import React from 'react'
const Loading = ({ message = "Loading..." }) => {
return (
<div className="loading">
<h2>{message}</h2>
<div className="loading-spinner"></div>
</div>
)
}
export default Loading

View File

@ -0,0 +1,194 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import { useMCP } from '../hooks/useMCP'
import { useAuth } from '../hooks/useAuth'
import Loading from './Loading'
type SearchItem = {
filename: string
content: string
chunks: any[]
tags: string[]
mime_type: string
metadata: any
}
const Tag = ({ tags }: { tags: string[] }) => {
return (
<div className="tags">
{tags?.map((tag: string, index: number) => (
<span key={index} className="tag">{tag}</span>
))}
</div>
)
}
const formatText = ({ filename, content, chunks, tags }: SearchItem) => {
return (
<div className="search-result-card">
<h4>{filename || 'Untitled'}</h4>
<Tag tags={tags} />
<p className="result-content">{content || 'No content available'}</p>
{chunks && chunks.length > 0 && (
<details className="result-chunks">
<summary>Relevant sections:</summary>
{chunks.map(({preview, score}, chunkIndex) => (
<div key={chunkIndex} className="chunk">
<div className="result-score">Score: {(score || 0).toFixed(3)}</div>
<p>{preview}</p>
</div>
))}
</details>
)}
</div>
)
}
const formatMarkdown = ({ filename, content, chunks, tags, metadata }: SearchItem) => {
return (
<div className="search-result-card">
<h4>{filename || 'Untitled'}</h4>
<Tag tags={tags} />
<div className="markdown-content">
<ReactMarkdown>{content || 'No content available'}</ReactMarkdown>
</div>
{chunks && chunks.length > 0 && (
<details className="result-chunks">
<summary>Relevant sections:</summary>
{chunks.map(({preview, score}, chunkIndex) => (
<div key={chunkIndex} className="chunk">
<div className="result-score">Score: {(score || 0).toFixed(3)}</div>
<div className="markdown-preview">
<p>{preview}</p>
</div>
</div>
))}
</details>
)}
</div>
)
}
const formatImage = ({ filename, chunks, tags, metadata }: SearchItem) => {
const title = metadata?.title || filename || 'Untitled'
const { fetchFile } = useMCP()
const [mime_type, setMimeType] = useState<string>()
const [content, setContent] = useState<string>()
useEffect(() => {
const fetchImage = async () => {
const files = await fetchFile(filename.replace('/app/memory_files/', ''))
const {mime_type, content} = files[0]
setMimeType(mime_type)
setContent(content)
}
fetchImage()
}, [filename])
return (
<div className="search-result-card">
<h4>{title}</h4>
<Tag tags={tags} />
<div className="image-container">
{mime_type && mime_type?.startsWith('image/') && <img src={`data:${mime_type};base64,${content}`} alt={title} className="search-result-image"/>}
</div>
</div>
)
}
const SearchResult = ({ result }: { result: SearchItem }) => {
if (result.mime_type.startsWith('image/')) {
return formatImage(result)
}
if (result.mime_type.startsWith('text/markdown')) {
console.log(result)
return formatMarkdown(result)
}
if (result.mime_type.startsWith('text/')) {
return formatText(result)
}
return null
}
const SearchResults = ({ results, isLoading }: { results: any[], isLoading: boolean }) => {
if (isLoading) {
return <Loading message="Searching..." />
}
return (
<div className="search-results">
{results.length > 0 && (
<div className="results-count">
Found {results.length} result{results.length !== 1 ? 's' : ''}
</div>
)}
{results.map((result, index) => <SearchResult key={index} result={result} />)}
{results.length === 0 && (
<div className="no-results">
No results found
</div>
)}
</div>
)
}
const SearchForm = ({ isLoading, onSearch }: { isLoading: boolean, onSearch: (query: string) => void }) => {
const [query, setQuery] = useState('')
return (
<form onSubmit={(e) => {
e.preventDefault()
onSearch(query)
}} className="search-form">
<div className="search-input-group">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search your knowledge base..."
className="search-input"
/>
<button type="submit" disabled={isLoading} className="search-btn">
{isLoading ? 'Searching...' : 'Search'}
</button>
</div>
</form>
)
}
const Search = () => {
const navigate = useNavigate()
const [results, setResults] = useState([])
const [isLoading, setIsLoading] = useState(false)
const { searchKnowledgeBase } = useMCP()
const handleSearch = async (query: string) => {
if (!query.trim()) return
setIsLoading(true)
try {
const searchResults = await searchKnowledgeBase(query)
setResults(searchResults || [])
} catch (error) {
console.error('Search error:', error)
setResults([])
} finally {
setIsLoading(false)
}
}
return (
<div className="search-view">
<header className="search-header">
<button onClick={() => navigate('/ui/dashboard')} className="back-btn">
Back to Dashboard
</button>
<h2>🔍 Search Knowledge Base</h2>
</header>
<SearchForm isLoading={isLoading} onSearch={handleSearch} />
<SearchResults results={results} isLoading={isLoading} />
</div>
)
}
export default Search

View File

@ -0,0 +1,15 @@
import React from 'react'
const AuthError = ({ error, onRetry }) => {
return (
<div className="error">
<h2>Authentication Error</h2>
<p>{error}</p>
<button onClick={onRetry}>
Try Again
</button>
</div>
)
}
export default AuthError

View File

@ -0,0 +1,15 @@
import React from 'react'
const LoginPrompt = ({ onLogin }) => {
return (
<div className="login-prompt">
<h1>Memory App</h1>
<p>Please log in to access your memory database</p>
<button onClick={onLogin} className="login-btn">
Log In
</button>
</div>
)
}
export default LoginPrompt

View File

@ -0,0 +1,5 @@
export { default as Loading } from './Loading'
export { default as Dashboard } from './Dashboard'
export { default as Search } from './Search'
export { default as LoginPrompt } from './auth/LoginPrompt'
export { default as AuthError } from './auth/AuthError'

View File

@ -0,0 +1,162 @@
import { useState, useEffect, useCallback } from 'react'
const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:8000'
const SESSION_COOKIE_NAME = import.meta.env.VITE_SESSION_COOKIE_NAME || 'session_id'
// Cookie utilities
const getCookie = (name) => {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) return parts.pop().split(';').shift()
return null
}
const setCookie = (name, value, days = 30) => {
const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`
}
const deleteCookie = (name) => {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/`
}
const getClientId = () => localStorage.getItem('oauth_client_id')
export const useAuth = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// Check if user has valid authentication
const checkAuth = useCallback(async () => {
const accessToken = getCookie('access_token')
const sessionId = getCookie(SESSION_COOKIE_NAME)
if (!accessToken && !sessionId) {
setIsAuthenticated(false)
setIsLoading(false)
return false
}
try {
// Validate token by making a test request
const response = await apiCall('/auth/me')
if (response.ok) {
setIsAuthenticated(true)
setIsLoading(false)
return true
} else {
// Token is invalid, clear it
logout()
return false
}
} catch (error) {
console.error('Auth check failed:', error)
logout()
return false
}
}, [])
// Logout function
const logout = useCallback(async () => {
try {
await apiCall('/auth/logout')
} catch (error) {
console.error('Logout failed:', error)
}
deleteCookie('access_token')
deleteCookie('refresh_token')
deleteCookie(SESSION_COOKIE_NAME)
setIsAuthenticated(false)
}, [])
// Refresh access token using refresh token
const refreshToken = useCallback(async () => {
const refreshToken = getCookie('refresh_token')
const clientId = getClientId()
if (!refreshToken || !clientId) {
logout()
return false
}
try {
const response = await apiCall('/token', {
method: 'POST',
body: {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
},
})
if (response.ok) {
const tokens = await response.json()
setCookie('access_token', tokens.access_token, 30)
if (tokens.refresh_token) {
setCookie('refresh_token', tokens.refresh_token, 30)
}
return true
} else {
logout()
return false
}
} catch (error) {
console.error('Token refresh failed:', error)
logout()
return false
}
}, [logout])
// Make authenticated API calls with automatic token refresh
const apiCall = useCallback(async (endpoint, options = {}) => {
let accessToken = getCookie('access_token')
if (!accessToken) {
throw new Error('No access token available')
}
const defaultHeaders = {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
}
const requestOptions = {
...options,
headers: { ...defaultHeaders, ...options.headers },
}
try {
let response = await fetch(`${SERVER_URL}${endpoint}`, requestOptions)
// If unauthorized, try refreshing token once
if (response.status === 401) {
const refreshed = await refreshToken()
if (refreshed) {
accessToken = getCookie('access_token')
requestOptions.headers['Authorization'] = `Bearer ${accessToken}`
response = await fetch(`${SERVER_URL}${endpoint}`, requestOptions)
}
}
return response
} catch (error) {
console.error('API call failed:', error)
throw error
}
}, [refreshToken])
useEffect(() => {
checkAuth()
}, [checkAuth])
return {
isAuthenticated,
isLoading,
logout,
checkAuth,
apiCall,
refreshToken,
}
}

View File

@ -0,0 +1,147 @@
import { useEffect, useCallback } from 'react'
import { useAuth } from './useAuth'
const parseServerSentEvents = async (response: Response): Promise<any> => {
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let buffer = '' // Buffer for incomplete lines
const events: any[] = []
if (reader) {
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
// Decode the chunk and add to buffer
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
// Process complete lines
const lines = buffer.split('\n')
// Keep the last line in buffer if it doesn't end with \n
if (!buffer.endsWith('\n')) {
buffer = lines.pop() || ''
} else {
buffer = ''
}
// Process each complete line to build SSE events
let currentEvent: { event?: string; data?: string; id?: string } = {}
for (const line of lines) {
if (line.trim() === '') {
// Empty line marks end of event
if (currentEvent.data) {
try {
const parsed = JSON.parse(currentEvent.data)
events.push(parsed)
} catch (e) {
console.warn('Failed to parse SSE event data:', currentEvent.data)
}
}
currentEvent = {}
} else if (line.startsWith('event: ')) {
currentEvent.event = line.slice(7)
} else if (line.startsWith('data: ')) {
currentEvent.data = line.slice(6)
} else if (line.startsWith('id: ')) {
currentEvent.id = line.slice(4)
}
// Ignore other SSE fields like retry:
}
// Handle case where last event doesn't end with empty line
if (buffer === '' && currentEvent.data) {
try {
const parsed = JSON.parse(currentEvent.data)
events.push(parsed)
} catch (e) {
console.warn('Failed to parse final SSE event data:', currentEvent.data)
}
}
}
} catch (error) {
console.error('Error reading SSE stream:', error)
throw error
} finally {
reader.releaseLock()
}
}
// For MCP, we expect one JSON-RPC response, so return the last/only event
if (events.length === 0) {
throw new Error('No valid SSE events received')
}
// Return the last event (which should be the JSON-RPC response)
return events[events.length - 1]
}
const parseJsonRpcResponse = async (response: Response): Promise<any> => {
const contentType = response.headers.get('content-type')
if (contentType?.includes('text/event-stream')) {
return parseServerSentEvents(response)
} else {
return response.json()
}
}
export const useMCP = () => {
const { apiCall, isAuthenticated, isLoading, checkAuth } = useAuth()
const mcpCall = useCallback(async (path: string, method: string, params: any = {}) => {
const response = await apiCall(`/mcp${path}`, {
method: 'POST',
headers: {
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method: "tools/call",
params: {
name: method,
arguments: params,
},
}),
})
if (!response.ok) {
throw new Error(`MCP call failed: ${response.statusText}`)
}
const resp = await parseJsonRpcResponse(response)
return resp?.result?.content.map((item: any) => JSON.parse(item.text))
}, [apiCall])
const listNotes = useCallback(async (path: string = "/") => {
return await mcpCall('/note_files', 'note_files', { path })
}, [mcpCall])
const fetchFile = useCallback(async (filename: string) => {
return await mcpCall('/fetch_file', 'fetch_file', { filename })
}, [mcpCall])
const searchKnowledgeBase = useCallback(async (query: string, previews: boolean = true, limit: number = 10) => {
return await mcpCall('/search_knowledge_base', 'search_knowledge_base', {
query,
previews,
limit
})
}, [mcpCall])
useEffect(() => {
checkAuth()
}, [checkAuth])
return {
mcpCall,
fetchFile,
listNotes,
searchKnowledgeBase,
}
}

View File

@ -0,0 +1,191 @@
import { useState, useCallback } from 'react'
const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'http://localhost:8000'
const SESSION_COOKIE_NAME = import.meta.env.VITE_SESSION_COOKIE_NAME || 'session_id'
const REDIRECT_URI = `${window.location.origin}/ui`
// OAuth utilities
const generateCodeVerifier = () => {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
const generateCodeChallenge = async (verifier) => {
const data = new TextEncoder().encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
const generateState = () => {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// Storage utilities
const setCookie = (name, value, days = 30) => {
const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`
}
const getClientId = () => localStorage.getItem('oauth_client_id')
const setClientId = (clientId) => localStorage.setItem('oauth_client_id', clientId)
export const useOAuth = () => {
const [error, setError] = useState<string | null>(null)
// Register OAuth client with the server
const registerClient = useCallback(async () => {
try {
const response = await fetch(`${SERVER_URL}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'React Memory App',
redirect_uris: [REDIRECT_URI],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope: 'read write',
token_endpoint_auth_method: 'none'
})
})
if (response.ok) {
const clientInfo = await response.json()
setClientId(clientInfo.client_id)
return clientInfo.client_id
}
return null
} catch (error) {
console.error('Error registering client:', error)
return null
}
}, [])
// Start OAuth authorization flow
const startOAuth = useCallback(async () => {
setError(null)
let clientId = getClientId()
if (!clientId) {
clientId = await registerClient()
if (!clientId) {
setError('Failed to register OAuth client')
return
}
}
const state = generateState()
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
// Store for callback verification
localStorage.setItem('oauth_state', state)
localStorage.setItem('code_verifier', codeVerifier)
// Build authorization URL
const authUrl = new URL(`${SERVER_URL}/authorize`)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', clientId)
authUrl.searchParams.set('redirect_uri', REDIRECT_URI)
authUrl.searchParams.set('scope', 'read write')
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('code_challenge', codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
window.location.href = authUrl.toString()
}, [registerClient])
// Handle OAuth callback
const handleCallback = useCallback(async () => {
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const state = urlParams.get('state')
const error = urlParams.get('error')
if (error) {
setError(`OAuth error: ${error}`)
return false
}
if (!code || !state) return false
// Verify state
const storedState = localStorage.getItem('oauth_state')
const storedCodeVerifier = localStorage.getItem('code_verifier')
const clientId = getClientId()
if (state !== storedState) {
setError('Invalid state parameter')
return false
}
if (!clientId) {
setError('Client ID not found')
return false
}
try {
// Exchange code for tokens
const response = await fetch(`${SERVER_URL}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
code,
redirect_uri: REDIRECT_URI,
code_verifier: storedCodeVerifier || undefined
})
})
if (response.ok) {
const tokens = await response.json()
// Store tokens
setCookie('access_token', tokens.access_token, 30)
setCookie(SESSION_COOKIE_NAME, tokens.access_token, 30)
if (tokens.refresh_token) {
setCookie('refresh_token', tokens.refresh_token, 30)
}
// Cleanup
localStorage.removeItem('oauth_state')
localStorage.removeItem('code_verifier')
window.history.replaceState({}, document.title, window.location.pathname)
return true
} else {
const errorData = await response.json()
setError(`Token exchange failed: ${errorData.error || 'Unknown error'}`)
return false
}
} catch (err) {
setError(`Network error: ${err.message}`)
return false
}
}, [])
const clearError = useCallback(() => {
setError(null)
localStorage.removeItem('oauth_client_id') // Force re-registration on retry
}, [])
return {
error,
startOAuth,
handleCallback,
clearError
}
}

9
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

8
frontend/vite.config.js Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/ui/',
})

View File

@ -127,6 +127,7 @@ async def handle_login(request: Request):
return login_form(request, oauth_params, "Invalid email or password")
redirect_url = await oauth_provider.complete_authorization(oauth_params, user)
print("redirect_url", redirect_url)
if redirect_url.startswith("http://anysphere.cursor-retrieval"):
redirect_url = redirect_url.replace("http://", "cursor://")
return RedirectResponse(url=redirect_url, status_code=302)

View File

@ -4,31 +4,17 @@ FastAPI application for the knowledge base.
import contextlib
import os
import pathlib
import logging
from typing import Annotated, Optional
from fastapi import (
FastAPI,
HTTPException,
File,
UploadFile,
Query,
Form,
Depends,
Request,
)
from fastapi import FastAPI, UploadFile, Request
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
from sqladmin import Admin
from memory.common import extract, settings
from memory.common.db.connection import get_engine
from memory.common.db.models import User
from memory.api.admin import setup_admin
from memory.api.search import search, SearchResult
from memory.api.auth import (
get_current_user,
AuthenticationMiddleware,
router as auth_router,
)
@ -45,14 +31,25 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Knowledge Base API", lifespan=lifespan)
app.add_middleware(AuthenticationMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify actual origins
allow_origins=["*"], # [settings.SERVER_URL],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/ui{full_path:path}")
async def serve_react_app(full_path: str):
full_path = full_path.lstrip("/")
index_file = settings.STATIC_DIR / full_path
if index_file.is_file():
return FileResponse(index_file)
return FileResponse(settings.STATIC_DIR / "index.html")
# SQLAdmin setup with OAuth protection
engine = get_engine()
admin = Admin(app, engine)
@ -60,7 +57,6 @@ admin = Admin(app, engine)
# Setup admin with OAuth protection using existing OAuth provider
setup_admin(admin)
app.include_router(auth_router)
app.add_middleware(AuthenticationMiddleware)
# Add health check to MCP server instead of main app
@ -86,59 +82,6 @@ async def input_type(item: str | UploadFile) -> list[extract.DataChunk]:
return extract.extract_data_chunks(content_type, await item.read())
@app.post("/search", response_model=list[SearchResult])
async def search_endpoint(
query: Optional[str] = Form(None),
previews: Optional[bool] = Form(False),
modalities: Annotated[list[str], Query()] = [],
files: list[UploadFile] = File([]),
limit: int = Query(10, ge=1, le=100),
min_text_score: float = Query(0.3, ge=0.0, le=1.0),
min_multimodal_score: float = Query(0.3, ge=0.0, le=1.0),
current_user: User = Depends(get_current_user),
):
"""Search endpoint - delegates to search module"""
upload_data = [
chunk for item in [query, *files] for chunk in await input_type(item)
]
logger.error(
f"Querying chunks for {modalities}, query: {query}, previews: {previews}, upload_data: {upload_data}"
)
return await search(
upload_data,
previews=previews,
modalities=set(modalities),
limit=limit,
min_text_score=min_text_score,
min_multimodal_score=min_multimodal_score,
)
@app.get("/files/{path:path}")
def get_file_by_path(path: str, current_user: User = Depends(get_current_user)):
"""
Fetch a file by its path
Parameters:
- path: Path of the file to fetch (relative to FILE_STORAGE_DIR)
Returns:
- The file as a download
"""
# Sanitize the path to prevent directory traversal
sanitized_path = path.lstrip("/")
if ".." in sanitized_path:
raise HTTPException(status_code=400, detail="Invalid path")
file_path = pathlib.Path(settings.FILE_STORAGE_DIR) / sanitized_path
# Check if the file exists on disk
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found at path: {path}")
return FileResponse(path=file_path, filename=file_path.name)
def main(reload: bool = False):
"""Run the FastAPI server in debug mode with auto-reloading."""
import uvicorn

View File

@ -1,14 +1,10 @@
from datetime import datetime, timedelta, timezone
from typing import cast
import logging
import pathlib
from fastapi import HTTPException, Depends, Request, Response, APIRouter, Form
from fastapi.templating import Jinja2Templates
from fastapi import HTTPException, Depends, Request, Response, APIRouter
from starlette.middleware.base import BaseHTTPMiddleware
from memory.common import settings
from sqlalchemy.orm import Session as DBSession, scoped_session
from pydantic import BaseModel
from memory.common.db.connection import get_session, make_session
from memory.common.db.models.users import User, UserSession
@ -16,28 +12,36 @@ from memory.common.db.models.users import User, UserSession
logger = logging.getLogger(__name__)
# Pydantic models
class LoginRequest(BaseModel):
email: str
password: str
class RegisterRequest(BaseModel):
email: str
password: str
name: str
class LoginResponse(BaseModel):
session_id: str
user_id: int
email: str
name: str
# Create router
router = APIRouter(prefix="/auth", tags=["auth"])
# Endpoints that don't require authentication
WHITELIST = {
"/health",
"/register",
"/authorize",
"/token",
"/mcp",
"/oauth/",
"/.well-known/",
"/ui",
}
def get_bearer_token(request: Request) -> str | None:
"""Get bearer token from request"""
bearer_token = request.headers.get("Authorization", "").split(" ")
if len(bearer_token) != 2:
return None
return bearer_token[1]
def get_token(request: Request) -> str | None:
"""Get token from request"""
return get_bearer_token(request) or request.cookies.get(
settings.SESSION_COOKIE_NAME
)
def create_user_session(
user_id: int, db: DBSession, valid_for: int = settings.SESSION_VALID_FOR
@ -56,7 +60,7 @@ def get_user_session(
request: Request, db: DBSession | scoped_session
) -> UserSession | None:
"""Get session ID from request"""
session_id = request.cookies.get(settings.SESSION_COOKIE_NAME)
session_id = get_token(request)
if not session_id:
return None
@ -110,82 +114,13 @@ def authenticate_user(email: str, password: str, db: DBSession) -> User | None:
return None
# Auth endpoints
@router.post("/register", response_model=LoginResponse)
def register(request: RegisterRequest, db: DBSession = Depends(get_session)):
"""Register a new user"""
if not settings.REGISTER_ENABLED:
raise HTTPException(status_code=403, detail="Registration is disabled")
user = create_user(request.email, request.password, request.name, db)
session_id = create_user_session(user.id, db) # type: ignore
return LoginResponse(session_id=session_id, **user.serialize())
@router.get("/login", response_model=LoginResponse)
def login_page(request: Request):
"""Login page"""
template_dir = pathlib.Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=template_dir)
return templates.TemplateResponse(
"login.html",
{
"request": request,
"action": router.url_path_for("login_form"),
"error": None,
"form_data": {},
},
)
@router.post("/login", response_model=LoginResponse)
def login(
request: LoginRequest, response: Response, db: DBSession = Depends(get_session)
):
"""Login and create a session"""
return login_form(response, db, request.email, request.password)
@router.post("/login-form", response_model=LoginResponse)
def login_form(
response: Response,
db: DBSession = Depends(get_session),
email: str = Form(),
password: str = Form(),
):
"""Login with form data and create a session"""
user = authenticate_user(email, password, db)
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
session_id = create_user_session(cast(int, user.id), db)
# Set session cookie
response.set_cookie(
key=settings.SESSION_COOKIE_NAME,
value=session_id,
httponly=True,
secure=settings.HTTPS,
samesite="lax",
max_age=settings.SESSION_COOKIE_MAX_AGE,
)
return LoginResponse(session_id=session_id, **user.serialize())
@router.api_route("/logout", methods=["GET", "POST"])
def logout(
request: Request,
response: Response,
db: DBSession = Depends(get_session),
):
def logout(request: Request, db: DBSession = Depends(get_session)):
"""Logout and clear session"""
session = get_user_session(request, db)
if session:
db.delete(session)
db.commit()
response.delete_cookie(settings.SESSION_COOKIE_NAME)
return {"message": "Logged out successfully"}
@ -198,19 +133,6 @@ def get_me(user: User = Depends(get_current_user)):
class AuthenticationMiddleware(BaseHTTPMiddleware):
"""Middleware to require authentication for all endpoints except whitelisted ones."""
# Endpoints that don't require authentication
WHITELIST = {
"/health",
"/auth/login",
"/auth/login-form",
"/auth/register",
"/register",
"/token",
"/mcp",
"/oauth/",
"/.well-known/",
}
async def dispatch(self, request: Request, call_next):
if settings.DISABLE_AUTH:
return await call_next(request)
@ -218,11 +140,14 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
path = request.url.path
# Skip authentication for whitelisted endpoints
if any(path.startswith(whitelist_path) for whitelist_path in self.WHITELIST):
if (
any(path.startswith(whitelist_path) for whitelist_path in WHITELIST)
or path == "/"
):
return await call_next(request)
# Check for session ID in header or cookie
session_id = request.cookies.get(settings.SESSION_COOKIE_NAME)
session_id = get_token(request)
if not session_id:
return Response(
content="Authentication required",

View File

@ -139,6 +139,12 @@ SESSION_VALID_FOR = int(os.getenv("SESSION_VALID_FOR", 30))
REGISTER_ENABLED = boolean_env("REGISTER_ENABLED", False) or True
DISABLE_AUTH = boolean_env("DISABLE_AUTH", False)
STATIC_DIR = pathlib.Path(
os.getenv(
"STATIC_DIR",
pathlib.Path(__file__).parent.parent.parent.parent / "frontend" / "dist",
)
)
# Discord notification settings
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "")