mirror of
https://github.com/mruwnik/memory.git
synced 2025-06-28 23:24:43 +02:00
add frontend
This commit is contained in:
parent
6ee46d6215
commit
d73c5bc928
@ -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
|
||||
|
@ -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
24
frontend/.gitignore
vendored
Normal 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
33
frontend/eslint.config.js
Normal 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
16
frontend/index.html
Normal 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
4023
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
1
frontend/public/favicon.ico
Normal file
1
frontend/public/favicon.ico
Normal 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
684
frontend/src/App.css
Normal 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
117
frontend/src/App.jsx
Normal 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
|
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
44
frontend/src/components/Dashboard.jsx
Normal file
44
frontend/src/components/Dashboard.jsx
Normal 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
|
12
frontend/src/components/Loading.tsx
Normal file
12
frontend/src/components/Loading.tsx
Normal 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
|
194
frontend/src/components/Search.tsx
Normal file
194
frontend/src/components/Search.tsx
Normal 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
|
15
frontend/src/components/auth/AuthError.tsx
Normal file
15
frontend/src/components/auth/AuthError.tsx
Normal 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
|
15
frontend/src/components/auth/LoginPrompt.tsx
Normal file
15
frontend/src/components/auth/LoginPrompt.tsx
Normal 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
|
5
frontend/src/components/index.js
Normal file
5
frontend/src/components/index.js
Normal 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'
|
162
frontend/src/hooks/useAuth.ts
Normal file
162
frontend/src/hooks/useAuth.ts
Normal 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,
|
||||
}
|
||||
}
|
147
frontend/src/hooks/useMCP.ts
Normal file
147
frontend/src/hooks/useMCP.ts
Normal 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,
|
||||
}
|
||||
}
|
191
frontend/src/hooks/useOAuth.ts
Normal file
191
frontend/src/hooks/useOAuth.ts
Normal 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
9
frontend/src/main.jsx
Normal 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
8
frontend/vite.config.js
Normal 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/',
|
||||
})
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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", "")
|
||||
|
Loading…
x
Reference in New Issue
Block a user