Spaces:
Running
Running
fix: resolve share branch conflicts
Browse files- .github/ISSUE_TEMPLATE/bug_report.yml +30 -49
- .github/ISSUE_TEMPLATE/feature_request.yml +22 -43
- .github/workflows/ci.yml +3 -1
- backend/app/main.py +2 -0
- backend/app/metrics.py +33 -0
- backend/app/rag/vectorstore.py +8 -5
- backend/app/routes/admin.py +73 -0
- backend/app/routes/chat.py +77 -58
- backend/app/schemas.py +18 -0
- backend/tests/conftest.py +6 -5
- backend/tests/test_admin.py +65 -0
- frontend/package-lock.json +105 -0
- frontend/package.json +3 -0
- frontend/src/app/admin/page.tsx +277 -0
- frontend/src/app/layout.tsx +5 -4
- frontend/src/app/login/page.tsx +11 -9
- frontend/src/app/register/page.tsx +13 -11
- frontend/src/components/chat/ChatPanel.tsx +15 -11
- frontend/src/components/document/DocumentSidebar.tsx +15 -13
- frontend/src/components/layout/Header.tsx +40 -7
- frontend/src/components/providers/I18nProvider.tsx +22 -0
- frontend/src/lib/i18n.ts +320 -0
.github/ISSUE_TEMPLATE/bug_report.yml
CHANGED
|
@@ -1,84 +1,65 @@
|
|
| 1 |
-
name:
|
| 2 |
-
description:
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
body:
|
| 7 |
- type: markdown
|
| 8 |
attributes:
|
| 9 |
value: |
|
| 10 |
-
Thanks for taking the time to
|
|
|
|
| 11 |
|
| 12 |
- type: textarea
|
| 13 |
id: description
|
| 14 |
attributes:
|
| 15 |
-
label:
|
| 16 |
-
description: A clear description of what the bug is.
|
| 17 |
-
placeholder: "When I
|
| 18 |
validations:
|
| 19 |
required: true
|
| 20 |
|
| 21 |
- type: textarea
|
| 22 |
id: reproduction
|
| 23 |
attributes:
|
| 24 |
-
label: Steps to Reproduce
|
| 25 |
-
description: How
|
| 26 |
-
|
| 27 |
1. Go to '...'
|
| 28 |
-
2. Click on '...'
|
| 29 |
-
3.
|
|
|
|
| 30 |
validations:
|
| 31 |
required: true
|
| 32 |
|
| 33 |
- type: textarea
|
| 34 |
id: expected
|
| 35 |
attributes:
|
| 36 |
-
label: Expected Behavior
|
| 37 |
-
description:
|
| 38 |
validations:
|
| 39 |
required: true
|
| 40 |
|
| 41 |
- type: textarea
|
| 42 |
id: screenshots
|
| 43 |
attributes:
|
| 44 |
-
label: Screenshots / Logs
|
| 45 |
-
description:
|
| 46 |
|
| 47 |
-
- type:
|
| 48 |
-
id:
|
| 49 |
attributes:
|
| 50 |
-
label:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
- Backend (FastAPI)
|
| 54 |
-
- Frontend (Next.js)
|
| 55 |
-
- RAG / Embeddings
|
| 56 |
-
- Authentication
|
| 57 |
-
- File Upload
|
| 58 |
-
- Chat / Streaming
|
| 59 |
-
- Docker / Deployment
|
| 60 |
-
- Documentation
|
| 61 |
validations:
|
| 62 |
required: true
|
| 63 |
|
| 64 |
-
- type: input
|
| 65 |
-
id: python-version
|
| 66 |
-
attributes:
|
| 67 |
-
label: Python Version (if backend issue)
|
| 68 |
-
placeholder: "e.g. 3.11"
|
| 69 |
-
|
| 70 |
-
- type: input
|
| 71 |
-
id: node-version
|
| 72 |
-
attributes:
|
| 73 |
-
label: Node.js Version (if frontend issue)
|
| 74 |
-
placeholder: "e.g. 20.x"
|
| 75 |
-
|
| 76 |
- type: checkboxes
|
| 77 |
-
id:
|
| 78 |
attributes:
|
| 79 |
-
label:
|
|
|
|
| 80 |
options:
|
| 81 |
-
- label: I
|
| 82 |
-
required: true
|
| 83 |
-
- label: I am targeting the `dev` branch, not `main`.
|
| 84 |
-
required: true
|
|
|
|
| 1 |
+
name: "\U0001f41b Bug Report"
|
| 2 |
+
description: "Create a report to help us improve the project by fixing a bug."
|
| 3 |
+
title: "[BUG] "
|
| 4 |
+
labels: ["bug"]
|
| 5 |
+
assignees:
|
| 6 |
+
- "param20h"
|
| 7 |
body:
|
| 8 |
- type: markdown
|
| 9 |
attributes:
|
| 10 |
value: |
|
| 11 |
+
Thanks for taking the time to fill out this bug report!
|
| 12 |
+
Before you submit, please search the issue tracker to see if this has already been reported.
|
| 13 |
|
| 14 |
- type: textarea
|
| 15 |
id: description
|
| 16 |
attributes:
|
| 17 |
+
label: "Description of the Bug"
|
| 18 |
+
description: "A clear and concise description of what the bug is."
|
| 19 |
+
placeholder: "When I click on X, nothing happens..."
|
| 20 |
validations:
|
| 21 |
required: true
|
| 22 |
|
| 23 |
- type: textarea
|
| 24 |
id: reproduction
|
| 25 |
attributes:
|
| 26 |
+
label: "Steps to Reproduce"
|
| 27 |
+
description: "How can we reproduce this issue?"
|
| 28 |
+
value: |
|
| 29 |
1. Go to '...'
|
| 30 |
+
2. Click on '....'
|
| 31 |
+
3. Scroll down to '....'
|
| 32 |
+
4. See error
|
| 33 |
validations:
|
| 34 |
required: true
|
| 35 |
|
| 36 |
- type: textarea
|
| 37 |
id: expected
|
| 38 |
attributes:
|
| 39 |
+
label: "Expected Behavior"
|
| 40 |
+
description: "A clear and concise description of what you expected to happen."
|
| 41 |
validations:
|
| 42 |
required: true
|
| 43 |
|
| 44 |
- type: textarea
|
| 45 |
id: screenshots
|
| 46 |
attributes:
|
| 47 |
+
label: "Screenshots / Logs"
|
| 48 |
+
description: "If applicable, add screenshots or error logs to help explain your problem."
|
| 49 |
|
| 50 |
+
- type: input
|
| 51 |
+
id: environment
|
| 52 |
attributes:
|
| 53 |
+
label: "Environment"
|
| 54 |
+
description: "What OS, browser, or environment were you using?"
|
| 55 |
+
placeholder: "e.g., macOS Sequoia, Chrome 120, Node.js v20"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
validations:
|
| 57 |
required: true
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
- type: checkboxes
|
| 60 |
+
id: gssoc
|
| 61 |
attributes:
|
| 62 |
+
label: "GSSoC '24"
|
| 63 |
+
description: "Are you a GSSoC contributor?"
|
| 64 |
options:
|
| 65 |
+
- label: "Yes, I am participating in GirlScript Summer of Code and would like to fix this."
|
|
|
|
|
|
|
|
|
.github/ISSUE_TEMPLATE/feature_request.yml
CHANGED
|
@@ -1,69 +1,48 @@
|
|
| 1 |
-
name:
|
| 2 |
-
description: Suggest
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
body:
|
| 7 |
- type: markdown
|
| 8 |
attributes:
|
| 9 |
value: |
|
| 10 |
-
|
|
|
|
| 11 |
|
| 12 |
- type: textarea
|
| 13 |
id: problem
|
| 14 |
attributes:
|
| 15 |
-
label:
|
| 16 |
-
description:
|
| 17 |
-
placeholder: "I find it frustrating when..."
|
| 18 |
validations:
|
| 19 |
required: true
|
| 20 |
|
| 21 |
- type: textarea
|
| 22 |
id: solution
|
| 23 |
attributes:
|
| 24 |
-
label:
|
| 25 |
-
description:
|
| 26 |
validations:
|
| 27 |
required: true
|
| 28 |
|
| 29 |
- type: textarea
|
| 30 |
id: alternatives
|
| 31 |
attributes:
|
| 32 |
-
label:
|
| 33 |
-
description:
|
| 34 |
-
|
| 35 |
-
- type: dropdown
|
| 36 |
-
id: area
|
| 37 |
-
attributes:
|
| 38 |
-
label: Which area does this affect?
|
| 39 |
-
multiple: true
|
| 40 |
-
options:
|
| 41 |
-
- Backend (FastAPI)
|
| 42 |
-
- Frontend (Next.js)
|
| 43 |
-
- RAG / Embeddings
|
| 44 |
-
- Authentication
|
| 45 |
-
- File Upload
|
| 46 |
-
- Chat / Streaming
|
| 47 |
-
- Docker / Deployment
|
| 48 |
-
- Documentation
|
| 49 |
-
- New Area
|
| 50 |
-
validations:
|
| 51 |
-
required: true
|
| 52 |
|
| 53 |
-
- type:
|
| 54 |
-
id:
|
| 55 |
attributes:
|
| 56 |
-
label:
|
| 57 |
-
|
| 58 |
-
- "🟢 Easy (good first issue)"
|
| 59 |
-
- "🟡 Medium"
|
| 60 |
-
- "🔴 Hard / Needs discussion"
|
| 61 |
|
| 62 |
- type: checkboxes
|
| 63 |
-
id:
|
| 64 |
attributes:
|
| 65 |
-
label:
|
|
|
|
| 66 |
options:
|
| 67 |
-
- label: I
|
| 68 |
-
required: true
|
| 69 |
-
- label: I am willing to work on this myself (optional but appreciated!).
|
|
|
|
| 1 |
+
name: "\U0001f680 Feature Request"
|
| 2 |
+
description: "Suggest an idea for this project."
|
| 3 |
+
title: "[FEAT] "
|
| 4 |
+
labels: ["enhancement"]
|
| 5 |
+
assignees:
|
| 6 |
+
- "param20h"
|
| 7 |
body:
|
| 8 |
- type: markdown
|
| 9 |
attributes:
|
| 10 |
value: |
|
| 11 |
+
Thanks for taking the time to suggest a new feature!
|
| 12 |
+
Please provide as much context as possible so we can properly evaluate your idea.
|
| 13 |
|
| 14 |
- type: textarea
|
| 15 |
id: problem
|
| 16 |
attributes:
|
| 17 |
+
label: "Is your feature request related to a problem? Please describe."
|
| 18 |
+
description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]"
|
|
|
|
| 19 |
validations:
|
| 20 |
required: true
|
| 21 |
|
| 22 |
- type: textarea
|
| 23 |
id: solution
|
| 24 |
attributes:
|
| 25 |
+
label: "Describe the solution you'd like"
|
| 26 |
+
description: "A clear and concise description of what you want to happen."
|
| 27 |
validations:
|
| 28 |
required: true
|
| 29 |
|
| 30 |
- type: textarea
|
| 31 |
id: alternatives
|
| 32 |
attributes:
|
| 33 |
+
label: "Describe alternatives you've considered"
|
| 34 |
+
description: "A clear and concise description of any alternative solutions or features you've considered."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
+
- type: textarea
|
| 37 |
+
id: additional_context
|
| 38 |
attributes:
|
| 39 |
+
label: "Additional Context"
|
| 40 |
+
description: "Add any other context, screenshots, or mockups about the feature request here."
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
- type: checkboxes
|
| 43 |
+
id: gssoc
|
| 44 |
attributes:
|
| 45 |
+
label: "GSSoC '24"
|
| 46 |
+
description: "Are you a GSSoC contributor?"
|
| 47 |
options:
|
| 48 |
+
- label: "Yes, I am participating in GirlScript Summer of Code and would like to build this."
|
|
|
|
|
|
.github/workflows/ci.yml
CHANGED
|
@@ -48,16 +48,18 @@ jobs:
|
|
| 48 |
env:
|
| 49 |
SECRET_KEY: ci-dummy-secret
|
| 50 |
DATABASE_URL: sqlite:///./ci_test.db
|
|
|
|
| 51 |
HF_TOKEN: ci-dummy-token
|
| 52 |
UPLOAD_DIR: /tmp/uploads
|
| 53 |
CHROMA_PERSIST_DIR: /tmp/chroma
|
| 54 |
run: |
|
| 55 |
-
python -c "import sys; sys.path.insert(0, 'backend'); from app.config import
|
| 56 |
|
| 57 |
- name: Run backend pytest suite
|
| 58 |
env:
|
| 59 |
SECRET_KEY: ci-dummy-secret
|
| 60 |
DATABASE_URL: sqlite:///./ci_test.db
|
|
|
|
| 61 |
HF_TOKEN: ci-dummy-token
|
| 62 |
UPLOAD_DIR: /tmp/uploads
|
| 63 |
CHROMA_PERSIST_DIR: /tmp/chroma
|
|
|
|
| 48 |
env:
|
| 49 |
SECRET_KEY: ci-dummy-secret
|
| 50 |
DATABASE_URL: sqlite:///./ci_test.db
|
| 51 |
+
DEBUG: "false"
|
| 52 |
HF_TOKEN: ci-dummy-token
|
| 53 |
UPLOAD_DIR: /tmp/uploads
|
| 54 |
CHROMA_PERSIST_DIR: /tmp/chroma
|
| 55 |
run: |
|
| 56 |
+
python -c "import sys; sys.path.insert(0, 'backend'); from app.config import get_settings; get_settings(); print('Config imports OK')"
|
| 57 |
|
| 58 |
- name: Run backend pytest suite
|
| 59 |
env:
|
| 60 |
SECRET_KEY: ci-dummy-secret
|
| 61 |
DATABASE_URL: sqlite:///./ci_test.db
|
| 62 |
+
DEBUG: "false"
|
| 63 |
HF_TOKEN: ci-dummy-token
|
| 64 |
UPLOAD_DIR: /tmp/uploads
|
| 65 |
CHROMA_PERSIST_DIR: /tmp/chroma
|
backend/app/main.py
CHANGED
|
@@ -92,11 +92,13 @@ from app.routes.auth import router as auth_router
|
|
| 92 |
from app.routes.documents import router as documents_router
|
| 93 |
from app.routes.chat import router as chat_router
|
| 94 |
from app.routes.github import router as github_router
|
|
|
|
| 95 |
|
| 96 |
app.include_router(auth_router, prefix="/api/v1")
|
| 97 |
app.include_router(documents_router, prefix="/api/v1")
|
| 98 |
app.include_router(chat_router, prefix="/api/v1")
|
| 99 |
app.include_router(github_router, prefix="/api/v1")
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
# ── Health Check ─────────────────────────────────────
|
|
|
|
| 92 |
from app.routes.documents import router as documents_router
|
| 93 |
from app.routes.chat import router as chat_router
|
| 94 |
from app.routes.github import router as github_router
|
| 95 |
+
from app.routes.admin import router as admin_router
|
| 96 |
|
| 97 |
app.include_router(auth_router, prefix="/api/v1")
|
| 98 |
app.include_router(documents_router, prefix="/api/v1")
|
| 99 |
app.include_router(chat_router, prefix="/api/v1")
|
| 100 |
app.include_router(github_router, prefix="/api/v1")
|
| 101 |
+
app.include_router(admin_router, prefix="/api/v1")
|
| 102 |
|
| 103 |
|
| 104 |
# ── Health Check ─────────────────────────────────────
|
backend/app/metrics.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Runtime metrics helpers for lightweight operational statistics.
|
| 3 |
+
"""
|
| 4 |
+
from threading import Lock
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
_metrics_lock = Lock()
|
| 8 |
+
_query_count = 0
|
| 9 |
+
_query_response_time_total_ms = 0.0
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def record_query_response_time(duration_seconds: float) -> None:
|
| 13 |
+
"""Record one completed query response duration."""
|
| 14 |
+
global _query_count, _query_response_time_total_ms
|
| 15 |
+
|
| 16 |
+
duration_ms = max(duration_seconds, 0) * 1000
|
| 17 |
+
with _metrics_lock:
|
| 18 |
+
_query_count += 1
|
| 19 |
+
_query_response_time_total_ms += duration_ms
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_query_metrics() -> dict[str, float | int]:
|
| 23 |
+
"""Return aggregate query metrics for the current process lifetime."""
|
| 24 |
+
with _metrics_lock:
|
| 25 |
+
average_ms = (
|
| 26 |
+
_query_response_time_total_ms / _query_count
|
| 27 |
+
if _query_count
|
| 28 |
+
else 0.0
|
| 29 |
+
)
|
| 30 |
+
return {
|
| 31 |
+
"query_count": _query_count,
|
| 32 |
+
"average_query_response_time_ms": round(average_ms, 2),
|
| 33 |
+
}
|
backend/app/rag/vectorstore.py
CHANGED
|
@@ -4,11 +4,7 @@ Per-user collections for data isolation.
|
|
| 4 |
"""
|
| 5 |
import logging
|
| 6 |
from typing import List, Dict, Any, Optional
|
| 7 |
-
import chromadb
|
| 8 |
-
from chromadb.config import Settings as ChromaSettings
|
| 9 |
from app.config import get_settings
|
| 10 |
-
from app.rag.embeddings import get_embedding_model
|
| 11 |
-
from app.rag.vision import generate_captions_for_chunks
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
settings = get_settings()
|
|
@@ -17,12 +13,15 @@ settings = get_settings()
|
|
| 17 |
_chroma_client = None
|
| 18 |
|
| 19 |
|
| 20 |
-
def get_chroma_client()
|
| 21 |
"""Get or create persistent ChromaDB client."""
|
| 22 |
global _chroma_client
|
| 23 |
|
| 24 |
if _chroma_client is None:
|
| 25 |
import os
|
|
|
|
|
|
|
|
|
|
| 26 |
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
|
| 27 |
|
| 28 |
_chroma_client = chromadb.PersistentClient(
|
|
@@ -58,11 +57,15 @@ def store_chunks(
|
|
| 58 |
|
| 59 |
# Generate captions for any extracted images before embedding
|
| 60 |
try:
|
|
|
|
|
|
|
| 61 |
generate_captions_for_chunks(chunks)
|
| 62 |
except Exception as e:
|
| 63 |
logger.warning(f"Could not generate image captions: {e}")
|
| 64 |
|
| 65 |
client = get_chroma_client()
|
|
|
|
|
|
|
| 66 |
embedding_model = get_embedding_model()
|
| 67 |
|
| 68 |
collection_name = get_collection_name(user_id)
|
|
|
|
| 4 |
"""
|
| 5 |
import logging
|
| 6 |
from typing import List, Dict, Any, Optional
|
|
|
|
|
|
|
| 7 |
from app.config import get_settings
|
|
|
|
|
|
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
settings = get_settings()
|
|
|
|
| 13 |
_chroma_client = None
|
| 14 |
|
| 15 |
|
| 16 |
+
def get_chroma_client():
|
| 17 |
"""Get or create persistent ChromaDB client."""
|
| 18 |
global _chroma_client
|
| 19 |
|
| 20 |
if _chroma_client is None:
|
| 21 |
import os
|
| 22 |
+
import chromadb
|
| 23 |
+
from chromadb.config import Settings as ChromaSettings
|
| 24 |
+
|
| 25 |
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
|
| 26 |
|
| 27 |
_chroma_client = chromadb.PersistentClient(
|
|
|
|
| 57 |
|
| 58 |
# Generate captions for any extracted images before embedding
|
| 59 |
try:
|
| 60 |
+
from app.rag.vision import generate_captions_for_chunks
|
| 61 |
+
|
| 62 |
generate_captions_for_chunks(chunks)
|
| 63 |
except Exception as e:
|
| 64 |
logger.warning(f"Could not generate image captions: {e}")
|
| 65 |
|
| 66 |
client = get_chroma_client()
|
| 67 |
+
from app.rag.embeddings import get_embedding_model
|
| 68 |
+
|
| 69 |
embedding_model = get_embedding_model()
|
| 70 |
|
| 71 |
collection_name = get_collection_name(user_id)
|
backend/app/routes/admin.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Admin-only operational statistics routes.
|
| 3 |
+
"""
|
| 4 |
+
import shutil
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends
|
| 8 |
+
from sqlalchemy import func
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from app.auth import get_admin_user
|
| 12 |
+
from app.config import get_settings
|
| 13 |
+
from app.database import get_db
|
| 14 |
+
from app.metrics import get_query_metrics
|
| 15 |
+
from app.models import Document, User
|
| 16 |
+
from app.schemas import AdminStatsResponse, DiskUsageResponse
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/admin", tags=["Admin"])
|
| 19 |
+
settings = get_settings()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _directory_size(path: Path) -> int:
|
| 23 |
+
if not path.exists():
|
| 24 |
+
return 0
|
| 25 |
+
|
| 26 |
+
total = 0
|
| 27 |
+
for item in path.rglob("*"):
|
| 28 |
+
if item.is_file():
|
| 29 |
+
try:
|
| 30 |
+
total += item.stat().st_size
|
| 31 |
+
except OSError:
|
| 32 |
+
continue
|
| 33 |
+
return total
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/stats", response_model=AdminStatsResponse)
|
| 37 |
+
def get_admin_stats(
|
| 38 |
+
_admin: User = Depends(get_admin_user),
|
| 39 |
+
db: Session = Depends(get_db),
|
| 40 |
+
):
|
| 41 |
+
"""Return aggregate system statistics for administrators."""
|
| 42 |
+
upload_dir = Path(settings.UPLOAD_DIR).resolve()
|
| 43 |
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
|
| 45 |
+
disk_usage = shutil.disk_usage(upload_dir)
|
| 46 |
+
used_percent = (
|
| 47 |
+
round((disk_usage.used / disk_usage.total) * 100, 2)
|
| 48 |
+
if disk_usage.total
|
| 49 |
+
else 0.0
|
| 50 |
+
)
|
| 51 |
+
query_metrics = get_query_metrics()
|
| 52 |
+
|
| 53 |
+
total_pdfs_uploaded = (
|
| 54 |
+
db.query(Document)
|
| 55 |
+
.filter(func.lower(Document.original_name).like("%.pdf"))
|
| 56 |
+
.count()
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
return AdminStatsResponse(
|
| 60 |
+
total_users=db.query(User).count(),
|
| 61 |
+
total_pdfs_uploaded=total_pdfs_uploaded,
|
| 62 |
+
average_query_response_time_ms=float(
|
| 63 |
+
query_metrics["average_query_response_time_ms"]
|
| 64 |
+
),
|
| 65 |
+
query_count=int(query_metrics["query_count"]),
|
| 66 |
+
disk_space_usage=DiskUsageResponse(
|
| 67 |
+
total_bytes=disk_usage.total,
|
| 68 |
+
used_bytes=disk_usage.used,
|
| 69 |
+
free_bytes=disk_usage.free,
|
| 70 |
+
usage_percent=used_percent,
|
| 71 |
+
upload_dir_bytes=_directory_size(upload_dir),
|
| 72 |
+
),
|
| 73 |
+
)
|
backend/app/routes/chat.py
CHANGED
|
@@ -3,6 +3,7 @@ Chat routes — ask questions with RAG, stream responses via SSE, manage history
|
|
| 3 |
"""
|
| 4 |
import html
|
| 5 |
import json
|
|
|
|
| 6 |
from datetime import datetime
|
| 7 |
from io import BytesIO
|
| 8 |
import logging
|
|
@@ -16,8 +17,11 @@ from reportlab.lib.units import inch
|
|
| 16 |
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
| 17 |
from sqlalchemy.orm import Session
|
| 18 |
|
|
|
|
| 19 |
from app.database import get_db
|
|
|
|
| 20 |
from app.models import User, ChatMessage, Document, SharedMessage
|
|
|
|
| 21 |
from app.schemas import (
|
| 22 |
ChatRequest,
|
| 23 |
ChatResponse,
|
|
@@ -27,9 +31,6 @@ from app.schemas import (
|
|
| 27 |
ShareLinkResponse,
|
| 28 |
SourceChunk,
|
| 29 |
)
|
| 30 |
-
from app.auth import get_current_user
|
| 31 |
-
from app.rag.agent import generate_answer, generate_answer_stream
|
| 32 |
-
from app.rate_limit import limiter
|
| 33 |
|
| 34 |
logger = logging.getLogger(__name__)
|
| 35 |
|
|
@@ -41,7 +42,6 @@ def get_shared_answer(
|
|
| 41 |
message_id: str,
|
| 42 |
db: Session = Depends(get_db),
|
| 43 |
):
|
| 44 |
-
"""Fetch a single assistant answer for public sharing."""
|
| 45 |
message = db.query(ChatMessage).filter(
|
| 46 |
ChatMessage.id == message_id,
|
| 47 |
ChatMessage.role == "assistant",
|
|
@@ -59,7 +59,6 @@ def create_share_link(
|
|
| 59 |
user: User = Depends(get_current_user),
|
| 60 |
db: Session = Depends(get_db),
|
| 61 |
):
|
| 62 |
-
"""Create a public share URL for a user's assistant answer."""
|
| 63 |
message = db.query(ChatMessage).filter(
|
| 64 |
ChatMessage.id == message_id,
|
| 65 |
ChatMessage.user_id == user.id,
|
|
@@ -83,6 +82,18 @@ def create_share_link(
|
|
| 83 |
)
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
@router.post("/ask", response_model=ChatResponse)
|
| 87 |
@limiter.limit("10/minute")
|
| 88 |
def ask_question(
|
|
@@ -118,38 +129,41 @@ def ask_question(
|
|
| 118 |
HTTPException: 400 if the document exists but its status is not
|
| 119 |
"ready" (e.g., still processing or failed).
|
| 120 |
"""
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
| 153 |
|
| 154 |
|
| 155 |
@router.post("/ask/stream")
|
|
@@ -211,6 +225,8 @@ def ask_question_stream(
|
|
| 211 |
detail=f"Document is still {doc.status}. Please wait for processing to complete.",
|
| 212 |
)
|
| 213 |
|
|
|
|
|
|
|
| 214 |
# Save user message immediately
|
| 215 |
_save_message(db, user.id, payload.document_id, "user", payload.question)
|
| 216 |
|
|
@@ -219,31 +235,34 @@ def ask_question_stream(
|
|
| 219 |
full_answer = ""
|
| 220 |
sources = []
|
| 221 |
|
| 222 |
-
for chunk in generate_answer_stream(
|
| 223 |
-
question=payload.question,
|
| 224 |
-
user_id=user.id,
|
| 225 |
-
document_id=payload.document_id,
|
| 226 |
-
):
|
| 227 |
-
yield chunk
|
| 228 |
-
|
| 229 |
-
# Parse to accumulate full answer for history
|
| 230 |
-
try:
|
| 231 |
-
if chunk.startswith("data: "):
|
| 232 |
-
data = json.loads(chunk[6:].strip())
|
| 233 |
-
if data.get("type") == "token":
|
| 234 |
-
full_answer += data.get("data", "")
|
| 235 |
-
elif data.get("type") == "sources":
|
| 236 |
-
sources = data.get("data", [])
|
| 237 |
-
except Exception:
|
| 238 |
-
pass
|
| 239 |
-
|
| 240 |
-
# Save assistant response to history
|
| 241 |
-
from app.database import SessionLocal
|
| 242 |
-
save_db = SessionLocal()
|
| 243 |
try:
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
finally:
|
| 246 |
-
|
| 247 |
|
| 248 |
return StreamingResponse(
|
| 249 |
event_stream(),
|
|
|
|
| 3 |
"""
|
| 4 |
import html
|
| 5 |
import json
|
| 6 |
+
import time
|
| 7 |
from datetime import datetime
|
| 8 |
from io import BytesIO
|
| 9 |
import logging
|
|
|
|
| 17 |
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
| 18 |
from sqlalchemy.orm import Session
|
| 19 |
|
| 20 |
+
from app.auth import get_current_user
|
| 21 |
from app.database import get_db
|
| 22 |
+
from app.metrics import record_query_response_time
|
| 23 |
from app.models import User, ChatMessage, Document, SharedMessage
|
| 24 |
+
from app.rate_limit import limiter
|
| 25 |
from app.schemas import (
|
| 26 |
ChatRequest,
|
| 27 |
ChatResponse,
|
|
|
|
| 31 |
ShareLinkResponse,
|
| 32 |
SourceChunk,
|
| 33 |
)
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
logger = logging.getLogger(__name__)
|
| 36 |
|
|
|
|
| 42 |
message_id: str,
|
| 43 |
db: Session = Depends(get_db),
|
| 44 |
):
|
|
|
|
| 45 |
message = db.query(ChatMessage).filter(
|
| 46 |
ChatMessage.id == message_id,
|
| 47 |
ChatMessage.role == "assistant",
|
|
|
|
| 59 |
user: User = Depends(get_current_user),
|
| 60 |
db: Session = Depends(get_db),
|
| 61 |
):
|
|
|
|
| 62 |
message = db.query(ChatMessage).filter(
|
| 63 |
ChatMessage.id == message_id,
|
| 64 |
ChatMessage.user_id == user.id,
|
|
|
|
| 82 |
)
|
| 83 |
|
| 84 |
|
| 85 |
+
def generate_answer(question: str, user_id: str, document_id: Optional[str] = None):
|
| 86 |
+
from app.rag.agent import generate_answer as _generate_answer
|
| 87 |
+
|
| 88 |
+
return _generate_answer(question=question, user_id=user_id, document_id=document_id)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def generate_answer_stream(question: str, user_id: str, document_id: Optional[str] = None):
|
| 92 |
+
from app.rag.agent import generate_answer_stream as _generate_answer_stream
|
| 93 |
+
|
| 94 |
+
return _generate_answer_stream(question=question, user_id=user_id, document_id=document_id)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
@router.post("/ask", response_model=ChatResponse)
|
| 98 |
@limiter.limit("10/minute")
|
| 99 |
def ask_question(
|
|
|
|
| 129 |
HTTPException: 400 if the document exists but its status is not
|
| 130 |
"ready" (e.g., still processing or failed).
|
| 131 |
"""
|
| 132 |
+
started_at = time.perf_counter()
|
| 133 |
+
try:
|
| 134 |
+
# Validate document exists if specified
|
| 135 |
+
if payload.document_id:
|
| 136 |
+
doc = db.query(Document).filter(
|
| 137 |
+
Document.id == payload.document_id,
|
| 138 |
+
Document.user_id == user.id,
|
| 139 |
+
).first()
|
| 140 |
+
|
| 141 |
+
if not doc:
|
| 142 |
+
raise HTTPException(status_code=404, detail="Document not found")
|
| 143 |
+
|
| 144 |
+
if doc.status != "ready":
|
| 145 |
+
raise HTTPException(
|
| 146 |
+
status_code=400,
|
| 147 |
+
detail=f"Document is still {doc.status}. Please wait for processing to complete.",
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
result = generate_answer(
|
| 151 |
+
question=payload.question,
|
| 152 |
+
user_id=user.id,
|
| 153 |
+
document_id=payload.document_id,
|
| 154 |
+
)
|
| 155 |
|
| 156 |
+
# Save to chat history
|
| 157 |
+
_save_message(db, user.id, payload.document_id, "user", payload.question)
|
| 158 |
+
_save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"])
|
| 159 |
|
| 160 |
+
return ChatResponse(
|
| 161 |
+
answer=result["answer"],
|
| 162 |
+
sources=[SourceChunk(**s) for s in result["sources"]],
|
| 163 |
+
document_id=payload.document_id,
|
| 164 |
+
)
|
| 165 |
+
finally:
|
| 166 |
+
record_query_response_time(time.perf_counter() - started_at)
|
| 167 |
|
| 168 |
|
| 169 |
@router.post("/ask/stream")
|
|
|
|
| 225 |
detail=f"Document is still {doc.status}. Please wait for processing to complete.",
|
| 226 |
)
|
| 227 |
|
| 228 |
+
started_at = time.perf_counter()
|
| 229 |
+
|
| 230 |
# Save user message immediately
|
| 231 |
_save_message(db, user.id, payload.document_id, "user", payload.question)
|
| 232 |
|
|
|
|
| 235 |
full_answer = ""
|
| 236 |
sources = []
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
try:
|
| 239 |
+
for chunk in generate_answer_stream(
|
| 240 |
+
question=payload.question,
|
| 241 |
+
user_id=user.id,
|
| 242 |
+
document_id=payload.document_id,
|
| 243 |
+
):
|
| 244 |
+
yield chunk
|
| 245 |
+
|
| 246 |
+
# Parse to accumulate full answer for history
|
| 247 |
+
try:
|
| 248 |
+
if chunk.startswith("data: "):
|
| 249 |
+
data = json.loads(chunk[6:].strip())
|
| 250 |
+
if data.get("type") == "token":
|
| 251 |
+
full_answer += data.get("data", "")
|
| 252 |
+
elif data.get("type") == "sources":
|
| 253 |
+
sources = data.get("data", [])
|
| 254 |
+
except Exception:
|
| 255 |
+
pass
|
| 256 |
+
|
| 257 |
+
# Save assistant response to history
|
| 258 |
+
from app.database import SessionLocal
|
| 259 |
+
save_db = SessionLocal()
|
| 260 |
+
try:
|
| 261 |
+
_save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources)
|
| 262 |
+
finally:
|
| 263 |
+
save_db.close()
|
| 264 |
finally:
|
| 265 |
+
record_query_response_time(time.perf_counter() - started_at)
|
| 266 |
|
| 267 |
return StreamingResponse(
|
| 268 |
event_stream(),
|
backend/app/schemas.py
CHANGED
|
@@ -118,6 +118,24 @@ class DocumentListResponse(BaseModel):
|
|
| 118 |
pages: int
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
# ── Chat ─────────────────────────────────────────────
|
| 122 |
|
| 123 |
class ChatRequest(BaseModel):
|
|
|
|
| 118 |
pages: int
|
| 119 |
|
| 120 |
|
| 121 |
+
# Admin
|
| 122 |
+
|
| 123 |
+
class DiskUsageResponse(BaseModel):
|
| 124 |
+
total_bytes: int
|
| 125 |
+
used_bytes: int
|
| 126 |
+
free_bytes: int
|
| 127 |
+
usage_percent: float
|
| 128 |
+
upload_dir_bytes: int
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class AdminStatsResponse(BaseModel):
|
| 132 |
+
total_users: int
|
| 133 |
+
total_pdfs_uploaded: int
|
| 134 |
+
average_query_response_time_ms: float
|
| 135 |
+
query_count: int
|
| 136 |
+
disk_space_usage: DiskUsageResponse
|
| 137 |
+
|
| 138 |
+
|
| 139 |
# ── Chat ─────────────────────────────────────────────
|
| 140 |
|
| 141 |
class ChatRequest(BaseModel):
|
backend/tests/conftest.py
CHANGED
|
@@ -16,11 +16,12 @@ BACKEND_DIR = ROOT / "backend"
|
|
| 16 |
if str(BACKEND_DIR) not in sys.path:
|
| 17 |
sys.path.insert(0, str(BACKEND_DIR))
|
| 18 |
|
| 19 |
-
os.environ
|
| 20 |
-
os.environ
|
| 21 |
-
os.environ
|
| 22 |
-
os.environ
|
| 23 |
-
os.environ
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
fake_embeddings = types.ModuleType("app.rag.embeddings")
|
|
|
|
| 16 |
if str(BACKEND_DIR) not in sys.path:
|
| 17 |
sys.path.insert(0, str(BACKEND_DIR))
|
| 18 |
|
| 19 |
+
os.environ["SECRET_KEY"] = "test-secret-key-that-is-long-enough"
|
| 20 |
+
os.environ["DATABASE_URL"] = "sqlite:///./test_bootstrap.db"
|
| 21 |
+
os.environ["DEBUG"] = "false"
|
| 22 |
+
os.environ["HF_TOKEN"] = "test-hf-token"
|
| 23 |
+
os.environ["UPLOAD_DIR"] = str(ROOT / "backend" / "test_uploads")
|
| 24 |
+
os.environ["CHROMA_PERSIST_DIR"] = str(ROOT / "backend" / "test_chroma")
|
| 25 |
|
| 26 |
|
| 27 |
fake_embeddings = types.ModuleType("app.rag.embeddings")
|
backend/tests/test_admin.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.auth import create_access_token, hash_password
|
| 2 |
+
from app.metrics import record_query_response_time
|
| 3 |
+
from app.models import Document, User
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_admin_stats_requires_admin(client, auth_headers):
|
| 7 |
+
response = client.get("/api/v1/admin/stats", headers=auth_headers)
|
| 8 |
+
|
| 9 |
+
assert response.status_code == 403
|
| 10 |
+
assert response.json()["detail"] == "Admin access required"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_admin_stats_returns_aggregate_metrics(client, db_session):
|
| 14 |
+
admin = User(
|
| 15 |
+
username="admin",
|
| 16 |
+
email="admin@example.com",
|
| 17 |
+
hashed_password=hash_password("password123"),
|
| 18 |
+
is_admin=True,
|
| 19 |
+
)
|
| 20 |
+
regular = User(
|
| 21 |
+
username="regular",
|
| 22 |
+
email="regular@example.com",
|
| 23 |
+
hashed_password=hash_password("password123"),
|
| 24 |
+
)
|
| 25 |
+
db_session.add_all([admin, regular])
|
| 26 |
+
db_session.commit()
|
| 27 |
+
db_session.refresh(admin)
|
| 28 |
+
db_session.refresh(regular)
|
| 29 |
+
|
| 30 |
+
db_session.add_all(
|
| 31 |
+
[
|
| 32 |
+
Document(
|
| 33 |
+
user_id=regular.id,
|
| 34 |
+
filename="first.pdf",
|
| 35 |
+
original_name="first.pdf",
|
| 36 |
+
file_size=100,
|
| 37 |
+
status="ready",
|
| 38 |
+
),
|
| 39 |
+
Document(
|
| 40 |
+
user_id=regular.id,
|
| 41 |
+
filename="notes.txt",
|
| 42 |
+
original_name="notes.txt",
|
| 43 |
+
file_size=50,
|
| 44 |
+
status="ready",
|
| 45 |
+
),
|
| 46 |
+
]
|
| 47 |
+
)
|
| 48 |
+
db_session.commit()
|
| 49 |
+
|
| 50 |
+
record_query_response_time(0.25)
|
| 51 |
+
|
| 52 |
+
token = create_access_token(admin.id)
|
| 53 |
+
response = client.get(
|
| 54 |
+
"/api/v1/admin/stats",
|
| 55 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
assert response.status_code == 200
|
| 59 |
+
payload = response.json()
|
| 60 |
+
assert payload["total_users"] == 2
|
| 61 |
+
assert payload["total_pdfs_uploaded"] == 1
|
| 62 |
+
assert payload["average_query_response_time_ms"] > 0
|
| 63 |
+
assert payload["query_count"] >= 1
|
| 64 |
+
assert payload["disk_space_usage"]["total_bytes"] > 0
|
| 65 |
+
assert payload["disk_space_usage"]["usage_percent"] >= 0
|
frontend/package-lock.json
CHANGED
|
@@ -11,6 +11,8 @@
|
|
| 11 |
"@base-ui/react": "^1.4.1",
|
| 12 |
"class-variance-authority": "^0.7.1",
|
| 13 |
"clsx": "^2.1.1",
|
|
|
|
|
|
|
| 14 |
"lucide-react": "^1.8.0",
|
| 15 |
"next": "16.2.4",
|
| 16 |
"next-themes": "^0.4.6",
|
|
@@ -18,6 +20,7 @@
|
|
| 18 |
"react": "19.2.4",
|
| 19 |
"react-dom": "19.2.4",
|
| 20 |
"react-dropzone": "^15.0.0",
|
|
|
|
| 21 |
"react-markdown": "^10.1.0",
|
| 22 |
"react-pdf": "^10.4.1",
|
| 23 |
"rehype-highlight": "^7.0.2",
|
|
@@ -80,6 +83,7 @@
|
|
| 80 |
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
| 81 |
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 82 |
"license": "MIT",
|
|
|
|
| 83 |
"dependencies": {
|
| 84 |
"@babel/code-frame": "^7.29.0",
|
| 85 |
"@babel/generator": "^7.29.0",
|
|
@@ -673,6 +677,7 @@
|
|
| 673 |
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
| 674 |
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 675 |
"license": "MIT",
|
|
|
|
| 676 |
"engines": {
|
| 677 |
"node": ">=12"
|
| 678 |
},
|
|
@@ -2107,6 +2112,7 @@
|
|
| 2107 |
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
| 2108 |
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
| 2109 |
"license": "MIT",
|
|
|
|
| 2110 |
"engines": {
|
| 2111 |
"node": "^14.21.3 || >=16"
|
| 2112 |
},
|
|
@@ -2214,6 +2220,7 @@
|
|
| 2214 |
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
| 2215 |
"devOptional": true,
|
| 2216 |
"license": "Apache-2.0",
|
|
|
|
| 2217 |
"dependencies": {
|
| 2218 |
"playwright": "1.60.0"
|
| 2219 |
},
|
|
@@ -2682,6 +2689,7 @@
|
|
| 2682 |
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
| 2683 |
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
| 2684 |
"license": "MIT",
|
|
|
|
| 2685 |
"dependencies": {
|
| 2686 |
"undici-types": "~6.21.0"
|
| 2687 |
}
|
|
@@ -2691,6 +2699,7 @@
|
|
| 2691 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
| 2692 |
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
| 2693 |
"license": "MIT",
|
|
|
|
| 2694 |
"dependencies": {
|
| 2695 |
"csstype": "^3.2.2"
|
| 2696 |
}
|
|
@@ -2777,6 +2786,7 @@
|
|
| 2777 |
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
|
| 2778 |
"dev": true,
|
| 2779 |
"license": "MIT",
|
|
|
|
| 2780 |
"dependencies": {
|
| 2781 |
"@typescript-eslint/scope-manager": "8.59.0",
|
| 2782 |
"@typescript-eslint/types": "8.59.0",
|
|
@@ -3321,6 +3331,7 @@
|
|
| 3321 |
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
| 3322 |
"dev": true,
|
| 3323 |
"license": "MIT",
|
|
|
|
| 3324 |
"bin": {
|
| 3325 |
"acorn": "bin/acorn"
|
| 3326 |
},
|
|
@@ -3775,6 +3786,7 @@
|
|
| 3775 |
}
|
| 3776 |
],
|
| 3777 |
"license": "MIT",
|
|
|
|
| 3778 |
"dependencies": {
|
| 3779 |
"baseline-browser-mapping": "^2.10.12",
|
| 3780 |
"caniuse-lite": "^1.0.30001782",
|
|
@@ -4826,6 +4838,7 @@
|
|
| 4826 |
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
| 4827 |
"dev": true,
|
| 4828 |
"license": "MIT",
|
|
|
|
| 4829 |
"dependencies": {
|
| 4830 |
"@eslint-community/eslint-utils": "^4.8.0",
|
| 4831 |
"@eslint-community/regexpp": "^4.12.1",
|
|
@@ -5011,6 +5024,7 @@
|
|
| 5011 |
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
| 5012 |
"dev": true,
|
| 5013 |
"license": "MIT",
|
|
|
|
| 5014 |
"dependencies": {
|
| 5015 |
"@rtsao/scc": "^1.1.0",
|
| 5016 |
"array-includes": "^3.1.9",
|
|
@@ -5310,6 +5324,7 @@
|
|
| 5310 |
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
| 5311 |
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
| 5312 |
"license": "MIT",
|
|
|
|
| 5313 |
"dependencies": {
|
| 5314 |
"accepts": "^2.0.0",
|
| 5315 |
"body-parser": "^2.2.1",
|
|
@@ -5669,6 +5684,7 @@
|
|
| 5669 |
"version": "2.3.2",
|
| 5670 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 5671 |
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
|
|
| 5672 |
"hasInstallScript": true,
|
| 5673 |
"license": "MIT",
|
| 5674 |
"optional": true,
|
|
@@ -6133,10 +6149,20 @@
|
|
| 6133 |
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
| 6134 |
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
| 6135 |
"license": "MIT",
|
|
|
|
| 6136 |
"engines": {
|
| 6137 |
"node": ">=16.9.0"
|
| 6138 |
}
|
| 6139 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6140 |
"node_modules/html-url-attributes": {
|
| 6141 |
"version": "3.0.1",
|
| 6142 |
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
|
@@ -6189,6 +6215,44 @@
|
|
| 6189 |
"node": ">=18.18.0"
|
| 6190 |
}
|
| 6191 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6192 |
"node_modules/iconv-lite": {
|
| 6193 |
"version": "0.7.2",
|
| 6194 |
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
|
@@ -9527,6 +9591,7 @@
|
|
| 9527 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
| 9528 |
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
| 9529 |
"license": "MIT",
|
|
|
|
| 9530 |
"engines": {
|
| 9531 |
"node": ">=0.10.0"
|
| 9532 |
}
|
|
@@ -9536,6 +9601,7 @@
|
|
| 9536 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
| 9537 |
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
| 9538 |
"license": "MIT",
|
|
|
|
| 9539 |
"dependencies": {
|
| 9540 |
"scheduler": "^0.27.0"
|
| 9541 |
},
|
|
@@ -9560,6 +9626,33 @@
|
|
| 9560 |
"react": ">= 16.8 || 18.0.0"
|
| 9561 |
}
|
| 9562 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9563 |
"node_modules/react-is": {
|
| 9564 |
"version": "16.13.1",
|
| 9565 |
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
|
@@ -10833,6 +10926,7 @@
|
|
| 10833 |
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 10834 |
"dev": true,
|
| 10835 |
"license": "MIT",
|
|
|
|
| 10836 |
"engines": {
|
| 10837 |
"node": ">=12"
|
| 10838 |
},
|
|
@@ -11101,6 +11195,7 @@
|
|
| 11101 |
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
| 11102 |
"devOptional": true,
|
| 11103 |
"license": "Apache-2.0",
|
|
|
|
| 11104 |
"bin": {
|
| 11105 |
"tsc": "bin/tsc",
|
| 11106 |
"tsserver": "bin/tsserver"
|
|
@@ -11434,6 +11529,15 @@
|
|
| 11434 |
"url": "https://opencollective.com/unified"
|
| 11435 |
}
|
| 11436 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11437 |
"node_modules/warning": {
|
| 11438 |
"version": "4.0.3",
|
| 11439 |
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
|
@@ -11774,6 +11878,7 @@
|
|
| 11774 |
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
| 11775 |
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
| 11776 |
"license": "MIT",
|
|
|
|
| 11777 |
"funding": {
|
| 11778 |
"url": "https://github.com/sponsors/colinhacks"
|
| 11779 |
}
|
|
|
|
| 11 |
"@base-ui/react": "^1.4.1",
|
| 12 |
"class-variance-authority": "^0.7.1",
|
| 13 |
"clsx": "^2.1.1",
|
| 14 |
+
"i18next": "^26.3.0",
|
| 15 |
+
"i18next-browser-languagedetector": "^8.2.1",
|
| 16 |
"lucide-react": "^1.8.0",
|
| 17 |
"next": "16.2.4",
|
| 18 |
"next-themes": "^0.4.6",
|
|
|
|
| 20 |
"react": "19.2.4",
|
| 21 |
"react-dom": "19.2.4",
|
| 22 |
"react-dropzone": "^15.0.0",
|
| 23 |
+
"react-i18next": "^17.0.8",
|
| 24 |
"react-markdown": "^10.1.0",
|
| 25 |
"react-pdf": "^10.4.1",
|
| 26 |
"rehype-highlight": "^7.0.2",
|
|
|
|
| 83 |
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
| 84 |
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 85 |
"license": "MIT",
|
| 86 |
+
"peer": true,
|
| 87 |
"dependencies": {
|
| 88 |
"@babel/code-frame": "^7.29.0",
|
| 89 |
"@babel/generator": "^7.29.0",
|
|
|
|
| 677 |
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
| 678 |
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 679 |
"license": "MIT",
|
| 680 |
+
"peer": true,
|
| 681 |
"engines": {
|
| 682 |
"node": ">=12"
|
| 683 |
},
|
|
|
|
| 2112 |
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
| 2113 |
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
| 2114 |
"license": "MIT",
|
| 2115 |
+
"peer": true,
|
| 2116 |
"engines": {
|
| 2117 |
"node": "^14.21.3 || >=16"
|
| 2118 |
},
|
|
|
|
| 2220 |
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
| 2221 |
"devOptional": true,
|
| 2222 |
"license": "Apache-2.0",
|
| 2223 |
+
"peer": true,
|
| 2224 |
"dependencies": {
|
| 2225 |
"playwright": "1.60.0"
|
| 2226 |
},
|
|
|
|
| 2689 |
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
| 2690 |
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
| 2691 |
"license": "MIT",
|
| 2692 |
+
"peer": true,
|
| 2693 |
"dependencies": {
|
| 2694 |
"undici-types": "~6.21.0"
|
| 2695 |
}
|
|
|
|
| 2699 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
| 2700 |
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
| 2701 |
"license": "MIT",
|
| 2702 |
+
"peer": true,
|
| 2703 |
"dependencies": {
|
| 2704 |
"csstype": "^3.2.2"
|
| 2705 |
}
|
|
|
|
| 2786 |
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
|
| 2787 |
"dev": true,
|
| 2788 |
"license": "MIT",
|
| 2789 |
+
"peer": true,
|
| 2790 |
"dependencies": {
|
| 2791 |
"@typescript-eslint/scope-manager": "8.59.0",
|
| 2792 |
"@typescript-eslint/types": "8.59.0",
|
|
|
|
| 3331 |
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
| 3332 |
"dev": true,
|
| 3333 |
"license": "MIT",
|
| 3334 |
+
"peer": true,
|
| 3335 |
"bin": {
|
| 3336 |
"acorn": "bin/acorn"
|
| 3337 |
},
|
|
|
|
| 3786 |
}
|
| 3787 |
],
|
| 3788 |
"license": "MIT",
|
| 3789 |
+
"peer": true,
|
| 3790 |
"dependencies": {
|
| 3791 |
"baseline-browser-mapping": "^2.10.12",
|
| 3792 |
"caniuse-lite": "^1.0.30001782",
|
|
|
|
| 4838 |
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
| 4839 |
"dev": true,
|
| 4840 |
"license": "MIT",
|
| 4841 |
+
"peer": true,
|
| 4842 |
"dependencies": {
|
| 4843 |
"@eslint-community/eslint-utils": "^4.8.0",
|
| 4844 |
"@eslint-community/regexpp": "^4.12.1",
|
|
|
|
| 5024 |
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
| 5025 |
"dev": true,
|
| 5026 |
"license": "MIT",
|
| 5027 |
+
"peer": true,
|
| 5028 |
"dependencies": {
|
| 5029 |
"@rtsao/scc": "^1.1.0",
|
| 5030 |
"array-includes": "^3.1.9",
|
|
|
|
| 5324 |
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
| 5325 |
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
| 5326 |
"license": "MIT",
|
| 5327 |
+
"peer": true,
|
| 5328 |
"dependencies": {
|
| 5329 |
"accepts": "^2.0.0",
|
| 5330 |
"body-parser": "^2.2.1",
|
|
|
|
| 5684 |
"version": "2.3.2",
|
| 5685 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 5686 |
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
| 5687 |
+
"dev": true,
|
| 5688 |
"hasInstallScript": true,
|
| 5689 |
"license": "MIT",
|
| 5690 |
"optional": true,
|
|
|
|
| 6149 |
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
| 6150 |
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
| 6151 |
"license": "MIT",
|
| 6152 |
+
"peer": true,
|
| 6153 |
"engines": {
|
| 6154 |
"node": ">=16.9.0"
|
| 6155 |
}
|
| 6156 |
},
|
| 6157 |
+
"node_modules/html-parse-stringify": {
|
| 6158 |
+
"version": "3.0.1",
|
| 6159 |
+
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
| 6160 |
+
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
| 6161 |
+
"license": "MIT",
|
| 6162 |
+
"dependencies": {
|
| 6163 |
+
"void-elements": "3.1.0"
|
| 6164 |
+
}
|
| 6165 |
+
},
|
| 6166 |
"node_modules/html-url-attributes": {
|
| 6167 |
"version": "3.0.1",
|
| 6168 |
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
|
|
|
| 6215 |
"node": ">=18.18.0"
|
| 6216 |
}
|
| 6217 |
},
|
| 6218 |
+
"node_modules/i18next": {
|
| 6219 |
+
"version": "26.3.0",
|
| 6220 |
+
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
| 6221 |
+
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
| 6222 |
+
"funding": [
|
| 6223 |
+
{
|
| 6224 |
+
"type": "individual",
|
| 6225 |
+
"url": "https://www.locize.com/i18next"
|
| 6226 |
+
},
|
| 6227 |
+
{
|
| 6228 |
+
"type": "individual",
|
| 6229 |
+
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
| 6230 |
+
},
|
| 6231 |
+
{
|
| 6232 |
+
"type": "individual",
|
| 6233 |
+
"url": "https://www.locize.com"
|
| 6234 |
+
}
|
| 6235 |
+
],
|
| 6236 |
+
"license": "MIT",
|
| 6237 |
+
"peer": true,
|
| 6238 |
+
"peerDependencies": {
|
| 6239 |
+
"typescript": "^5 || ^6"
|
| 6240 |
+
},
|
| 6241 |
+
"peerDependenciesMeta": {
|
| 6242 |
+
"typescript": {
|
| 6243 |
+
"optional": true
|
| 6244 |
+
}
|
| 6245 |
+
}
|
| 6246 |
+
},
|
| 6247 |
+
"node_modules/i18next-browser-languagedetector": {
|
| 6248 |
+
"version": "8.2.1",
|
| 6249 |
+
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
| 6250 |
+
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
| 6251 |
+
"license": "MIT",
|
| 6252 |
+
"dependencies": {
|
| 6253 |
+
"@babel/runtime": "^7.23.2"
|
| 6254 |
+
}
|
| 6255 |
+
},
|
| 6256 |
"node_modules/iconv-lite": {
|
| 6257 |
"version": "0.7.2",
|
| 6258 |
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
|
|
|
| 9591 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
| 9592 |
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
| 9593 |
"license": "MIT",
|
| 9594 |
+
"peer": true,
|
| 9595 |
"engines": {
|
| 9596 |
"node": ">=0.10.0"
|
| 9597 |
}
|
|
|
|
| 9601 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
| 9602 |
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
| 9603 |
"license": "MIT",
|
| 9604 |
+
"peer": true,
|
| 9605 |
"dependencies": {
|
| 9606 |
"scheduler": "^0.27.0"
|
| 9607 |
},
|
|
|
|
| 9626 |
"react": ">= 16.8 || 18.0.0"
|
| 9627 |
}
|
| 9628 |
},
|
| 9629 |
+
"node_modules/react-i18next": {
|
| 9630 |
+
"version": "17.0.8",
|
| 9631 |
+
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
| 9632 |
+
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
| 9633 |
+
"license": "MIT",
|
| 9634 |
+
"dependencies": {
|
| 9635 |
+
"@babel/runtime": "^7.29.2",
|
| 9636 |
+
"html-parse-stringify": "^3.0.1",
|
| 9637 |
+
"use-sync-external-store": "^1.6.0"
|
| 9638 |
+
},
|
| 9639 |
+
"peerDependencies": {
|
| 9640 |
+
"i18next": ">= 26.2.0",
|
| 9641 |
+
"react": ">= 16.8.0",
|
| 9642 |
+
"typescript": "^5 || ^6"
|
| 9643 |
+
},
|
| 9644 |
+
"peerDependenciesMeta": {
|
| 9645 |
+
"react-dom": {
|
| 9646 |
+
"optional": true
|
| 9647 |
+
},
|
| 9648 |
+
"react-native": {
|
| 9649 |
+
"optional": true
|
| 9650 |
+
},
|
| 9651 |
+
"typescript": {
|
| 9652 |
+
"optional": true
|
| 9653 |
+
}
|
| 9654 |
+
}
|
| 9655 |
+
},
|
| 9656 |
"node_modules/react-is": {
|
| 9657 |
"version": "16.13.1",
|
| 9658 |
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
|
|
|
| 10926 |
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 10927 |
"dev": true,
|
| 10928 |
"license": "MIT",
|
| 10929 |
+
"peer": true,
|
| 10930 |
"engines": {
|
| 10931 |
"node": ">=12"
|
| 10932 |
},
|
|
|
|
| 11195 |
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
| 11196 |
"devOptional": true,
|
| 11197 |
"license": "Apache-2.0",
|
| 11198 |
+
"peer": true,
|
| 11199 |
"bin": {
|
| 11200 |
"tsc": "bin/tsc",
|
| 11201 |
"tsserver": "bin/tsserver"
|
|
|
|
| 11529 |
"url": "https://opencollective.com/unified"
|
| 11530 |
}
|
| 11531 |
},
|
| 11532 |
+
"node_modules/void-elements": {
|
| 11533 |
+
"version": "3.1.0",
|
| 11534 |
+
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
| 11535 |
+
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
| 11536 |
+
"license": "MIT",
|
| 11537 |
+
"engines": {
|
| 11538 |
+
"node": ">=0.10.0"
|
| 11539 |
+
}
|
| 11540 |
+
},
|
| 11541 |
"node_modules/warning": {
|
| 11542 |
"version": "4.0.3",
|
| 11543 |
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
|
|
|
| 11878 |
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
| 11879 |
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
| 11880 |
"license": "MIT",
|
| 11881 |
+
"peer": true,
|
| 11882 |
"funding": {
|
| 11883 |
"url": "https://github.com/sponsors/colinhacks"
|
| 11884 |
}
|
frontend/package.json
CHANGED
|
@@ -14,6 +14,8 @@
|
|
| 14 |
"@base-ui/react": "^1.4.1",
|
| 15 |
"class-variance-authority": "^0.7.1",
|
| 16 |
"clsx": "^2.1.1",
|
|
|
|
|
|
|
| 17 |
"lucide-react": "^1.8.0",
|
| 18 |
"next": "16.2.4",
|
| 19 |
"next-themes": "^0.4.6",
|
|
@@ -21,6 +23,7 @@
|
|
| 21 |
"react": "19.2.4",
|
| 22 |
"react-dom": "19.2.4",
|
| 23 |
"react-dropzone": "^15.0.0",
|
|
|
|
| 24 |
"react-markdown": "^10.1.0",
|
| 25 |
"react-pdf": "^10.4.1",
|
| 26 |
"rehype-highlight": "^7.0.2",
|
|
|
|
| 14 |
"@base-ui/react": "^1.4.1",
|
| 15 |
"class-variance-authority": "^0.7.1",
|
| 16 |
"clsx": "^2.1.1",
|
| 17 |
+
"i18next": "^26.3.0",
|
| 18 |
+
"i18next-browser-languagedetector": "^8.2.1",
|
| 19 |
"lucide-react": "^1.8.0",
|
| 20 |
"next": "16.2.4",
|
| 21 |
"next-themes": "^0.4.6",
|
|
|
|
| 23 |
"react": "19.2.4",
|
| 24 |
"react-dom": "19.2.4",
|
| 25 |
"react-dropzone": "^15.0.0",
|
| 26 |
+
"react-i18next": "^17.0.8",
|
| 27 |
"react-markdown": "^10.1.0",
|
| 28 |
"react-pdf": "^10.4.1",
|
| 29 |
"rehype-highlight": "^7.0.2",
|
frontend/src/app/admin/page.tsx
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
| 4 |
+
import { useRouter } from "next/navigation";
|
| 5 |
+
import {
|
| 6 |
+
ArrowLeft,
|
| 7 |
+
Clock3,
|
| 8 |
+
Database,
|
| 9 |
+
FileText,
|
| 10 |
+
HardDrive,
|
| 11 |
+
RefreshCw,
|
| 12 |
+
Users,
|
| 13 |
+
} from "lucide-react";
|
| 14 |
+
|
| 15 |
+
import { api, CONNECTION_ERROR_MESSAGE } from "@/lib/api";
|
| 16 |
+
import { useAuth } from "@/lib/auth";
|
| 17 |
+
import { Button } from "@/components/ui/button";
|
| 18 |
+
import {
|
| 19 |
+
Card,
|
| 20 |
+
CardContent,
|
| 21 |
+
CardDescription,
|
| 22 |
+
CardHeader,
|
| 23 |
+
CardTitle,
|
| 24 |
+
} from "@/components/ui/card";
|
| 25 |
+
import { Progress } from "@/components/ui/progress";
|
| 26 |
+
import { Skeleton } from "@/components/ui/skeleton";
|
| 27 |
+
|
| 28 |
+
interface AdminStats {
|
| 29 |
+
total_users: number;
|
| 30 |
+
total_pdfs_uploaded: number;
|
| 31 |
+
average_query_response_time_ms: number;
|
| 32 |
+
query_count: number;
|
| 33 |
+
disk_space_usage: {
|
| 34 |
+
total_bytes: number;
|
| 35 |
+
used_bytes: number;
|
| 36 |
+
free_bytes: number;
|
| 37 |
+
usage_percent: number;
|
| 38 |
+
upload_dir_bytes: number;
|
| 39 |
+
};
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const formatBytes = (bytes: number) => {
|
| 43 |
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
| 44 |
+
|
| 45 |
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
| 46 |
+
const index = Math.min(
|
| 47 |
+
Math.floor(Math.log(bytes) / Math.log(1024)),
|
| 48 |
+
units.length - 1
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const formatTime = (milliseconds: number) => {
|
| 55 |
+
if (milliseconds >= 1000) return `${(milliseconds / 1000).toFixed(2)} s`;
|
| 56 |
+
return `${Math.round(milliseconds)} ms`;
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
function MetricCard({
|
| 60 |
+
icon: Icon,
|
| 61 |
+
label,
|
| 62 |
+
value,
|
| 63 |
+
detail,
|
| 64 |
+
}: {
|
| 65 |
+
icon: typeof Users;
|
| 66 |
+
label: string;
|
| 67 |
+
value: string;
|
| 68 |
+
detail: string;
|
| 69 |
+
}) {
|
| 70 |
+
return (
|
| 71 |
+
<Card className="min-h-36">
|
| 72 |
+
<CardHeader className="grid-cols-[1fr_auto]">
|
| 73 |
+
<div>
|
| 74 |
+
<CardDescription>{label}</CardDescription>
|
| 75 |
+
<CardTitle className="mt-2 text-3xl tabular-nums">{value}</CardTitle>
|
| 76 |
+
</div>
|
| 77 |
+
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/15 text-primary">
|
| 78 |
+
<Icon className="h-4 w-4" />
|
| 79 |
+
</div>
|
| 80 |
+
</CardHeader>
|
| 81 |
+
<CardContent>
|
| 82 |
+
<p className="text-sm text-muted-foreground">{detail}</p>
|
| 83 |
+
</CardContent>
|
| 84 |
+
</Card>
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function AdminSkeleton() {
|
| 89 |
+
return (
|
| 90 |
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
| 91 |
+
{[1, 2, 3, 4].map((item) => (
|
| 92 |
+
<Card key={item} className="min-h-36">
|
| 93 |
+
<CardHeader>
|
| 94 |
+
<Skeleton className="h-4 w-28" />
|
| 95 |
+
<Skeleton className="h-8 w-20" />
|
| 96 |
+
</CardHeader>
|
| 97 |
+
<CardContent>
|
| 98 |
+
<Skeleton className="h-4 w-36" />
|
| 99 |
+
</CardContent>
|
| 100 |
+
</Card>
|
| 101 |
+
))}
|
| 102 |
+
</div>
|
| 103 |
+
);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export default function AdminPage() {
|
| 107 |
+
const { user, loading } = useAuth();
|
| 108 |
+
const router = useRouter();
|
| 109 |
+
const [stats, setStats] = useState<AdminStats | null>(null);
|
| 110 |
+
const [statsLoading, setStatsLoading] = useState(true);
|
| 111 |
+
const [error, setError] = useState("");
|
| 112 |
+
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
| 113 |
+
|
| 114 |
+
useEffect(() => {
|
| 115 |
+
if (loading) return;
|
| 116 |
+
if (!user) router.replace("/login");
|
| 117 |
+
else if (!user.is_admin) router.replace("/dashboard");
|
| 118 |
+
}, [loading, router, user]);
|
| 119 |
+
|
| 120 |
+
const loadStats = useCallback(async () => {
|
| 121 |
+
try {
|
| 122 |
+
setStatsLoading(true);
|
| 123 |
+
const data = await api.get<AdminStats>("/api/v1/admin/stats");
|
| 124 |
+
setStats(data);
|
| 125 |
+
setLastUpdated(new Date());
|
| 126 |
+
setError("");
|
| 127 |
+
} catch (err) {
|
| 128 |
+
const message =
|
| 129 |
+
err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
|
| 130 |
+
setError(message);
|
| 131 |
+
} finally {
|
| 132 |
+
setStatsLoading(false);
|
| 133 |
+
}
|
| 134 |
+
}, []);
|
| 135 |
+
|
| 136 |
+
useEffect(() => {
|
| 137 |
+
if (!user?.is_admin) return;
|
| 138 |
+
|
| 139 |
+
const initialLoad = window.setTimeout(() => void loadStats(), 0);
|
| 140 |
+
const interval = window.setInterval(() => void loadStats(), 10000);
|
| 141 |
+
|
| 142 |
+
return () => {
|
| 143 |
+
window.clearTimeout(initialLoad);
|
| 144 |
+
window.clearInterval(interval);
|
| 145 |
+
};
|
| 146 |
+
}, [loadStats, user?.is_admin]);
|
| 147 |
+
|
| 148 |
+
const diskDetail = useMemo(() => {
|
| 149 |
+
if (!stats) return "";
|
| 150 |
+
const disk = stats.disk_space_usage;
|
| 151 |
+
return `${formatBytes(disk.used_bytes)} used of ${formatBytes(disk.total_bytes)}`;
|
| 152 |
+
}, [stats]);
|
| 153 |
+
|
| 154 |
+
if (loading || !user || !user.is_admin) {
|
| 155 |
+
return (
|
| 156 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 157 |
+
<div className="animate-pulse-glow w-12 h-12 rounded-full bg-primary/20" />
|
| 158 |
+
</div>
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
return (
|
| 163 |
+
<div className="min-h-screen bg-background">
|
| 164 |
+
<header className="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-border/50 bg-card/50 px-4 backdrop-blur-md">
|
| 165 |
+
<div className="flex items-center gap-3">
|
| 166 |
+
<Button
|
| 167 |
+
variant="ghost"
|
| 168 |
+
size="icon"
|
| 169 |
+
onClick={() => router.push("/dashboard")}
|
| 170 |
+
title="Back to dashboard"
|
| 171 |
+
>
|
| 172 |
+
<ArrowLeft className="h-4 w-4" />
|
| 173 |
+
</Button>
|
| 174 |
+
<div className="flex items-center gap-2">
|
| 175 |
+
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/15">
|
| 176 |
+
<Database className="h-4 w-4 text-primary" />
|
| 177 |
+
</div>
|
| 178 |
+
<span className="text-sm font-semibold">Admin Metrics</span>
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<Button
|
| 183 |
+
variant="outline"
|
| 184 |
+
size="sm"
|
| 185 |
+
onClick={() => void loadStats()}
|
| 186 |
+
disabled={statsLoading}
|
| 187 |
+
>
|
| 188 |
+
<RefreshCw className={statsLoading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
| 189 |
+
Refresh
|
| 190 |
+
</Button>
|
| 191 |
+
</header>
|
| 192 |
+
|
| 193 |
+
<main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6">
|
| 194 |
+
<div className="flex flex-col gap-1">
|
| 195 |
+
<h1 className="text-2xl font-semibold tracking-normal">System overview</h1>
|
| 196 |
+
<p className="text-sm text-muted-foreground">
|
| 197 |
+
{lastUpdated
|
| 198 |
+
? `Last updated ${lastUpdated.toLocaleTimeString()}`
|
| 199 |
+
: "Waiting for live metrics"}
|
| 200 |
+
</p>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{error && (
|
| 204 |
+
<div
|
| 205 |
+
role="alert"
|
| 206 |
+
className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
| 207 |
+
>
|
| 208 |
+
{error}
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
|
| 212 |
+
{statsLoading && !stats ? (
|
| 213 |
+
<AdminSkeleton />
|
| 214 |
+
) : stats ? (
|
| 215 |
+
<>
|
| 216 |
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
| 217 |
+
<MetricCard
|
| 218 |
+
icon={Users}
|
| 219 |
+
label="Total users"
|
| 220 |
+
value={stats.total_users.toLocaleString()}
|
| 221 |
+
detail="Registered accounts"
|
| 222 |
+
/>
|
| 223 |
+
<MetricCard
|
| 224 |
+
icon={FileText}
|
| 225 |
+
label="PDFs uploaded"
|
| 226 |
+
value={stats.total_pdfs_uploaded.toLocaleString()}
|
| 227 |
+
detail="All uploaded PDF records"
|
| 228 |
+
/>
|
| 229 |
+
<MetricCard
|
| 230 |
+
icon={Clock3}
|
| 231 |
+
label="Avg response time"
|
| 232 |
+
value={formatTime(stats.average_query_response_time_ms)}
|
| 233 |
+
detail={`${stats.query_count.toLocaleString()} measured queries`}
|
| 234 |
+
/>
|
| 235 |
+
<MetricCard
|
| 236 |
+
icon={HardDrive}
|
| 237 |
+
label="Upload storage"
|
| 238 |
+
value={formatBytes(stats.disk_space_usage.upload_dir_bytes)}
|
| 239 |
+
detail="Files in the upload directory"
|
| 240 |
+
/>
|
| 241 |
+
</div>
|
| 242 |
+
|
| 243 |
+
<Card>
|
| 244 |
+
<CardHeader>
|
| 245 |
+
<CardTitle>Disk space usage</CardTitle>
|
| 246 |
+
<CardDescription>{diskDetail}</CardDescription>
|
| 247 |
+
</CardHeader>
|
| 248 |
+
<CardContent className="space-y-4">
|
| 249 |
+
<Progress value={stats.disk_space_usage.usage_percent} />
|
| 250 |
+
<div className="grid gap-3 text-sm sm:grid-cols-3">
|
| 251 |
+
<div>
|
| 252 |
+
<p className="text-muted-foreground">Used</p>
|
| 253 |
+
<p className="font-medium tabular-nums">
|
| 254 |
+
{formatBytes(stats.disk_space_usage.used_bytes)}
|
| 255 |
+
</p>
|
| 256 |
+
</div>
|
| 257 |
+
<div>
|
| 258 |
+
<p className="text-muted-foreground">Free</p>
|
| 259 |
+
<p className="font-medium tabular-nums">
|
| 260 |
+
{formatBytes(stats.disk_space_usage.free_bytes)}
|
| 261 |
+
</p>
|
| 262 |
+
</div>
|
| 263 |
+
<div>
|
| 264 |
+
<p className="text-muted-foreground">Usage</p>
|
| 265 |
+
<p className="font-medium tabular-nums">
|
| 266 |
+
{stats.disk_space_usage.usage_percent.toFixed(2)}%
|
| 267 |
+
</p>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
</CardContent>
|
| 271 |
+
</Card>
|
| 272 |
+
</>
|
| 273 |
+
) : null}
|
| 274 |
+
</main>
|
| 275 |
+
</div>
|
| 276 |
+
);
|
| 277 |
+
}
|
frontend/src/app/layout.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
|
| 3 |
import "./globals.css";
|
| 4 |
import { AuthProvider } from "@/lib/auth";
|
| 5 |
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
|
|
| 6 |
import { ThemeProvider } from "@/components/layout/ThemeProvider";
|
| 7 |
|
| 8 |
const inter = Inter({
|
|
@@ -33,12 +34,12 @@ export default function RootLayout({
|
|
| 33 |
disableTransitionOnChange
|
| 34 |
>
|
| 35 |
<AuthProvider>
|
| 36 |
-
<
|
| 37 |
-
{children}
|
| 38 |
-
</
|
| 39 |
</AuthProvider>
|
| 40 |
</ThemeProvider>
|
| 41 |
</body>
|
| 42 |
</html>
|
| 43 |
);
|
| 44 |
-
}
|
|
|
|
| 3 |
import "./globals.css";
|
| 4 |
import { AuthProvider } from "@/lib/auth";
|
| 5 |
import { TooltipProvider } from "@/components/ui/tooltip";
|
| 6 |
+
import I18nProvider from "@/components/providers/I18nProvider";
|
| 7 |
import { ThemeProvider } from "@/components/layout/ThemeProvider";
|
| 8 |
|
| 9 |
const inter = Inter({
|
|
|
|
| 34 |
disableTransitionOnChange
|
| 35 |
>
|
| 36 |
<AuthProvider>
|
| 37 |
+
<I18nProvider>
|
| 38 |
+
<TooltipProvider>{children}</TooltipProvider>
|
| 39 |
+
</I18nProvider>
|
| 40 |
</AuthProvider>
|
| 41 |
</ThemeProvider>
|
| 42 |
</body>
|
| 43 |
</html>
|
| 44 |
);
|
| 45 |
+
}
|
frontend/src/app/login/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import { useCallback, useState } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
|
|
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
import { Input } from "@/components/ui/input";
|
| 8 |
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
@@ -12,6 +13,7 @@ import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
|
| 12 |
|
| 13 |
export default function LoginPage() {
|
| 14 |
const { login } = useAuth();
|
|
|
|
| 15 |
const router = useRouter();
|
| 16 |
const [email, setEmail] = useState("");
|
| 17 |
const [password, setPassword] = useState("");
|
|
@@ -32,7 +34,7 @@ export default function LoginPage() {
|
|
| 32 |
await login(email, password);
|
| 33 |
router.replace("/dashboard");
|
| 34 |
} catch (err: unknown) {
|
| 35 |
-
const message = err instanceof Error ? err.message : "
|
| 36 |
setError(message);
|
| 37 |
} finally {
|
| 38 |
setLoading(false);
|
|
@@ -51,8 +53,8 @@ export default function LoginPage() {
|
|
| 51 |
<Brain className="w-6 h-6 text-primary" />
|
| 52 |
</div>
|
| 53 |
</div>
|
| 54 |
-
<CardTitle className="text-2xl font-bold">
|
| 55 |
-
<CardDescription>
|
| 56 |
</CardHeader>
|
| 57 |
|
| 58 |
<CardContent>
|
|
@@ -71,7 +73,7 @@ export default function LoginPage() {
|
|
| 71 |
)}
|
| 72 |
|
| 73 |
<div className="space-y-2">
|
| 74 |
-
<label className="text-sm font-medium">
|
| 75 |
<Input
|
| 76 |
id="login-email"
|
| 77 |
type="email"
|
|
@@ -84,7 +86,7 @@ export default function LoginPage() {
|
|
| 84 |
</div>
|
| 85 |
|
| 86 |
<div className="space-y-2">
|
| 87 |
-
<label className="text-sm font-medium">
|
| 88 |
<div className="relative">
|
| 89 |
<Input
|
| 90 |
id="login-password"
|
|
@@ -109,18 +111,18 @@ export default function LoginPage() {
|
|
| 109 |
{loading ? (
|
| 110 |
<span className="flex items-center gap-2">
|
| 111 |
<span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
| 112 |
-
|
| 113 |
</span>
|
| 114 |
) : (
|
| 115 |
-
"
|
| 116 |
)}
|
| 117 |
</Button>
|
| 118 |
</form>
|
| 119 |
|
| 120 |
<p className="text-center text-sm text-muted-foreground mt-6">
|
| 121 |
-
|
| 122 |
<Link href="/register" className="text-primary hover:underline font-medium">
|
| 123 |
-
|
| 124 |
</Link>
|
| 125 |
</p>
|
| 126 |
</CardContent>
|
|
|
|
| 3 |
import { useCallback, useState } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
+
import { useTranslation } from "react-i18next";
|
| 7 |
import { Button } from "@/components/ui/button";
|
| 8 |
import { Input } from "@/components/ui/input";
|
| 9 |
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
|
|
| 13 |
|
| 14 |
export default function LoginPage() {
|
| 15 |
const { login } = useAuth();
|
| 16 |
+
const { t } = useTranslation();
|
| 17 |
const router = useRouter();
|
| 18 |
const [email, setEmail] = useState("");
|
| 19 |
const [password, setPassword] = useState("");
|
|
|
|
| 34 |
await login(email, password);
|
| 35 |
router.replace("/dashboard");
|
| 36 |
} catch (err: unknown) {
|
| 37 |
+
const message = err instanceof Error ? err.message : t("login.fallbackError");
|
| 38 |
setError(message);
|
| 39 |
} finally {
|
| 40 |
setLoading(false);
|
|
|
|
| 53 |
<Brain className="w-6 h-6 text-primary" />
|
| 54 |
</div>
|
| 55 |
</div>
|
| 56 |
+
<CardTitle className="text-2xl font-bold">{t("login.title")}</CardTitle>
|
| 57 |
+
<CardDescription>{t("login.description")}</CardDescription>
|
| 58 |
</CardHeader>
|
| 59 |
|
| 60 |
<CardContent>
|
|
|
|
| 73 |
)}
|
| 74 |
|
| 75 |
<div className="space-y-2">
|
| 76 |
+
<label className="text-sm font-medium">{t("common.email")}</label>
|
| 77 |
<Input
|
| 78 |
id="login-email"
|
| 79 |
type="email"
|
|
|
|
| 86 |
</div>
|
| 87 |
|
| 88 |
<div className="space-y-2">
|
| 89 |
+
<label className="text-sm font-medium">{t("common.password")}</label>
|
| 90 |
<div className="relative">
|
| 91 |
<Input
|
| 92 |
id="login-password"
|
|
|
|
| 111 |
{loading ? (
|
| 112 |
<span className="flex items-center gap-2">
|
| 113 |
<span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
| 114 |
+
{t("login.submitting")}
|
| 115 |
</span>
|
| 116 |
) : (
|
| 117 |
+
t("login.submit")
|
| 118 |
)}
|
| 119 |
</Button>
|
| 120 |
</form>
|
| 121 |
|
| 122 |
<p className="text-center text-sm text-muted-foreground mt-6">
|
| 123 |
+
{t("login.noAccount")}{" "}
|
| 124 |
<Link href="/register" className="text-primary hover:underline font-medium">
|
| 125 |
+
{t("login.createOne")}
|
| 126 |
</Link>
|
| 127 |
</p>
|
| 128 |
</CardContent>
|
frontend/src/app/register/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import { useCallback, useState } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
|
|
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
import { Input } from "@/components/ui/input";
|
| 8 |
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
@@ -12,6 +13,7 @@ import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
|
|
| 12 |
|
| 13 |
export default function RegisterPage() {
|
| 14 |
const { register } = useAuth();
|
|
|
|
| 15 |
const router = useRouter();
|
| 16 |
const [username, setUsername] = useState("");
|
| 17 |
const [email, setEmail] = useState("");
|
|
@@ -33,7 +35,7 @@ export default function RegisterPage() {
|
|
| 33 |
await register(username, email, password);
|
| 34 |
router.replace("/dashboard");
|
| 35 |
} catch (err: unknown) {
|
| 36 |
-
const message = err instanceof Error ? err.message : "
|
| 37 |
setError(message);
|
| 38 |
} finally {
|
| 39 |
setLoading(false);
|
|
@@ -51,8 +53,8 @@ export default function RegisterPage() {
|
|
| 51 |
<Brain className="w-6 h-6 text-primary" />
|
| 52 |
</div>
|
| 53 |
</div>
|
| 54 |
-
<CardTitle className="text-2xl font-bold">
|
| 55 |
-
<CardDescription>
|
| 56 |
</CardHeader>
|
| 57 |
|
| 58 |
<CardContent>
|
|
@@ -71,7 +73,7 @@ export default function RegisterPage() {
|
|
| 71 |
)}
|
| 72 |
|
| 73 |
<div className="space-y-2">
|
| 74 |
-
<label className="text-sm font-medium">
|
| 75 |
<Input
|
| 76 |
id="reg-username"
|
| 77 |
type="text"
|
|
@@ -85,7 +87,7 @@ export default function RegisterPage() {
|
|
| 85 |
</div>
|
| 86 |
|
| 87 |
<div className="space-y-2">
|
| 88 |
-
<label className="text-sm font-medium">
|
| 89 |
<Input
|
| 90 |
id="reg-email"
|
| 91 |
type="email"
|
|
@@ -98,12 +100,12 @@ export default function RegisterPage() {
|
|
| 98 |
</div>
|
| 99 |
|
| 100 |
<div className="space-y-2">
|
| 101 |
-
<label className="text-sm font-medium">
|
| 102 |
<div className="relative">
|
| 103 |
<Input
|
| 104 |
id="reg-password"
|
| 105 |
type={showPw ? "text" : "password"}
|
| 106 |
-
placeholder="
|
| 107 |
value={password}
|
| 108 |
onChange={(e) => setPassword(e.target.value)}
|
| 109 |
required
|
|
@@ -124,18 +126,18 @@ export default function RegisterPage() {
|
|
| 124 |
{loading ? (
|
| 125 |
<span className="flex items-center gap-2">
|
| 126 |
<span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
| 127 |
-
|
| 128 |
</span>
|
| 129 |
) : (
|
| 130 |
-
"
|
| 131 |
)}
|
| 132 |
</Button>
|
| 133 |
</form>
|
| 134 |
|
| 135 |
<p className="text-center text-sm text-muted-foreground mt-6">
|
| 136 |
-
|
| 137 |
<Link href="/login" className="text-primary hover:underline font-medium">
|
| 138 |
-
|
| 139 |
</Link>
|
| 140 |
</p>
|
| 141 |
</CardContent>
|
|
|
|
| 3 |
import { useCallback, useState } from "react";
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { useAuth } from "@/lib/auth";
|
| 6 |
+
import { useTranslation } from "react-i18next";
|
| 7 |
import { Button } from "@/components/ui/button";
|
| 8 |
import { Input } from "@/components/ui/input";
|
| 9 |
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
|
|
| 13 |
|
| 14 |
export default function RegisterPage() {
|
| 15 |
const { register } = useAuth();
|
| 16 |
+
const { t } = useTranslation();
|
| 17 |
const router = useRouter();
|
| 18 |
const [username, setUsername] = useState("");
|
| 19 |
const [email, setEmail] = useState("");
|
|
|
|
| 35 |
await register(username, email, password);
|
| 36 |
router.replace("/dashboard");
|
| 37 |
} catch (err: unknown) {
|
| 38 |
+
const message = err instanceof Error ? err.message : t("register.fallbackError");
|
| 39 |
setError(message);
|
| 40 |
} finally {
|
| 41 |
setLoading(false);
|
|
|
|
| 53 |
<Brain className="w-6 h-6 text-primary" />
|
| 54 |
</div>
|
| 55 |
</div>
|
| 56 |
+
<CardTitle className="text-2xl font-bold">{t("register.title")}</CardTitle>
|
| 57 |
+
<CardDescription>{t("register.description")}</CardDescription>
|
| 58 |
</CardHeader>
|
| 59 |
|
| 60 |
<CardContent>
|
|
|
|
| 73 |
)}
|
| 74 |
|
| 75 |
<div className="space-y-2">
|
| 76 |
+
<label className="text-sm font-medium">{t("common.username")}</label>
|
| 77 |
<Input
|
| 78 |
id="reg-username"
|
| 79 |
type="text"
|
|
|
|
| 87 |
</div>
|
| 88 |
|
| 89 |
<div className="space-y-2">
|
| 90 |
+
<label className="text-sm font-medium">{t("common.email")}</label>
|
| 91 |
<Input
|
| 92 |
id="reg-email"
|
| 93 |
type="email"
|
|
|
|
| 100 |
</div>
|
| 101 |
|
| 102 |
<div className="space-y-2">
|
| 103 |
+
<label className="text-sm font-medium">{t("common.password")}</label>
|
| 104 |
<div className="relative">
|
| 105 |
<Input
|
| 106 |
id="reg-password"
|
| 107 |
type={showPw ? "text" : "password"}
|
| 108 |
+
placeholder={t("register.passwordPlaceholder")}
|
| 109 |
value={password}
|
| 110 |
onChange={(e) => setPassword(e.target.value)}
|
| 111 |
required
|
|
|
|
| 126 |
{loading ? (
|
| 127 |
<span className="flex items-center gap-2">
|
| 128 |
<span className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
| 129 |
+
{t("register.submitting")}
|
| 130 |
</span>
|
| 131 |
) : (
|
| 132 |
+
t("register.submit")
|
| 133 |
)}
|
| 134 |
</Button>
|
| 135 |
</form>
|
| 136 |
|
| 137 |
<p className="text-center text-sm text-muted-foreground mt-6">
|
| 138 |
+
{t("register.hasAccount")}{" "}
|
| 139 |
<Link href="/login" className="text-primary hover:underline font-medium">
|
| 140 |
+
{t("register.signIn")}
|
| 141 |
</Link>
|
| 142 |
</p>
|
| 143 |
</CardContent>
|
frontend/src/components/chat/ChatPanel.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState, useRef, useEffect } from "react";
|
|
|
|
| 4 |
import type { DocInfo } from "@/app/dashboard/page";
|
| 5 |
import { api, API_BASE } from "@/lib/api";
|
| 6 |
import { useChatStore, type ChatMsg, type SourceChunk } from "@/store/chat-store";
|
|
@@ -16,6 +17,7 @@ interface Props {
|
|
| 16 |
}
|
| 17 |
|
| 18 |
export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
|
|
| 19 |
const messages = useChatStore((state) => state.messages);
|
| 20 |
const input = useChatStore((state) => state.input);
|
| 21 |
const streaming = useChatStore((state) => state.streaming);
|
|
@@ -185,7 +187,9 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 185 |
m.id === assistantId
|
| 186 |
? {
|
| 187 |
...m,
|
| 188 |
-
content:
|
|
|
|
|
|
|
| 189 |
isStreaming: false,
|
| 190 |
}
|
| 191 |
: m
|
|
@@ -198,7 +202,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 198 |
};
|
| 199 |
|
| 200 |
const handleClear = async () => {
|
| 201 |
-
if (!activeDoc || !confirm("
|
| 202 |
try {
|
| 203 |
await api.delete(`/api/v1/chat/history/${activeDoc.id}`);
|
| 204 |
setMessages([]);
|
|
@@ -250,12 +254,12 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 250 |
<MessageSquare className="w-8 h-8 text-primary/60" />
|
| 251 |
</div>
|
| 252 |
<h3 className="text-lg font-semibold mb-1">
|
| 253 |
-
{activeDoc ? "
|
| 254 |
</h3>
|
| 255 |
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
| 256 |
{activeDoc
|
| 257 |
-
?
|
| 258 |
-
: "
|
| 259 |
</p>
|
| 260 |
</div>
|
| 261 |
) : (
|
|
@@ -293,8 +297,8 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 293 |
onKeyDown={handleKeyDown}
|
| 294 |
placeholder={
|
| 295 |
activeDoc
|
| 296 |
-
?
|
| 297 |
-
: "
|
| 298 |
}
|
| 299 |
disabled={streaming}
|
| 300 |
className="min-h-[44px] max-h-32 resize-none bg-background/50 border-border/50"
|
|
@@ -324,7 +328,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 324 |
size="icon"
|
| 325 |
onClick={() => setShowExportMenu((v) => !v)}
|
| 326 |
className="h-[44px] w-[44px] text-muted-foreground hover:text-primary"
|
| 327 |
-
title="
|
| 328 |
>
|
| 329 |
<Download className="w-4 h-4" />
|
| 330 |
</Button>
|
|
@@ -336,7 +340,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 336 |
className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
| 337 |
>
|
| 338 |
<span className="text-base">📝</span>
|
| 339 |
-
|
| 340 |
</button>
|
| 341 |
<button
|
| 342 |
id="export-txt-btn"
|
|
@@ -344,7 +348,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 344 |
className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
| 345 |
>
|
| 346 |
<span className="text-base">📄</span>
|
| 347 |
-
|
| 348 |
</button>
|
| 349 |
<button
|
| 350 |
id="export-pdf-btn"
|
|
@@ -352,7 +356,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 352 |
className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
| 353 |
>
|
| 354 |
<span className="text-base">📕</span>
|
| 355 |
-
|
| 356 |
</button>
|
| 357 |
</div>
|
| 358 |
)}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState, useRef, useEffect } from "react";
|
| 4 |
+
import { useTranslation } from "react-i18next";
|
| 5 |
import type { DocInfo } from "@/app/dashboard/page";
|
| 6 |
import { api, API_BASE } from "@/lib/api";
|
| 7 |
import { useChatStore, type ChatMsg, type SourceChunk } from "@/store/chat-store";
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
| 20 |
+
const { t } = useTranslation();
|
| 21 |
const messages = useChatStore((state) => state.messages);
|
| 22 |
const input = useChatStore((state) => state.input);
|
| 23 |
const streaming = useChatStore((state) => state.streaming);
|
|
|
|
| 187 |
m.id === assistantId
|
| 188 |
? {
|
| 189 |
...m,
|
| 190 |
+
content: t("chat.fallbackError", {
|
| 191 |
+
message: err instanceof Error ? err.message : "Unknown error",
|
| 192 |
+
}),
|
| 193 |
isStreaming: false,
|
| 194 |
}
|
| 195 |
: m
|
|
|
|
| 202 |
};
|
| 203 |
|
| 204 |
const handleClear = async () => {
|
| 205 |
+
if (!activeDoc || !confirm(t("chat.clearConfirm"))) return;
|
| 206 |
try {
|
| 207 |
await api.delete(`/api/v1/chat/history/${activeDoc.id}`);
|
| 208 |
setMessages([]);
|
|
|
|
| 254 |
<MessageSquare className="w-8 h-8 text-primary/60" />
|
| 255 |
</div>
|
| 256 |
<h3 className="text-lg font-semibold mb-1">
|
| 257 |
+
{activeDoc ? t("chat.askAboutDocument") : t("chat.selectDocument")}
|
| 258 |
</h3>
|
| 259 |
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
| 260 |
{activeDoc
|
| 261 |
+
? t("chat.readyPrompt", { name: activeDoc.original_name })
|
| 262 |
+
: t("chat.uploadPrompt")}
|
| 263 |
</p>
|
| 264 |
</div>
|
| 265 |
) : (
|
|
|
|
| 297 |
onKeyDown={handleKeyDown}
|
| 298 |
placeholder={
|
| 299 |
activeDoc
|
| 300 |
+
? t("chat.askPlaceholder", { name: activeDoc.original_name })
|
| 301 |
+
: t("chat.selectPlaceholder")
|
| 302 |
}
|
| 303 |
disabled={streaming}
|
| 304 |
className="min-h-[44px] max-h-32 resize-none bg-background/50 border-border/50"
|
|
|
|
| 328 |
size="icon"
|
| 329 |
onClick={() => setShowExportMenu((v) => !v)}
|
| 330 |
className="h-[44px] w-[44px] text-muted-foreground hover:text-primary"
|
| 331 |
+
title={t("chat.exportTitle")}
|
| 332 |
>
|
| 333 |
<Download className="w-4 h-4" />
|
| 334 |
</Button>
|
|
|
|
| 340 |
className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
| 341 |
>
|
| 342 |
<span className="text-base">📝</span>
|
| 343 |
+
{t("chat.markdown")}
|
| 344 |
</button>
|
| 345 |
<button
|
| 346 |
id="export-txt-btn"
|
|
|
|
| 348 |
className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
| 349 |
>
|
| 350 |
<span className="text-base">📄</span>
|
| 351 |
+
{t("chat.plainText")}
|
| 352 |
</button>
|
| 353 |
<button
|
| 354 |
id="export-pdf-btn"
|
|
|
|
| 356 |
className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
| 357 |
>
|
| 358 |
<span className="text-base">📕</span>
|
| 359 |
+
{t("chat.pdf")}
|
| 360 |
</button>
|
| 361 |
</div>
|
| 362 |
)}
|
frontend/src/components/document/DocumentSidebar.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState, useCallback } from "react";
|
|
|
|
| 4 |
import type { DocInfo } from "@/app/dashboard/page";
|
| 5 |
import { api } from "@/lib/api";
|
| 6 |
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
@@ -20,6 +21,7 @@ interface Props {
|
|
| 20 |
}
|
| 21 |
|
| 22 |
export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc, onDocumentsChange }: Props) {
|
|
|
|
| 23 |
const [uploading, setUploading] = useState(false);
|
| 24 |
const [uploadProgress, setUploadProgress] = useState(0);
|
| 25 |
const [uploadError, setUploadError] = useState("");
|
|
@@ -43,7 +45,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
|
|
| 43 |
}
|
| 44 |
onDocumentsChange();
|
| 45 |
} catch (err) {
|
| 46 |
-
const message = err instanceof Error ? err.message : "
|
| 47 |
setUploadError(message);
|
| 48 |
} finally {
|
| 49 |
setUploading(false);
|
|
@@ -51,7 +53,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
|
|
| 51 |
}
|
| 52 |
})();
|
| 53 |
},
|
| 54 |
-
[onDocumentsChange]
|
| 55 |
);
|
| 56 |
|
| 57 |
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
@@ -67,7 +69,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
|
|
| 67 |
|
| 68 |
const handleDelete = async (docId: string, e: React.MouseEvent) => {
|
| 69 |
e.stopPropagation();
|
| 70 |
-
if (!confirm("
|
| 71 |
setDeleting(docId);
|
| 72 |
try {
|
| 73 |
await api.delete(`/api/v1/documents/${docId}`);
|
|
@@ -119,17 +121,17 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
|
|
| 119 |
{uploading ? (
|
| 120 |
<div className="space-y-2">
|
| 121 |
<Loader2 className="w-5 h-5 mx-auto animate-spin text-primary" />
|
| 122 |
-
<p className="text-xs text-muted-foreground">
|
| 123 |
<Progress value={uploadProgress} className="h-1" />
|
| 124 |
</div>
|
| 125 |
) : (
|
| 126 |
<>
|
| 127 |
<Upload className="w-5 h-5 mx-auto text-muted-foreground mb-2" />
|
| 128 |
<p className="text-xs text-muted-foreground">
|
| 129 |
-
{isDragActive ? "
|
| 130 |
</p>
|
| 131 |
<p className="text-[10px] text-muted-foreground/60 mt-1">
|
| 132 |
-
|
| 133 |
</p>
|
| 134 |
</>
|
| 135 |
)}
|
|
@@ -139,7 +141,7 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
|
|
| 139 |
{/* ── Documents List ──────────────────────────── */}
|
| 140 |
<div className="px-3 pt-3 pb-1">
|
| 141 |
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
| 142 |
-
|
| 143 |
</h3>
|
| 144 |
</div>
|
| 145 |
|
|
@@ -147,8 +149,8 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
|
|
| 147 |
{documents.length === 0 ? (
|
| 148 |
<div className="text-center py-12">
|
| 149 |
<FolderOpen className="w-8 h-8 mx-auto text-muted-foreground/40 mb-3" />
|
| 150 |
-
<p className="text-sm text-muted-foreground">
|
| 151 |
-
<p className="text-xs text-muted-foreground/60 mt-1">
|
| 152 |
</div>
|
| 153 |
) : (
|
| 154 |
<div className="space-y-1 pb-3">
|
|
@@ -179,22 +181,22 @@ export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc
|
|
| 179 |
<>
|
| 180 |
<span className="text-[10px] text-muted-foreground">•</span>
|
| 181 |
<span className="text-[10px] text-muted-foreground">
|
| 182 |
-
{doc.page_count
|
| 183 |
</span>
|
| 184 |
<span className="text-[10px] text-muted-foreground">•</span>
|
| 185 |
<span className="text-[10px] text-muted-foreground">
|
| 186 |
-
{doc.chunk_count
|
| 187 |
</span>
|
| 188 |
</>
|
| 189 |
)}
|
| 190 |
{doc.status === "processing" && (
|
| 191 |
<Badge variant="secondary" className="text-[9px] h-4 px-1.5">
|
| 192 |
-
|
| 193 |
</Badge>
|
| 194 |
)}
|
| 195 |
{doc.status === "failed" && (
|
| 196 |
<Badge variant="destructive" className="text-[9px] h-4 px-1.5">
|
| 197 |
-
|
| 198 |
</Badge>
|
| 199 |
)}
|
| 200 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState, useCallback } from "react";
|
| 4 |
+
import { useTranslation } from "react-i18next";
|
| 5 |
import type { DocInfo } from "@/app/dashboard/page";
|
| 6 |
import { api } from "@/lib/api";
|
| 7 |
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
export default function DocumentSidebar({ documents = [], activeDoc, onSelectDoc, onDocumentsChange }: Props) {
|
| 24 |
+
const { t } = useTranslation();
|
| 25 |
const [uploading, setUploading] = useState(false);
|
| 26 |
const [uploadProgress, setUploadProgress] = useState(0);
|
| 27 |
const [uploadError, setUploadError] = useState("");
|
|
|
|
| 45 |
}
|
| 46 |
onDocumentsChange();
|
| 47 |
} catch (err) {
|
| 48 |
+
const message = err instanceof Error ? err.message : t("documents.uploadFailed");
|
| 49 |
setUploadError(message);
|
| 50 |
} finally {
|
| 51 |
setUploading(false);
|
|
|
|
| 53 |
}
|
| 54 |
})();
|
| 55 |
},
|
| 56 |
+
[onDocumentsChange, t]
|
| 57 |
);
|
| 58 |
|
| 59 |
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
|
|
| 69 |
|
| 70 |
const handleDelete = async (docId: string, e: React.MouseEvent) => {
|
| 71 |
e.stopPropagation();
|
| 72 |
+
if (!confirm(t("documents.deleteConfirm"))) return;
|
| 73 |
setDeleting(docId);
|
| 74 |
try {
|
| 75 |
await api.delete(`/api/v1/documents/${docId}`);
|
|
|
|
| 121 |
{uploading ? (
|
| 122 |
<div className="space-y-2">
|
| 123 |
<Loader2 className="w-5 h-5 mx-auto animate-spin text-primary" />
|
| 124 |
+
<p className="text-xs text-muted-foreground">{t("documents.uploading")}</p>
|
| 125 |
<Progress value={uploadProgress} className="h-1" />
|
| 126 |
</div>
|
| 127 |
) : (
|
| 128 |
<>
|
| 129 |
<Upload className="w-5 h-5 mx-auto text-muted-foreground mb-2" />
|
| 130 |
<p className="text-xs text-muted-foreground">
|
| 131 |
+
{isDragActive ? t("documents.dropHere") : t("documents.dropOrClick")}
|
| 132 |
</p>
|
| 133 |
<p className="text-[10px] text-muted-foreground/60 mt-1">
|
| 134 |
+
{t("documents.uploadFormats")}
|
| 135 |
</p>
|
| 136 |
</>
|
| 137 |
)}
|
|
|
|
| 141 |
{/* ── Documents List ──────────────────────────── */}
|
| 142 |
<div className="px-3 pt-3 pb-1">
|
| 143 |
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
| 144 |
+
{t("documents.documentsTitle", { count: documents.length })}
|
| 145 |
</h3>
|
| 146 |
</div>
|
| 147 |
|
|
|
|
| 149 |
{documents.length === 0 ? (
|
| 150 |
<div className="text-center py-12">
|
| 151 |
<FolderOpen className="w-8 h-8 mx-auto text-muted-foreground/40 mb-3" />
|
| 152 |
+
<p className="text-sm text-muted-foreground">{t("documents.noDocuments")}</p>
|
| 153 |
+
<p className="text-xs text-muted-foreground/60 mt-1">{t("documents.getStarted")}</p>
|
| 154 |
</div>
|
| 155 |
) : (
|
| 156 |
<div className="space-y-1 pb-3">
|
|
|
|
| 181 |
<>
|
| 182 |
<span className="text-[10px] text-muted-foreground">•</span>
|
| 183 |
<span className="text-[10px] text-muted-foreground">
|
| 184 |
+
{t("documents.pagesShort", { count: doc.page_count })}
|
| 185 |
</span>
|
| 186 |
<span className="text-[10px] text-muted-foreground">•</span>
|
| 187 |
<span className="text-[10px] text-muted-foreground">
|
| 188 |
+
{t("documents.chunks", { count: doc.chunk_count })}
|
| 189 |
</span>
|
| 190 |
</>
|
| 191 |
)}
|
| 192 |
{doc.status === "processing" && (
|
| 193 |
<Badge variant="secondary" className="text-[9px] h-4 px-1.5">
|
| 194 |
+
{t("documents.processing")}
|
| 195 |
</Badge>
|
| 196 |
)}
|
| 197 |
{doc.status === "failed" && (
|
| 198 |
<Badge variant="destructive" className="text-[9px] h-4 px-1.5">
|
| 199 |
+
{t("documents.failed")}
|
| 200 |
</Badge>
|
| 201 |
)}
|
| 202 |
</div>
|
frontend/src/components/layout/Header.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useAuth } from "@/lib/auth";
|
|
|
|
| 4 |
import { useRouter } from "next/navigation";
|
| 5 |
import { Button } from "@/components/ui/button";
|
| 6 |
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
@@ -19,6 +20,7 @@ import {
|
|
| 19 |
PanelRightOpen,
|
| 20 |
LogOut,
|
| 21 |
Moon,
|
|
|
|
| 22 |
Sun,
|
| 23 |
} from "lucide-react";
|
| 24 |
import { useSyncExternalStore } from "react";
|
|
@@ -39,6 +41,7 @@ const getServerSnapshot = () => false;
|
|
| 39 |
|
| 40 |
export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onToggleViewer }: HeaderProps) {
|
| 41 |
const { user, logout } = useAuth();
|
|
|
|
| 42 |
const router = useRouter();
|
| 43 |
const { theme, setTheme } = useTheme();
|
| 44 |
const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); // ← replaces useState + useEffect
|
|
@@ -51,11 +54,24 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
|
|
| 51 |
router.replace("/login");
|
| 52 |
};
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
return (
|
| 55 |
<header className="h-14 flex items-center justify-between px-4 border-b border-border/50 bg-card/50 backdrop-blur-md flex-shrink-0 z-50">
|
| 56 |
{/* Left */}
|
| 57 |
<div className="flex items-center gap-3">
|
| 58 |
-
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleSidebar} title={sidebarOpen ? "
|
| 59 |
{sidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeftOpen className="w-4 h-4" />}
|
| 60 |
</Button>
|
| 61 |
|
|
@@ -63,22 +79,34 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
|
|
| 63 |
<div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center">
|
| 64 |
<Brain className="w-4 h-4 text-primary" />
|
| 65 |
</div>
|
| 66 |
-
<span className="font-semibold text-sm hidden sm:inline">
|
| 67 |
</div>
|
| 68 |
</div>
|
| 69 |
|
| 70 |
{/* Right */}
|
| 71 |
<div className="flex items-center gap-2">
|
| 72 |
-
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleViewer} title={viewerOpen ? "
|
| 73 |
{viewerOpen ? <PanelRightClose className="w-4 h-4" /> : <PanelRightOpen className="w-4 h-4" />}
|
| 74 |
</Button>
|
| 75 |
|
| 76 |
{mounted && (
|
| 77 |
-
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={toggleTheme} title={isDark ? "
|
| 78 |
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
| 79 |
</Button>
|
| 80 |
)}
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
<DropdownMenu>
|
| 83 |
<DropdownMenuTrigger
|
| 84 |
render={
|
|
@@ -99,11 +127,16 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
|
|
| 99 |
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
| 100 |
</div>
|
| 101 |
<DropdownMenuSeparator />
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
<DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
|
| 105 |
<LogOut className="w-4 h-4 mr-2" />
|
| 106 |
-
|
| 107 |
</DropdownMenuItem>
|
| 108 |
</DropdownMenuContent>
|
| 109 |
</DropdownMenu>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useAuth } from "@/lib/auth";
|
| 4 |
+
import { useTranslation } from "react-i18next";
|
| 5 |
import { useRouter } from "next/navigation";
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
|
|
| 20 |
PanelRightOpen,
|
| 21 |
LogOut,
|
| 22 |
Moon,
|
| 23 |
+
Shield,
|
| 24 |
Sun,
|
| 25 |
} from "lucide-react";
|
| 26 |
import { useSyncExternalStore } from "react";
|
|
|
|
| 41 |
|
| 42 |
export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onToggleViewer }: HeaderProps) {
|
| 43 |
const { user, logout } = useAuth();
|
| 44 |
+
const { t, i18n } = useTranslation();
|
| 45 |
const router = useRouter();
|
| 46 |
const { theme, setTheme } = useTheme();
|
| 47 |
const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); // ← replaces useState + useEffect
|
|
|
|
| 54 |
router.replace("/login");
|
| 55 |
};
|
| 56 |
|
| 57 |
+
const languageLabel = (language: string) => {
|
| 58 |
+
switch (language) {
|
| 59 |
+
case "hi":
|
| 60 |
+
return t("common.hindi");
|
| 61 |
+
case "es":
|
| 62 |
+
return t("common.spanish");
|
| 63 |
+
case "fr":
|
| 64 |
+
return t("common.french");
|
| 65 |
+
default:
|
| 66 |
+
return t("common.english");
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
return (
|
| 71 |
<header className="h-14 flex items-center justify-between px-4 border-b border-border/50 bg-card/50 backdrop-blur-md flex-shrink-0 z-50">
|
| 72 |
{/* Left */}
|
| 73 |
<div className="flex items-center gap-3">
|
| 74 |
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleSidebar} title={sidebarOpen ? t("header.closeSidebar") : t("header.openSidebar")}>
|
| 75 |
{sidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeftOpen className="w-4 h-4" />}
|
| 76 |
</Button>
|
| 77 |
|
|
|
|
| 79 |
<div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center">
|
| 80 |
<Brain className="w-4 h-4 text-primary" />
|
| 81 |
</div>
|
| 82 |
+
<span className="font-semibold text-sm hidden sm:inline">{t("common.appName")}</span>
|
| 83 |
</div>
|
| 84 |
</div>
|
| 85 |
|
| 86 |
{/* Right */}
|
| 87 |
<div className="flex items-center gap-2">
|
| 88 |
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleViewer} title={viewerOpen ? t("header.closeViewer") : t("header.openViewer")}>
|
| 89 |
{viewerOpen ? <PanelRightClose className="w-4 h-4" /> : <PanelRightOpen className="w-4 h-4" />}
|
| 90 |
</Button>
|
| 91 |
|
| 92 |
{mounted && (
|
| 93 |
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={toggleTheme} title={isDark ? t("header.lightMode") : t("header.darkMode")}>
|
| 94 |
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
| 95 |
</Button>
|
| 96 |
)}
|
| 97 |
|
| 98 |
+
<select
|
| 99 |
+
aria-label={t("common.language")}
|
| 100 |
+
value={i18n.resolvedLanguage || "en"}
|
| 101 |
+
onChange={(e) => void i18n.changeLanguage(e.target.value)}
|
| 102 |
+
className="h-8 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
| 103 |
+
>
|
| 104 |
+
<option value="en">{languageLabel("en")}</option>
|
| 105 |
+
<option value="hi">{languageLabel("hi")}</option>
|
| 106 |
+
<option value="es">{languageLabel("es")}</option>
|
| 107 |
+
<option value="fr">{languageLabel("fr")}</option>
|
| 108 |
+
</select>
|
| 109 |
+
|
| 110 |
<DropdownMenu>
|
| 111 |
<DropdownMenuTrigger
|
| 112 |
render={
|
|
|
|
| 127 |
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
| 128 |
</div>
|
| 129 |
<DropdownMenuSeparator />
|
| 130 |
+
{user?.is_admin && (
|
| 131 |
+
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push("/admin")}>
|
| 132 |
+
<Shield className="w-4 h-4 mr-2" />
|
| 133 |
+
Admin metrics
|
| 134 |
+
</DropdownMenuItem>
|
| 135 |
+
)}
|
| 136 |
+
{user?.is_admin && <DropdownMenuSeparator />}
|
| 137 |
<DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
|
| 138 |
<LogOut className="w-4 h-4 mr-2" />
|
| 139 |
+
{t("header.signOut")}
|
| 140 |
</DropdownMenuItem>
|
| 141 |
</DropdownMenuContent>
|
| 142 |
</DropdownMenu>
|
frontend/src/components/providers/I18nProvider.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect } from "react";
|
| 4 |
+
import { I18nextProvider } from "react-i18next";
|
| 5 |
+
import i18n from "@/lib/i18n";
|
| 6 |
+
|
| 7 |
+
export default function I18nProvider({ children }: { children: React.ReactNode }) {
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
document.documentElement.lang = i18n.resolvedLanguage || "en";
|
| 10 |
+
|
| 11 |
+
const handleLanguageChanged = (language: string) => {
|
| 12 |
+
document.documentElement.lang = language;
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
i18n.on("languageChanged", handleLanguageChanged);
|
| 16 |
+
return () => {
|
| 17 |
+
i18n.off("languageChanged", handleLanguageChanged);
|
| 18 |
+
};
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
| 22 |
+
}
|
frontend/src/lib/i18n.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import i18n from "i18next";
|
| 4 |
+
import LanguageDetector from "i18next-browser-languagedetector";
|
| 5 |
+
import { initReactI18next } from "react-i18next";
|
| 6 |
+
|
| 7 |
+
const resources = {
|
| 8 |
+
en: {
|
| 9 |
+
translation: {
|
| 10 |
+
common: {
|
| 11 |
+
appName: "Document AI Analyst",
|
| 12 |
+
language: "Language",
|
| 13 |
+
english: "English",
|
| 14 |
+
hindi: "Hindi",
|
| 15 |
+
spanish: "Spanish",
|
| 16 |
+
french: "French",
|
| 17 |
+
email: "Email",
|
| 18 |
+
password: "Password",
|
| 19 |
+
username: "Username",
|
| 20 |
+
},
|
| 21 |
+
header: {
|
| 22 |
+
closeSidebar: "Close sidebar",
|
| 23 |
+
openSidebar: "Open sidebar",
|
| 24 |
+
closeViewer: "Close viewer",
|
| 25 |
+
openViewer: "Open viewer",
|
| 26 |
+
lightMode: "Light mode",
|
| 27 |
+
darkMode: "Dark mode",
|
| 28 |
+
signOut: "Sign out",
|
| 29 |
+
},
|
| 30 |
+
login: {
|
| 31 |
+
title: "Welcome back",
|
| 32 |
+
description: "Sign in to your Document AI Analyst account",
|
| 33 |
+
fallbackError: "Login failed",
|
| 34 |
+
submit: "Sign In",
|
| 35 |
+
submitting: "Signing in...",
|
| 36 |
+
noAccount: "Don't have an account?",
|
| 37 |
+
createOne: "Create one",
|
| 38 |
+
},
|
| 39 |
+
register: {
|
| 40 |
+
title: "Create Account",
|
| 41 |
+
description: "Start analyzing documents with AI",
|
| 42 |
+
fallbackError: "Registration failed",
|
| 43 |
+
passwordPlaceholder: "Minimum 6 characters",
|
| 44 |
+
submit: "Create Account",
|
| 45 |
+
submitting: "Creating account...",
|
| 46 |
+
hasAccount: "Already have an account?",
|
| 47 |
+
signIn: "Sign in",
|
| 48 |
+
},
|
| 49 |
+
chat: {
|
| 50 |
+
askAboutDocument: "Ask about your document",
|
| 51 |
+
selectDocument: "Select a document",
|
| 52 |
+
readyPrompt: "\"{{name}}\" is ready. Ask any question and get cited answers.",
|
| 53 |
+
uploadPrompt: "Upload and select a document from the sidebar to start chatting.",
|
| 54 |
+
fallbackError: "Failed to get response: {{message}}",
|
| 55 |
+
clearConfirm: "Clear all chat history for this document?",
|
| 56 |
+
askPlaceholder: "Ask about \"{{name}}\"...",
|
| 57 |
+
selectPlaceholder: "Select a document first...",
|
| 58 |
+
exportTitle: "Export chat history",
|
| 59 |
+
markdown: "Markdown (.md)",
|
| 60 |
+
plainText: "Plain Text (.txt)",
|
| 61 |
+
pdf: "PDF (.pdf)",
|
| 62 |
+
},
|
| 63 |
+
documents: {
|
| 64 |
+
uploadFailed: "Upload failed",
|
| 65 |
+
deleteConfirm: "Delete this document and all its data?",
|
| 66 |
+
uploading: "Uploading...",
|
| 67 |
+
dropHere: "Drop files here",
|
| 68 |
+
dropOrClick: "Drop files or click to upload",
|
| 69 |
+
uploadFormats: "PDF, DOCX, TXT, MD (max 50MB)",
|
| 70 |
+
documentsTitle: "Documents ({{count}})",
|
| 71 |
+
noDocuments: "No documents yet",
|
| 72 |
+
getStarted: "Upload a file to get started",
|
| 73 |
+
pagesShort: "{{count}} pg",
|
| 74 |
+
chunks: "{{count}} chunks",
|
| 75 |
+
processing: "Processing",
|
| 76 |
+
failed: "Failed",
|
| 77 |
+
},
|
| 78 |
+
},
|
| 79 |
+
},
|
| 80 |
+
hi: {
|
| 81 |
+
translation: {
|
| 82 |
+
common: {
|
| 83 |
+
appName: "डॉक्यूमेंट एआई एनालिस्ट",
|
| 84 |
+
language: "भाषा",
|
| 85 |
+
english: "अंग्रेज़ी",
|
| 86 |
+
hindi: "हिंदी",
|
| 87 |
+
spanish: "स्पेनिश",
|
| 88 |
+
french: "फ़्रेंच",
|
| 89 |
+
email: "ईमेल",
|
| 90 |
+
password: "पासवर्ड",
|
| 91 |
+
username: "उपयोगकर्ता नाम",
|
| 92 |
+
},
|
| 93 |
+
header: {
|
| 94 |
+
closeSidebar: "साइडबार बंद करें",
|
| 95 |
+
openSidebar: "साइडबार खोलें",
|
| 96 |
+
closeViewer: "व्यूअर बंद करें",
|
| 97 |
+
openViewer: "व्यूअर खोलें",
|
| 98 |
+
lightMode: "लाइट मोड",
|
| 99 |
+
darkMode: "डार्क मोड",
|
| 100 |
+
signOut: "साइन आउट",
|
| 101 |
+
},
|
| 102 |
+
login: {
|
| 103 |
+
title: "वापसी पर स्वागत है",
|
| 104 |
+
description: "अपने डॉक्यूमेंट एआई एनालिस्ट खाते में साइन इन करें",
|
| 105 |
+
fallbackError: "लॉगिन विफल",
|
| 106 |
+
submit: "साइन इन करें",
|
| 107 |
+
submitting: "साइन इन हो रहा है...",
|
| 108 |
+
noAccount: "क्या आपका खाता नहीं है?",
|
| 109 |
+
createOne: "एक बनाएं",
|
| 110 |
+
},
|
| 111 |
+
register: {
|
| 112 |
+
title: "खाता बनाएं",
|
| 113 |
+
description: "एआई के साथ दस्तावेज़ों का विश्लेषण शुरू करें",
|
| 114 |
+
fallbackError: "पंजीकरण विफल",
|
| 115 |
+
passwordPlaceholder: "कम से कम 6 अक्षर",
|
| 116 |
+
submit: "खाता बनाएं",
|
| 117 |
+
submitting: "खाता बनाया जा रहा है...",
|
| 118 |
+
hasAccount: "क्या पहले से खाता है?",
|
| 119 |
+
signIn: "साइन इन करें",
|
| 120 |
+
},
|
| 121 |
+
chat: {
|
| 122 |
+
askAboutDocument: "अपने दस्तावेज़ के बारे में पूछें",
|
| 123 |
+
selectDocument: "एक दस्तावेज़ चुनें",
|
| 124 |
+
readyPrompt: "\"{{name}}\" तैयार है। कोई भी प्रश्न पूछें और स्रोत सहित उत्तर पाएं।",
|
| 125 |
+
uploadPrompt: "चैट शुरू करने के लिए साइडबार से फ़ाइल अपलोड और चुनें।",
|
| 126 |
+
fallbackError: "जवाब प्राप्त नहीं हुआ: {{message}}",
|
| 127 |
+
clearConfirm: "क्या इस दस्तावेज़ का पूरा चैट इतिहास साफ़ करें?",
|
| 128 |
+
askPlaceholder: "\"{{name}}\" के बारे में पूछें...",
|
| 129 |
+
selectPlaceholder: "पहले एक दस्तावेज़ चुनें...",
|
| 130 |
+
exportTitle: "चैट इतिहास निर्यात करें",
|
| 131 |
+
markdown: "मार्कडाउन (.md)",
|
| 132 |
+
plainText: "सादा पाठ (.txt)",
|
| 133 |
+
pdf: "पीडीएफ (.pdf)",
|
| 134 |
+
},
|
| 135 |
+
documents: {
|
| 136 |
+
uploadFailed: "अपलोड विफल",
|
| 137 |
+
deleteConfirm: "क्या इस दस्तावेज़ और उसके सभी डेटा को हटाएं?",
|
| 138 |
+
uploading: "अपलोड हो रहा है...",
|
| 139 |
+
dropHere: "फ़ाइलें यहाँ छोड़ें",
|
| 140 |
+
dropOrClick: "फ़ाइलें छोड़ें या अपलोड के लिए क्लिक करें",
|
| 141 |
+
uploadFormats: "PDF, DOCX, TXT, MD (अधिकतम 50MB)",
|
| 142 |
+
documentsTitle: "दस्तावेज़ ({{count}})",
|
| 143 |
+
noDocuments: "अभी तक कोई दस्तावेज़ नहीं",
|
| 144 |
+
getStarted: "शुरू करने के लिए फ़ाइल अपलोड करें",
|
| 145 |
+
pagesShort: "{{count}} पेज",
|
| 146 |
+
chunks: "{{count}} खंड",
|
| 147 |
+
processing: "प्रोसेस हो रहा है",
|
| 148 |
+
failed: "विफल",
|
| 149 |
+
},
|
| 150 |
+
},
|
| 151 |
+
},
|
| 152 |
+
es: {
|
| 153 |
+
translation: {
|
| 154 |
+
common: {
|
| 155 |
+
appName: "Analista IA de Documentos",
|
| 156 |
+
language: "Idioma",
|
| 157 |
+
english: "Inglés",
|
| 158 |
+
hindi: "Hindi",
|
| 159 |
+
spanish: "Español",
|
| 160 |
+
french: "Francés",
|
| 161 |
+
email: "Correo electrónico",
|
| 162 |
+
password: "Contraseña",
|
| 163 |
+
username: "Nombre de usuario",
|
| 164 |
+
},
|
| 165 |
+
header: {
|
| 166 |
+
closeSidebar: "Cerrar barra lateral",
|
| 167 |
+
openSidebar: "Abrir barra lateral",
|
| 168 |
+
closeViewer: "Cerrar visor",
|
| 169 |
+
openViewer: "Abrir visor",
|
| 170 |
+
lightMode: "Modo claro",
|
| 171 |
+
darkMode: "Modo oscuro",
|
| 172 |
+
signOut: "Cerrar sesión",
|
| 173 |
+
},
|
| 174 |
+
login: {
|
| 175 |
+
title: "Bienvenido de nuevo",
|
| 176 |
+
description: "Inicia sesión en tu cuenta de Analista IA de Documentos",
|
| 177 |
+
fallbackError: "Error al iniciar sesión",
|
| 178 |
+
submit: "Iniciar sesión",
|
| 179 |
+
submitting: "Iniciando sesión...",
|
| 180 |
+
noAccount: "¿No tienes una cuenta?",
|
| 181 |
+
createOne: "Crear una",
|
| 182 |
+
},
|
| 183 |
+
register: {
|
| 184 |
+
title: "Crear cuenta",
|
| 185 |
+
description: "Empieza a analizar documentos con IA",
|
| 186 |
+
fallbackError: "Error de registro",
|
| 187 |
+
passwordPlaceholder: "Mínimo 6 caracteres",
|
| 188 |
+
submit: "Crear cuenta",
|
| 189 |
+
submitting: "Creando cuenta...",
|
| 190 |
+
hasAccount: "¿Ya tienes una cuenta?",
|
| 191 |
+
signIn: "Inicia sesión",
|
| 192 |
+
},
|
| 193 |
+
chat: {
|
| 194 |
+
askAboutDocument: "Pregunta sobre tu documento",
|
| 195 |
+
selectDocument: "Selecciona un documento",
|
| 196 |
+
readyPrompt: "\"{{name}}\" está listo. Haz cualquier pregunta y obtén respuestas con citas.",
|
| 197 |
+
uploadPrompt: "Sube y selecciona un documento de la barra lateral para comenzar a chatear.",
|
| 198 |
+
fallbackError: "No se pudo obtener respuesta: {{message}}",
|
| 199 |
+
clearConfirm: "¿Borrar todo el historial de chat de este documento?",
|
| 200 |
+
askPlaceholder: "Pregunta sobre \"{{name}}\"...",
|
| 201 |
+
selectPlaceholder: "Primero selecciona un documento...",
|
| 202 |
+
exportTitle: "Exportar historial del chat",
|
| 203 |
+
markdown: "Markdown (.md)",
|
| 204 |
+
plainText: "Texto plano (.txt)",
|
| 205 |
+
pdf: "PDF (.pdf)",
|
| 206 |
+
},
|
| 207 |
+
documents: {
|
| 208 |
+
uploadFailed: "Error de carga",
|
| 209 |
+
deleteConfirm: "¿Eliminar este documento y todos sus datos?",
|
| 210 |
+
uploading: "Subiendo...",
|
| 211 |
+
dropHere: "Suelta archivos aquí",
|
| 212 |
+
dropOrClick: "Suelta archivos o haz clic para subir",
|
| 213 |
+
uploadFormats: "PDF, DOCX, TXT, MD (máx. 50MB)",
|
| 214 |
+
documentsTitle: "Documentos ({{count}})",
|
| 215 |
+
noDocuments: "Aún no hay documentos",
|
| 216 |
+
getStarted: "Sube un archivo para comenzar",
|
| 217 |
+
pagesShort: "{{count}} pág",
|
| 218 |
+
chunks: "{{count}} fragmentos",
|
| 219 |
+
processing: "Procesando",
|
| 220 |
+
failed: "Falló",
|
| 221 |
+
},
|
| 222 |
+
},
|
| 223 |
+
},
|
| 224 |
+
fr: {
|
| 225 |
+
translation: {
|
| 226 |
+
common: {
|
| 227 |
+
appName: "Analyste IA de Documents",
|
| 228 |
+
language: "Langue",
|
| 229 |
+
english: "Anglais",
|
| 230 |
+
hindi: "Hindi",
|
| 231 |
+
spanish: "Espagnol",
|
| 232 |
+
french: "Français",
|
| 233 |
+
email: "E-mail",
|
| 234 |
+
password: "Mot de passe",
|
| 235 |
+
username: "Nom d'utilisateur",
|
| 236 |
+
},
|
| 237 |
+
header: {
|
| 238 |
+
closeSidebar: "Fermer la barre latérale",
|
| 239 |
+
openSidebar: "Ouvrir la barre latérale",
|
| 240 |
+
closeViewer: "Fermer le lecteur",
|
| 241 |
+
openViewer: "Ouvrir le lecteur",
|
| 242 |
+
lightMode: "Mode clair",
|
| 243 |
+
darkMode: "Mode sombre",
|
| 244 |
+
signOut: "Se déconnecter",
|
| 245 |
+
},
|
| 246 |
+
login: {
|
| 247 |
+
title: "Bon retour",
|
| 248 |
+
description: "Connectez-vous à votre compte Analyste IA de Documents",
|
| 249 |
+
fallbackError: "Échec de la connexion",
|
| 250 |
+
submit: "Se connecter",
|
| 251 |
+
submitting: "Connexion en cours...",
|
| 252 |
+
noAccount: "Vous n'avez pas de compte ?",
|
| 253 |
+
createOne: "En créer un",
|
| 254 |
+
},
|
| 255 |
+
register: {
|
| 256 |
+
title: "Créer un compte",
|
| 257 |
+
description: "Commencez à analyser des documents avec l'IA",
|
| 258 |
+
fallbackError: "Échec de l'inscription",
|
| 259 |
+
passwordPlaceholder: "6 caractères minimum",
|
| 260 |
+
submit: "Créer un compte",
|
| 261 |
+
submitting: "Création du compte...",
|
| 262 |
+
hasAccount: "Vous avez déjà un compte ?",
|
| 263 |
+
signIn: "Se connecter",
|
| 264 |
+
},
|
| 265 |
+
chat: {
|
| 266 |
+
askAboutDocument: "Posez une question sur votre document",
|
| 267 |
+
selectDocument: "Sélectionnez un document",
|
| 268 |
+
readyPrompt: "\"{{name}}\" est prêt. Posez n'importe quelle question et obtenez des réponses sourcées.",
|
| 269 |
+
uploadPrompt: "Importez puis sélectionnez un document dans la barre latérale pour commencer à discuter.",
|
| 270 |
+
fallbackError: "Impossible d'obtenir une réponse : {{message}}",
|
| 271 |
+
clearConfirm: "Effacer tout l'historique de discussion de ce document ?",
|
| 272 |
+
askPlaceholder: "Posez une question sur \"{{name}}\"...",
|
| 273 |
+
selectPlaceholder: "Sélectionnez d'abord un document...",
|
| 274 |
+
exportTitle: "Exporter l'historique du chat",
|
| 275 |
+
markdown: "Markdown (.md)",
|
| 276 |
+
plainText: "Texte brut (.txt)",
|
| 277 |
+
pdf: "PDF (.pdf)",
|
| 278 |
+
},
|
| 279 |
+
documents: {
|
| 280 |
+
uploadFailed: "Échec de l'envoi",
|
| 281 |
+
deleteConfirm: "Supprimer ce document et toutes ses données ?",
|
| 282 |
+
uploading: "Envoi en cours...",
|
| 283 |
+
dropHere: "Déposez les fichiers ici",
|
| 284 |
+
dropOrClick: "Déposez les fichiers ou cliquez pour téléverser",
|
| 285 |
+
uploadFormats: "PDF, DOCX, TXT, MD (max 50 Mo)",
|
| 286 |
+
documentsTitle: "Documents ({{count}})",
|
| 287 |
+
noDocuments: "Aucun document pour le moment",
|
| 288 |
+
getStarted: "Importez un fichier pour commencer",
|
| 289 |
+
pagesShort: "{{count}} p",
|
| 290 |
+
chunks: "{{count}} segments",
|
| 291 |
+
processing: "Traitement",
|
| 292 |
+
failed: "Échec",
|
| 293 |
+
},
|
| 294 |
+
},
|
| 295 |
+
},
|
| 296 |
+
} as const;
|
| 297 |
+
|
| 298 |
+
if (!i18n.isInitialized) {
|
| 299 |
+
void i18n
|
| 300 |
+
.use(LanguageDetector)
|
| 301 |
+
.use(initReactI18next)
|
| 302 |
+
.init({
|
| 303 |
+
resources,
|
| 304 |
+
fallbackLng: "en",
|
| 305 |
+
supportedLngs: ["en", "hi", "es", "fr"],
|
| 306 |
+
interpolation: {
|
| 307 |
+
escapeValue: false,
|
| 308 |
+
},
|
| 309 |
+
detection: {
|
| 310 |
+
order: ["localStorage", "navigator"],
|
| 311 |
+
caches: ["localStorage"],
|
| 312 |
+
lookupLocalStorage: "i18nextLng",
|
| 313 |
+
},
|
| 314 |
+
react: {
|
| 315 |
+
useSuspense: false,
|
| 316 |
+
},
|
| 317 |
+
});
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
export default i18n;
|