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
|
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
|
||||||
QDRANT_URL: http://qdrant:6333
|
QDRANT_URL: http://qdrant:6333
|
||||||
SERVER_URL: "${SERVER_URL:-http://localhost:8000}"
|
SERVER_URL: "${SERVER_URL:-http://localhost:8000}"
|
||||||
|
VITE_SERVER_URL: "${SERVER_URL:-http://localhost:8000}"
|
||||||
|
STATIC_DIR: "/app/static"
|
||||||
secrets: [postgres_password]
|
secrets: [postgres_password]
|
||||||
volumes:
|
volumes:
|
||||||
- ./memory_files:/app/memory_files:rw
|
- ./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
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@ -20,6 +30,9 @@ RUN pip install -e ".[api]"
|
|||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
RUN pip install -e ".[api]"
|
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 as non-root user
|
||||||
RUN useradd -m appuser
|
RUN useradd -m appuser
|
||||||
RUN mkdir -p /app/memory_files
|
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")
|
return login_form(request, oauth_params, "Invalid email or password")
|
||||||
|
|
||||||
redirect_url = await oauth_provider.complete_authorization(oauth_params, user)
|
redirect_url = await oauth_provider.complete_authorization(oauth_params, user)
|
||||||
|
print("redirect_url", redirect_url)
|
||||||
if redirect_url.startswith("http://anysphere.cursor-retrieval"):
|
if redirect_url.startswith("http://anysphere.cursor-retrieval"):
|
||||||
redirect_url = redirect_url.replace("http://", "cursor://")
|
redirect_url = redirect_url.replace("http://", "cursor://")
|
||||||
return RedirectResponse(url=redirect_url, status_code=302)
|
return RedirectResponse(url=redirect_url, status_code=302)
|
||||||
|
@ -4,31 +4,17 @@ FastAPI application for the knowledge base.
|
|||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import pathlib
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated, Optional
|
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import FastAPI, UploadFile, Request
|
||||||
FastAPI,
|
|
||||||
HTTPException,
|
|
||||||
File,
|
|
||||||
UploadFile,
|
|
||||||
Query,
|
|
||||||
Form,
|
|
||||||
Depends,
|
|
||||||
Request,
|
|
||||||
)
|
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqladmin import Admin
|
from sqladmin import Admin
|
||||||
|
|
||||||
from memory.common import extract, settings
|
from memory.common import extract, settings
|
||||||
from memory.common.db.connection import get_engine
|
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.admin import setup_admin
|
||||||
from memory.api.search import search, SearchResult
|
|
||||||
from memory.api.auth import (
|
from memory.api.auth import (
|
||||||
get_current_user,
|
|
||||||
AuthenticationMiddleware,
|
AuthenticationMiddleware,
|
||||||
router as auth_router,
|
router as auth_router,
|
||||||
)
|
)
|
||||||
@ -45,14 +31,25 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Knowledge Base API", lifespan=lifespan)
|
app = FastAPI(title="Knowledge Base API", lifespan=lifespan)
|
||||||
|
app.add_middleware(AuthenticationMiddleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # In production, specify actual origins
|
allow_origins=["*"], # [settings.SERVER_URL],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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
|
# SQLAdmin setup with OAuth protection
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
admin = Admin(app, engine)
|
admin = Admin(app, engine)
|
||||||
@ -60,7 +57,6 @@ admin = Admin(app, engine)
|
|||||||
# Setup admin with OAuth protection using existing OAuth provider
|
# Setup admin with OAuth protection using existing OAuth provider
|
||||||
setup_admin(admin)
|
setup_admin(admin)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.add_middleware(AuthenticationMiddleware)
|
|
||||||
|
|
||||||
|
|
||||||
# Add health check to MCP server instead of main app
|
# 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())
|
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):
|
def main(reload: bool = False):
|
||||||
"""Run the FastAPI server in debug mode with auto-reloading."""
|
"""Run the FastAPI server in debug mode with auto-reloading."""
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
@ -1,14 +1,10 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import cast
|
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Depends, Request, Response, APIRouter, Form
|
from fastapi import HTTPException, Depends, Request, Response, APIRouter
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from memory.common import settings
|
from memory.common import settings
|
||||||
from sqlalchemy.orm import Session as DBSession, scoped_session
|
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.connection import get_session, make_session
|
||||||
from memory.common.db.models.users import User, UserSession
|
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__)
|
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
|
# Create router
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
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(
|
def create_user_session(
|
||||||
user_id: int, db: DBSession, valid_for: int = settings.SESSION_VALID_FOR
|
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
|
request: Request, db: DBSession | scoped_session
|
||||||
) -> UserSession | None:
|
) -> UserSession | None:
|
||||||
"""Get session ID from request"""
|
"""Get session ID from request"""
|
||||||
session_id = request.cookies.get(settings.SESSION_COOKIE_NAME)
|
session_id = get_token(request)
|
||||||
|
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return None
|
return None
|
||||||
@ -110,82 +114,13 @@ def authenticate_user(email: str, password: str, db: DBSession) -> User | None:
|
|||||||
return 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"])
|
@router.api_route("/logout", methods=["GET", "POST"])
|
||||||
def logout(
|
def logout(request: Request, db: DBSession = Depends(get_session)):
|
||||||
request: Request,
|
|
||||||
response: Response,
|
|
||||||
db: DBSession = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Logout and clear session"""
|
"""Logout and clear session"""
|
||||||
session = get_user_session(request, db)
|
session = get_user_session(request, db)
|
||||||
if session:
|
if session:
|
||||||
db.delete(session)
|
db.delete(session)
|
||||||
db.commit()
|
db.commit()
|
||||||
response.delete_cookie(settings.SESSION_COOKIE_NAME)
|
|
||||||
return {"message": "Logged out successfully"}
|
return {"message": "Logged out successfully"}
|
||||||
|
|
||||||
|
|
||||||
@ -198,19 +133,6 @@ def get_me(user: User = Depends(get_current_user)):
|
|||||||
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
class AuthenticationMiddleware(BaseHTTPMiddleware):
|
||||||
"""Middleware to require authentication for all endpoints except whitelisted ones."""
|
"""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):
|
async def dispatch(self, request: Request, call_next):
|
||||||
if settings.DISABLE_AUTH:
|
if settings.DISABLE_AUTH:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
@ -218,11 +140,14 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
|
|||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
# Skip authentication for whitelisted endpoints
|
# 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)
|
return await call_next(request)
|
||||||
|
|
||||||
# Check for session ID in header or cookie
|
# 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:
|
if not session_id:
|
||||||
return Response(
|
return Response(
|
||||||
content="Authentication required",
|
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
|
REGISTER_ENABLED = boolean_env("REGISTER_ENABLED", False) or True
|
||||||
DISABLE_AUTH = boolean_env("DISABLE_AUTH", False)
|
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 notification settings
|
||||||
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "")
|
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user