Spaces:
Running
Running
Merge branch 'dev' into fix/mobile-responsiveness-130
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +18 -0
- .github/ISSUE_TEMPLATE/bug_report.yml +30 -49
- .github/ISSUE_TEMPLATE/feature_request.yml +22 -43
- .github/workflows/ci.yml +3 -1
- CODE_OF_CONDUCT.md +85 -0
- SECURITY.md +26 -0
- backend/app/auth.py +28 -1
- backend/app/config.py +10 -0
- backend/app/database.py +31 -2
- backend/app/main.py +2 -0
- backend/app/metrics.py +33 -0
- backend/app/models.py +28 -0
- backend/app/rag/agent.py +17 -0
- backend/app/rag/chunker.py +41 -0
- backend/app/rag/embeddings.py +19 -2
- backend/app/rag/retriever.py +12 -0
- backend/app/rag/tracing.py +102 -0
- backend/app/rag/vectorstore.py +17 -4
- backend/app/rag/vision.py +99 -0
- backend/app/routes/admin.py +73 -0
- backend/app/routes/auth.py +68 -1
- backend/app/routes/chat.py +148 -57
- backend/app/schemas.py +49 -0
- backend/requirements.txt +1 -0
- backend/tests/conftest.py +60 -6
- backend/tests/test_admin.py +65 -0
- backend/tests/test_auth.py +36 -0
- backend/tests/test_share.py +61 -0
- bots/discord/README.md +37 -0
- bots/discord/bot.py +68 -0
- bots/discord/requirements.txt +2 -0
- frontend/package-lock.json +116 -0
- frontend/package.json +4 -0
- frontend/src/app/admin/page.tsx +277 -0
- frontend/src/app/dashboard/page.tsx +13 -1
- frontend/src/app/globals.css +29 -0
- frontend/src/app/layout.tsx +15 -6
- frontend/src/app/login/page.tsx +11 -9
- frontend/src/app/register/page.tsx +13 -11
- frontend/src/app/share/page.tsx +188 -0
- frontend/src/components/auth/ApiKeyManager.tsx +158 -0
- frontend/src/components/chat/ChatPanel.tsx +15 -11
- frontend/src/components/chat/MessageBubble.tsx +72 -19
- frontend/src/components/chat/SourceCard.tsx +114 -71
- frontend/src/components/document/DocumentSidebar.tsx +15 -13
- frontend/src/components/layout/Header.tsx +17 -1
- frontend/src/components/layout/ThemeProvider.tsx +8 -0
- frontend/src/components/layout/ThemeToggle.tsx +31 -0
- frontend/src/components/providers/I18nProvider.tsx +22 -0
- frontend/src/lib/api.ts +23 -0
.env.example
CHANGED
|
@@ -91,6 +91,24 @@ HF_TOKEN=your_huggingface_token_here
|
|
| 91 |
# Optional — defaults to 1024
|
| 92 |
# LLM_MAX_NEW_TOKENS=1024
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
# ── Embeddings (Optional — defaults shown)──────────────────────────────────────────────
|
| 95 |
|
| 96 |
# SentenceTransformer model ID for generating document embeddings.
|
|
|
|
| 91 |
# Optional — defaults to 1024
|
| 92 |
# LLM_MAX_NEW_TOKENS=1024
|
| 93 |
|
| 94 |
+
# ── LangSmith Tracing (Optional) ────────────────────────
|
| 95 |
+
|
| 96 |
+
# Enable LangSmith tracing for the backend RAG pipeline.
|
| 97 |
+
# Optional — defaults to False
|
| 98 |
+
# LANGSMITH_TRACING=False
|
| 99 |
+
|
| 100 |
+
# LangSmith API key.
|
| 101 |
+
# Optional — only needed when LANGSMITH_TRACING=True
|
| 102 |
+
# LANGSMITH_API_KEY=
|
| 103 |
+
|
| 104 |
+
# LangSmith API endpoint.
|
| 105 |
+
# Optional — defaults to "https://api.smith.langchain.com"
|
| 106 |
+
# LANGSMITH_ENDPOINT=https://api.smith.langchain.com
|
| 107 |
+
|
| 108 |
+
# LangSmith project name used for traced runs.
|
| 109 |
+
# Optional — defaults to "pdf-assistant-rag"
|
| 110 |
+
# LANGSMITH_PROJECT=pdf-assistant-rag
|
| 111 |
+
|
| 112 |
# ── Embeddings (Optional — defaults shown)──────────────────────────────────────────────
|
| 113 |
|
| 114 |
# SentenceTransformer model ID for generating document embeddings.
|
.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
|
CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributor Covenant Code of Conduct
|
| 2 |
+
|
| 3 |
+
## Our Pledge
|
| 4 |
+
|
| 5 |
+
We as members, contributors, and leaders pledge to make participation in our
|
| 6 |
+
community a harassment-free experience for everyone, regardless of age, body
|
| 7 |
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
| 8 |
+
identity and expression, level of experience, education, socio-economic status,
|
| 9 |
+
nationality, personal appearance, race, religion, or sexual identity
|
| 10 |
+
and orientation.
|
| 11 |
+
|
| 12 |
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
| 13 |
+
diverse, inclusive, and healthy community.
|
| 14 |
+
|
| 15 |
+
## Our Standards
|
| 16 |
+
|
| 17 |
+
Examples of behavior that contributes to a positive environment for our
|
| 18 |
+
community include:
|
| 19 |
+
|
| 20 |
+
* Demonstrating empathy and kindness toward other people
|
| 21 |
+
* Being respectful of differing opinions, viewpoints, and experiences
|
| 22 |
+
* Giving and gracefully accepting constructive feedback
|
| 23 |
+
* Accepting responsibility and apologizing to those affected by our mistakes,
|
| 24 |
+
and learning from the experience
|
| 25 |
+
* Focusing on what is best not just for us as individuals, but for the
|
| 26 |
+
overall community
|
| 27 |
+
|
| 28 |
+
Examples of unacceptable behavior include:
|
| 29 |
+
|
| 30 |
+
* The use of sexualized language or imagery, and sexual attention or
|
| 31 |
+
advances of any kind
|
| 32 |
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
| 33 |
+
* Public or private harassment
|
| 34 |
+
* Publishing others' private information, such as a physical or email
|
| 35 |
+
address, without their explicit permission
|
| 36 |
+
* Other conduct which could reasonably be considered inappropriate in a
|
| 37 |
+
professional setting
|
| 38 |
+
|
| 39 |
+
## Enforcement Responsibilities
|
| 40 |
+
|
| 41 |
+
Community leaders are responsible for clarifying and enforcing our standards of
|
| 42 |
+
acceptable behavior and will take appropriate and fair corrective action in
|
| 43 |
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
| 44 |
+
or harmful.
|
| 45 |
+
|
| 46 |
+
Community leaders have the right and responsibility to remove, edit, or reject
|
| 47 |
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
| 48 |
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
| 49 |
+
decisions when appropriate.
|
| 50 |
+
|
| 51 |
+
## Scope
|
| 52 |
+
|
| 53 |
+
This Code of Conduct applies within all community spaces, and also applies when
|
| 54 |
+
an individual is officially representing the community in public spaces.
|
| 55 |
+
Examples of representing our community include using an official e-mail address,
|
| 56 |
+
posting via an official social media account, or acting as an appointed
|
| 57 |
+
representative at an online or offline event.
|
| 58 |
+
|
| 59 |
+
## Enforcement
|
| 60 |
+
|
| 61 |
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
| 62 |
+
reported to the community leaders responsible for enforcement.
|
| 63 |
+
All complaints will be reviewed and investigated promptly and fairly.
|
| 64 |
+
|
| 65 |
+
All community leaders are obligated to respect the privacy and security of the
|
| 66 |
+
reporter of any incident.
|
| 67 |
+
|
| 68 |
+
## Attribution
|
| 69 |
+
|
| 70 |
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
| 71 |
+
version 2.1, available at
|
| 72 |
+
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
| 73 |
+
|
| 74 |
+
Community Impact Guidelines were inspired by
|
| 75 |
+
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
| 76 |
+
|
| 77 |
+
For answers to common questions about this code of conduct, see the FAQ at
|
| 78 |
+
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
|
| 79 |
+
at [https://www.contributor-covenant.org/translations][translations].
|
| 80 |
+
|
| 81 |
+
[homepage]: https://www.contributor-covenant.org
|
| 82 |
+
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
| 83 |
+
[Mozilla CoC]: https://github.com/mozilla/diversity
|
| 84 |
+
[FAQ]: https://www.contributor-covenant.org/faq
|
| 85 |
+
[translations]: https://www.contributor-covenant.org/translations
|
SECURITY.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Policy
|
| 2 |
+
|
| 3 |
+
## Supported Versions
|
| 4 |
+
|
| 5 |
+
Currently, the following branches and versions of PDF-Assistant-RAG are supported with security updates.
|
| 6 |
+
|
| 7 |
+
| Version | Supported |
|
| 8 |
+
| ------- | ------------------ |
|
| 9 |
+
| `dev` | :white_check_mark: |
|
| 10 |
+
| `main` | :white_check_mark: |
|
| 11 |
+
| < 1.0 | :x: |
|
| 12 |
+
|
| 13 |
+
## Reporting a Vulnerability
|
| 14 |
+
|
| 15 |
+
We take the security of our users and their data very seriously. If you discover a security vulnerability in this project, please **do not** report it by creating a public GitHub issue.
|
| 16 |
+
|
| 17 |
+
Instead, please privately report it by emailing the repository owner directly.
|
| 18 |
+
|
| 19 |
+
When reporting a vulnerability, please include:
|
| 20 |
+
* A detailed description of the vulnerability.
|
| 21 |
+
* The steps required to reproduce the vulnerability.
|
| 22 |
+
* Any potential impact or risk to users.
|
| 23 |
+
|
| 24 |
+
We will acknowledge your email within 48 hours and work with you to understand and resolve the issue. We aim to fix critical security issues as fast as possible and will credit you in the release notes if you wish.
|
| 25 |
+
|
| 26 |
+
Thank you for helping keep this project secure!
|
backend/app/auth.py
CHANGED
|
@@ -67,12 +67,39 @@ def decode_token(token: str, token_type: str = "access") -> Optional[str]:
|
|
| 67 |
|
| 68 |
# ── FastAPI Dependencies ─────────────────────────────
|
| 69 |
|
|
|
|
|
|
|
| 70 |
def get_current_user(
|
| 71 |
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 72 |
db: Session = Depends(get_db),
|
| 73 |
) -> User:
|
| 74 |
-
"""Dependency: extract and validate user from JWT bearer token."""
|
| 75 |
token = credentials.credentials
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
user_id = decode_token(token)
|
| 77 |
|
| 78 |
if not user_id:
|
|
|
|
| 67 |
|
| 68 |
# ── FastAPI Dependencies ─────────────────────────────
|
| 69 |
|
| 70 |
+
import hashlib
|
| 71 |
+
|
| 72 |
def get_current_user(
|
| 73 |
credentials: HTTPAuthorizationCredentials = Depends(security),
|
| 74 |
db: Session = Depends(get_db),
|
| 75 |
) -> User:
|
| 76 |
+
"""Dependency: extract and validate user from JWT bearer token or API key."""
|
| 77 |
token = credentials.credentials
|
| 78 |
+
|
| 79 |
+
# Check if token is an API key
|
| 80 |
+
if token.startswith("rag_"):
|
| 81 |
+
hashed = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
| 82 |
+
from app.models import ApiKey
|
| 83 |
+
api_key = db.query(ApiKey).filter(ApiKey.hashed_key == hashed).first()
|
| 84 |
+
if not api_key:
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 87 |
+
detail="Invalid API key",
|
| 88 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
api_key.last_used = datetime.now(timezone.utc)
|
| 92 |
+
db.commit()
|
| 93 |
+
|
| 94 |
+
user = api_key.user
|
| 95 |
+
if not user:
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 98 |
+
detail="User not found for this API key",
|
| 99 |
+
)
|
| 100 |
+
return user
|
| 101 |
+
|
| 102 |
+
# Otherwise, process as JWT
|
| 103 |
user_id = decode_token(token)
|
| 104 |
|
| 105 |
if not user_id:
|
backend/app/config.py
CHANGED
|
@@ -56,8 +56,18 @@ class Settings(BaseSettings):
|
|
| 56 |
LLM_TEMPERATURE: float = 0.3
|
| 57 |
SUMMARY_MAX_TOKENS: int = 512
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# ── Reranker ─────────────────────────────────────────
|
| 60 |
RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
@property
|
|
|
|
| 56 |
LLM_TEMPERATURE: float = 0.3
|
| 57 |
SUMMARY_MAX_TOKENS: int = 512
|
| 58 |
|
| 59 |
+
# ── LangSmith Tracing (optional) ─────────────────────
|
| 60 |
+
LANGSMITH_TRACING: bool = False
|
| 61 |
+
LANGSMITH_API_KEY: str = ""
|
| 62 |
+
LANGSMITH_ENDPOINT: str = "https://api.smith.langchain.com"
|
| 63 |
+
LANGSMITH_PROJECT: str = "pdf-assistant-rag"
|
| 64 |
+
|
| 65 |
# ── Reranker ─────────────────────────────────────────
|
| 66 |
RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 67 |
+
# ── Vision / Image captioning ─────────────────────
|
| 68 |
+
VISION_PROVIDER: str | None = None # e.g. 'openai'
|
| 69 |
+
VISION_MODEL: str | None = None
|
| 70 |
+
OPENAI_API_KEY: str = ""
|
| 71 |
|
| 72 |
|
| 73 |
@property
|
backend/app/database.py
CHANGED
|
@@ -3,11 +3,13 @@ SQLAlchemy database setup with SQLite.
|
|
| 3 |
Uses synchronous SQLAlchemy for simplicity and compatibility.
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
-
|
|
|
|
| 7 |
from sqlalchemy.orm import sessionmaker, declarative_base
|
| 8 |
from app.config import get_settings
|
| 9 |
|
| 10 |
settings = get_settings()
|
|
|
|
| 11 |
|
| 12 |
# ── Ensure data directory exists ─────────────────────
|
| 13 |
db_path = settings.DATABASE_URL.replace("sqlite:///", "")
|
|
@@ -34,7 +36,34 @@ def get_db():
|
|
| 34 |
db.close()
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def init_db():
|
| 38 |
-
"""Create all tables on startup."""
|
| 39 |
from app import models # noqa: F401 — import to register models
|
| 40 |
Base.metadata.create_all(bind=engine)
|
|
|
|
|
|
| 3 |
Uses synchronous SQLAlchemy for simplicity and compatibility.
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
+
import logging
|
| 7 |
+
from sqlalchemy import create_engine, inspect, text
|
| 8 |
from sqlalchemy.orm import sessionmaker, declarative_base
|
| 9 |
from app.config import get_settings
|
| 10 |
|
| 11 |
settings = get_settings()
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
# ── Ensure data directory exists ─────────────────────
|
| 15 |
db_path = settings.DATABASE_URL.replace("sqlite:///", "")
|
|
|
|
| 36 |
db.close()
|
| 37 |
|
| 38 |
|
| 39 |
+
def _migrate_schema():
|
| 40 |
+
"""Apply schema migrations for existing databases (SQLite-compatible).
|
| 41 |
+
|
| 42 |
+
SQLAlchemy's ``create_all`` only creates new tables and does **not**
|
| 43 |
+
add missing columns to existing tables. This helper fills that gap
|
| 44 |
+
for non-destructive changes such as new nullable columns.
|
| 45 |
+
"""
|
| 46 |
+
inspector = inspect(engine)
|
| 47 |
+
existing_columns = {c["name"] for c in inspector.get_columns("users")}
|
| 48 |
+
|
| 49 |
+
migrations = [
|
| 50 |
+
("users", "hf_token", "ALTER TABLE users ADD COLUMN hf_token VARCHAR(255)"),
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
for table, column, ddl in migrations:
|
| 54 |
+
if column not in existing_columns:
|
| 55 |
+
try:
|
| 56 |
+
with engine.begin() as conn:
|
| 57 |
+
conn.execute(text(ddl))
|
| 58 |
+
logger.info("Migration: added column %s.%s", table, column)
|
| 59 |
+
except Exception:
|
| 60 |
+
logger.warning(
|
| 61 |
+
"Migration skipped (may already exist): %s.%s", table, column
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
def init_db():
|
| 66 |
+
"""Create all tables on startup and apply schema migrations."""
|
| 67 |
from app import models # noqa: F401 — import to register models
|
| 68 |
Base.metadata.create_all(bind=engine)
|
| 69 |
+
_migrate_schema()
|
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/models.py
CHANGED
|
@@ -22,10 +22,26 @@ class User(Base):
|
|
| 22 |
is_admin = Column(Boolean, default=False)
|
| 23 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 24 |
last_login = Column(DateTime, nullable=True, index=True)
|
|
|
|
| 25 |
|
| 26 |
# Relationships
|
| 27 |
documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
|
| 28 |
messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
class Document(Base):
|
|
@@ -62,3 +78,15 @@ class ChatMessage(Base):
|
|
| 62 |
# Relationships
|
| 63 |
user = relationship("User", back_populates="messages")
|
| 64 |
document = relationship("Document", back_populates="messages")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
is_admin = Column(Boolean, default=False)
|
| 23 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 24 |
last_login = Column(DateTime, nullable=True, index=True)
|
| 25 |
+
hf_token = Column(String(255), nullable=True)
|
| 26 |
|
| 27 |
# Relationships
|
| 28 |
documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
|
| 29 |
messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan")
|
| 30 |
+
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ApiKey(Base):
|
| 34 |
+
__tablename__ = "api_keys"
|
| 35 |
+
|
| 36 |
+
id = Column(String, primary_key=True, default=generate_uuid)
|
| 37 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
| 38 |
+
key_prefix = Column(String(10), nullable=False)
|
| 39 |
+
hashed_key = Column(String(255), nullable=False, unique=True, index=True)
|
| 40 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 41 |
+
last_used = Column(DateTime, nullable=True)
|
| 42 |
+
|
| 43 |
+
# Relationships
|
| 44 |
+
user = relationship("User", back_populates="api_keys")
|
| 45 |
|
| 46 |
|
| 47 |
class Document(Base):
|
|
|
|
| 78 |
# Relationships
|
| 79 |
user = relationship("User", back_populates="messages")
|
| 80 |
document = relationship("Document", back_populates="messages")
|
| 81 |
+
shared_message = relationship("SharedMessage", back_populates="message", uselist=False, cascade="all, delete-orphan")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class SharedMessage(Base):
|
| 85 |
+
__tablename__ = "shared_messages"
|
| 86 |
+
|
| 87 |
+
id = Column(String, primary_key=True, default=generate_uuid)
|
| 88 |
+
message_id = Column(String, ForeignKey("chat_messages.id"), nullable=False, unique=True, index=True)
|
| 89 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 90 |
+
|
| 91 |
+
# Relationships
|
| 92 |
+
message = relationship("ChatMessage", back_populates="shared_message")
|
backend/app/rag/agent.py
CHANGED
|
@@ -10,6 +10,7 @@ from huggingface_hub import InferenceClient
|
|
| 10 |
from app.config import get_settings
|
| 11 |
from app.rag.retriever import retrieve
|
| 12 |
from app.rag.prompts import SYSTEM_PROMPT, RAG_PROMPT_TEMPLATE, GREETING_PROMPT
|
|
|
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
settings = get_settings()
|
|
@@ -65,6 +66,14 @@ def _chat_messages(system: str, user_content: str) -> list:
|
|
| 65 |
]
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def generate_answer(
|
| 69 |
question: str,
|
| 70 |
user_id: str,
|
|
@@ -145,6 +154,14 @@ def generate_answer(
|
|
| 145 |
return {"answer": answer, "sources": sources}
|
| 146 |
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
def generate_answer_stream(
|
| 149 |
question: str,
|
| 150 |
user_id: str,
|
|
|
|
| 10 |
from app.config import get_settings
|
| 11 |
from app.rag.retriever import retrieve
|
| 12 |
from app.rag.prompts import SYSTEM_PROMPT, RAG_PROMPT_TEMPLATE, GREETING_PROMPT
|
| 13 |
+
from app.rag.tracing import trace_function
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
settings = get_settings()
|
|
|
|
| 66 |
]
|
| 67 |
|
| 68 |
|
| 69 |
+
@trace_function(
|
| 70 |
+
"generate_answer",
|
| 71 |
+
metadata_factory=lambda question, user_id, document_id=None: {
|
| 72 |
+
"user_id": user_id,
|
| 73 |
+
"document_id": document_id,
|
| 74 |
+
"llm_model": settings.LLM_MODEL,
|
| 75 |
+
},
|
| 76 |
+
)
|
| 77 |
def generate_answer(
|
| 78 |
question: str,
|
| 79 |
user_id: str,
|
|
|
|
| 154 |
return {"answer": answer, "sources": sources}
|
| 155 |
|
| 156 |
|
| 157 |
+
@trace_function(
|
| 158 |
+
"generate_answer_stream",
|
| 159 |
+
metadata_factory=lambda question, user_id, document_id=None: {
|
| 160 |
+
"user_id": user_id,
|
| 161 |
+
"document_id": document_id,
|
| 162 |
+
"llm_model": settings.LLM_MODEL,
|
| 163 |
+
},
|
| 164 |
+
)
|
| 165 |
def generate_answer_stream(
|
| 166 |
question: str,
|
| 167 |
user_id: str,
|
backend/app/rag/chunker.py
CHANGED
|
@@ -28,6 +28,34 @@ def extract_pdf(filepath: str) -> List[Dict[str, Any]]:
|
|
| 28 |
return pages
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
def extract_docx(filepath: str) -> List[Dict[str, Any]]:
|
| 32 |
"""Extract text from DOCX files."""
|
| 33 |
doc = docx.Document(filepath)
|
|
@@ -50,10 +78,13 @@ def chunk_document(filepath: str) -> List[Dict[str, Any]]:
|
|
| 50 |
Returns list of dicts with 'text', 'page', and 'chunk_index'.
|
| 51 |
"""
|
| 52 |
ext = filepath.rsplit(".", 1)[-1].lower()
|
|
|
|
| 53 |
|
| 54 |
# ── Extract text by file type ────────────────────
|
| 55 |
if ext == "pdf":
|
| 56 |
pages = extract_pdf(filepath)
|
|
|
|
|
|
|
| 57 |
elif ext == "docx":
|
| 58 |
pages = extract_docx(filepath)
|
| 59 |
elif ext in ("txt", "md"):
|
|
@@ -91,6 +122,16 @@ def chunk_document(filepath: str) -> List[Dict[str, Any]]:
|
|
| 91 |
})
|
| 92 |
chunk_index += 1
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
return all_chunks
|
| 95 |
|
| 96 |
|
|
|
|
| 28 |
return pages
|
| 29 |
|
| 30 |
|
| 31 |
+
def extract_pdf_images(filepath: str) -> List[Dict[str, Any]]:
|
| 32 |
+
"""Extract images from a PDF and return list of dicts with image bytes and page number.
|
| 33 |
+
|
| 34 |
+
Each entry: {"image_bytes": b"...", "page": int}
|
| 35 |
+
"""
|
| 36 |
+
images = []
|
| 37 |
+
doc = fitz.open(filepath)
|
| 38 |
+
|
| 39 |
+
for page_num, page in enumerate(doc):
|
| 40 |
+
# get_images returns a list of tuples where first item is xref
|
| 41 |
+
for img in page.get_images(full=True):
|
| 42 |
+
xref = img[0]
|
| 43 |
+
try:
|
| 44 |
+
pix = fitz.Pixmap(doc, xref)
|
| 45 |
+
# Convert to RGB if it's CMYK or has alpha
|
| 46 |
+
if pix.n >= 4:
|
| 47 |
+
pix = fitz.Pixmap(fitz.csRGB, pix)
|
| 48 |
+
|
| 49 |
+
img_bytes = pix.tobytes("png")
|
| 50 |
+
images.append({"image_bytes": img_bytes, "page": page_num + 1})
|
| 51 |
+
except Exception:
|
| 52 |
+
# ignore extracting this image
|
| 53 |
+
continue
|
| 54 |
+
|
| 55 |
+
doc.close()
|
| 56 |
+
return images
|
| 57 |
+
|
| 58 |
+
|
| 59 |
def extract_docx(filepath: str) -> List[Dict[str, Any]]:
|
| 60 |
"""Extract text from DOCX files."""
|
| 61 |
doc = docx.Document(filepath)
|
|
|
|
| 78 |
Returns list of dicts with 'text', 'page', and 'chunk_index'.
|
| 79 |
"""
|
| 80 |
ext = filepath.rsplit(".", 1)[-1].lower()
|
| 81 |
+
images = []
|
| 82 |
|
| 83 |
# ── Extract text by file type ────────────────────
|
| 84 |
if ext == "pdf":
|
| 85 |
pages = extract_pdf(filepath)
|
| 86 |
+
# also extract images for later captioning/embedding
|
| 87 |
+
images = extract_pdf_images(filepath)
|
| 88 |
elif ext == "docx":
|
| 89 |
pages = extract_docx(filepath)
|
| 90 |
elif ext in ("txt", "md"):
|
|
|
|
| 122 |
})
|
| 123 |
chunk_index += 1
|
| 124 |
|
| 125 |
+
# Attach any images that belong to this page after text chunks for the page
|
| 126 |
+
for img in [i for i in images if i["page"] == page_num]:
|
| 127 |
+
all_chunks.append({
|
| 128 |
+
"text": "",
|
| 129 |
+
"page": page_num,
|
| 130 |
+
"chunk_index": chunk_index,
|
| 131 |
+
"image_bytes": img["image_bytes"],
|
| 132 |
+
})
|
| 133 |
+
chunk_index += 1
|
| 134 |
+
|
| 135 |
return all_chunks
|
| 136 |
|
| 137 |
|
backend/app/rag/embeddings.py
CHANGED
|
@@ -6,6 +6,7 @@ import logging
|
|
| 6 |
from typing import List
|
| 7 |
from langchain_huggingface import HuggingFaceEmbeddings
|
| 8 |
from app.config import get_settings
|
|
|
|
| 9 |
|
| 10 |
logger = logging.getLogger(__name__)
|
| 11 |
settings = get_settings()
|
|
@@ -36,10 +37,26 @@ def get_embedding_model() -> HuggingFaceEmbeddings:
|
|
| 36 |
def embed_texts(texts: List[str]) -> List[List[float]]:
|
| 37 |
"""Embed a batch of texts into vectors."""
|
| 38 |
model = get_embedding_model()
|
| 39 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
def embed_query(query: str) -> List[float]:
|
| 43 |
"""Embed a single query string."""
|
| 44 |
model = get_embedding_model()
|
| 45 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from typing import List
|
| 7 |
from langchain_huggingface import HuggingFaceEmbeddings
|
| 8 |
from app.config import get_settings
|
| 9 |
+
from app.rag.tracing import trace_call
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
settings = get_settings()
|
|
|
|
| 37 |
def embed_texts(texts: List[str]) -> List[List[float]]:
|
| 38 |
"""Embed a batch of texts into vectors."""
|
| 39 |
model = get_embedding_model()
|
| 40 |
+
return trace_call(
|
| 41 |
+
"embed_texts",
|
| 42 |
+
lambda: model.embed_documents(texts),
|
| 43 |
+
run_type="embedding",
|
| 44 |
+
metadata={
|
| 45 |
+
"embedding_model": settings.EMBEDDING_MODEL,
|
| 46 |
+
"text_count": len(texts),
|
| 47 |
+
},
|
| 48 |
+
)
|
| 49 |
|
| 50 |
|
| 51 |
def embed_query(query: str) -> List[float]:
|
| 52 |
"""Embed a single query string."""
|
| 53 |
model = get_embedding_model()
|
| 54 |
+
return trace_call(
|
| 55 |
+
"embed_query",
|
| 56 |
+
lambda: model.embed_query(query),
|
| 57 |
+
run_type="embedding",
|
| 58 |
+
metadata={
|
| 59 |
+
"embedding_model": settings.EMBEDDING_MODEL,
|
| 60 |
+
"query_length": len(query),
|
| 61 |
+
},
|
| 62 |
+
)
|
backend/app/rag/retriever.py
CHANGED
|
@@ -5,6 +5,7 @@ import logging
|
|
| 5 |
from typing import List, Dict, Any, Optional
|
| 6 |
from app.config import get_settings
|
| 7 |
from app.rag.embeddings import embed_query
|
|
|
|
| 8 |
from app.rag.vectorstore import query_chunks
|
| 9 |
|
| 10 |
logger = logging.getLogger(__name__)
|
|
@@ -31,6 +32,17 @@ def get_reranker():
|
|
| 31 |
return _reranker if _reranker != "disabled" else None
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def retrieve(
|
| 35 |
query: str,
|
| 36 |
user_id: str,
|
|
|
|
| 5 |
from typing import List, Dict, Any, Optional
|
| 6 |
from app.config import get_settings
|
| 7 |
from app.rag.embeddings import embed_query
|
| 8 |
+
from app.rag.tracing import trace_function
|
| 9 |
from app.rag.vectorstore import query_chunks
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
|
|
|
| 32 |
return _reranker if _reranker != "disabled" else None
|
| 33 |
|
| 34 |
|
| 35 |
+
@trace_function(
|
| 36 |
+
"retrieve",
|
| 37 |
+
metadata_factory=lambda query, user_id, document_id=None: {
|
| 38 |
+
"user_id": user_id,
|
| 39 |
+
"document_id": document_id,
|
| 40 |
+
"embedding_model": settings.EMBEDDING_MODEL,
|
| 41 |
+
"reranker_model": settings.RERANKER_MODEL,
|
| 42 |
+
"top_k_retrieval": settings.TOP_K_RETRIEVAL,
|
| 43 |
+
"top_k_rerank": settings.TOP_K_RERANK,
|
| 44 |
+
},
|
| 45 |
+
)
|
| 46 |
def retrieve(
|
| 47 |
query: str,
|
| 48 |
user_id: str,
|
backend/app/rag/tracing.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Optional LangSmith tracing helpers for the RAG pipeline.
|
| 3 |
+
Safe to import even when LangSmith is not installed or configured.
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from functools import wraps
|
| 8 |
+
from typing import Any, Callable, Optional
|
| 9 |
+
|
| 10 |
+
from app.config import get_settings
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
settings = get_settings()
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from langsmith import traceable as _langsmith_traceable
|
| 17 |
+
except Exception: # pragma: no cover - optional dependency safety
|
| 18 |
+
_langsmith_traceable = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def configure_langsmith() -> bool:
|
| 22 |
+
"""Configure LangSmith environment variables when tracing is enabled."""
|
| 23 |
+
if not settings.LANGSMITH_TRACING:
|
| 24 |
+
return False
|
| 25 |
+
|
| 26 |
+
if not settings.LANGSMITH_API_KEY:
|
| 27 |
+
logger.warning("LangSmith tracing enabled but LANGSMITH_API_KEY is not set; tracing disabled.")
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
os.environ["LANGSMITH_TRACING"] = "true"
|
| 31 |
+
os.environ["LANGSMITH_API_KEY"] = settings.LANGSMITH_API_KEY
|
| 32 |
+
os.environ["LANGSMITH_ENDPOINT"] = settings.LANGSMITH_ENDPOINT
|
| 33 |
+
os.environ["LANGSMITH_PROJECT"] = settings.LANGSMITH_PROJECT
|
| 34 |
+
return _langsmith_traceable is not None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
LANGSMITH_ENABLED = configure_langsmith()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _sanitize_metadata(metadata: Optional[dict[str, Any]]) -> dict[str, Any]:
|
| 41 |
+
return {key: value for key, value in (metadata or {}).items() if value is not None}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _build_traceable(name: str, run_type: str, metadata: Optional[dict[str, Any]] = None):
|
| 45 |
+
"""Build a LangSmith traceable decorator safely across versions."""
|
| 46 |
+
if _langsmith_traceable is None:
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
sanitized = _sanitize_metadata(metadata)
|
| 50 |
+
try:
|
| 51 |
+
return _langsmith_traceable(
|
| 52 |
+
name=name,
|
| 53 |
+
run_type=run_type,
|
| 54 |
+
metadata=sanitized or None,
|
| 55 |
+
)
|
| 56 |
+
except TypeError:
|
| 57 |
+
return _langsmith_traceable(name=name, run_type=run_type)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def trace_call(
|
| 61 |
+
name: str,
|
| 62 |
+
fn: Callable[..., Any],
|
| 63 |
+
*args: Any,
|
| 64 |
+
run_type: str = "chain",
|
| 65 |
+
metadata: Optional[dict[str, Any]] = None,
|
| 66 |
+
**kwargs: Any,
|
| 67 |
+
) -> Any:
|
| 68 |
+
"""Execute a callable with LangSmith tracing when available."""
|
| 69 |
+
if not LANGSMITH_ENABLED:
|
| 70 |
+
return fn(*args, **kwargs)
|
| 71 |
+
|
| 72 |
+
decorator = _build_traceable(name, run_type, metadata)
|
| 73 |
+
if decorator is None:
|
| 74 |
+
return fn(*args, **kwargs)
|
| 75 |
+
|
| 76 |
+
traced_fn = decorator(fn)
|
| 77 |
+
return traced_fn(*args, **kwargs)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def trace_function(
|
| 81 |
+
name: str,
|
| 82 |
+
*,
|
| 83 |
+
run_type: str = "chain",
|
| 84 |
+
metadata_factory: Optional[Callable[..., dict[str, Any]]] = None,
|
| 85 |
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
| 86 |
+
"""Decorator wrapper that becomes a no-op when LangSmith is disabled."""
|
| 87 |
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
| 88 |
+
@wraps(fn)
|
| 89 |
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
| 90 |
+
metadata = metadata_factory(*args, **kwargs) if metadata_factory else None
|
| 91 |
+
return trace_call(
|
| 92 |
+
name,
|
| 93 |
+
fn,
|
| 94 |
+
*args,
|
| 95 |
+
run_type=run_type,
|
| 96 |
+
metadata=metadata,
|
| 97 |
+
**kwargs,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
return wrapped
|
| 101 |
+
|
| 102 |
+
return decorator
|
backend/app/rag/vectorstore.py
CHANGED
|
@@ -4,10 +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 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
settings = get_settings()
|
|
@@ -16,12 +13,15 @@ settings = get_settings()
|
|
| 16 |
_chroma_client = None
|
| 17 |
|
| 18 |
|
| 19 |
-
def get_chroma_client()
|
| 20 |
"""Get or create persistent ChromaDB client."""
|
| 21 |
global _chroma_client
|
| 22 |
|
| 23 |
if _chroma_client is None:
|
| 24 |
import os
|
|
|
|
|
|
|
|
|
|
| 25 |
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
|
| 26 |
|
| 27 |
_chroma_client = chromadb.PersistentClient(
|
|
@@ -55,7 +55,17 @@ def store_chunks(
|
|
| 55 |
if not chunks:
|
| 56 |
return 0
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
client = get_chroma_client()
|
|
|
|
|
|
|
| 59 |
embedding_model = get_embedding_model()
|
| 60 |
|
| 61 |
collection_name = get_collection_name(user_id)
|
|
@@ -74,6 +84,9 @@ def store_chunks(
|
|
| 74 |
"document_id": document_id,
|
| 75 |
"page": chunk["page"],
|
| 76 |
"chunk_index": chunk["chunk_index"],
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
for chunk in chunks
|
| 79 |
]
|
|
|
|
| 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(
|
|
|
|
| 55 |
if not chunks:
|
| 56 |
return 0
|
| 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)
|
|
|
|
| 84 |
"document_id": document_id,
|
| 85 |
"page": chunk["page"],
|
| 86 |
"chunk_index": chunk["chunk_index"],
|
| 87 |
+
# Indicate whether this chunk was originally an image and include a short caption
|
| 88 |
+
**({"is_image": True, "image_caption": chunk.get("image_caption", "")}
|
| 89 |
+
if chunk.get("is_image") else {}),
|
| 90 |
}
|
| 91 |
for chunk in chunks
|
| 92 |
]
|
backend/app/rag/vision.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image captioning / vision helpers for RAG pipeline.
|
| 2 |
+
|
| 3 |
+
Provides a simple, pluggable interface to generate textual descriptions
|
| 4 |
+
for images extracted from PDFs. By default it uses local OCR (pytesseract)
|
| 5 |
+
when available as a robust fallback. An external VLM provider (OpenAI)
|
| 6 |
+
can be integrated by setting `VISION_PROVIDER` and appropriate API keys
|
| 7 |
+
in settings; the provider hook is intentionally small and optional.
|
| 8 |
+
"""
|
| 9 |
+
import logging
|
| 10 |
+
from typing import List, Dict, Any
|
| 11 |
+
from io import BytesIO
|
| 12 |
+
|
| 13 |
+
from app.config import get_settings
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
settings = get_settings()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _ocr_caption(image_bytes: bytes) -> str:
|
| 20 |
+
"""Try to produce a caption using pytesseract OCR; returns empty string if not available."""
|
| 21 |
+
try:
|
| 22 |
+
from PIL import Image
|
| 23 |
+
import pytesseract
|
| 24 |
+
except Exception:
|
| 25 |
+
return ""
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
img = Image.open(BytesIO(image_bytes)).convert("RGB")
|
| 29 |
+
text = pytesseract.image_to_string(img)
|
| 30 |
+
text = text.strip()
|
| 31 |
+
return text
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.debug(f"OCR failed: {e}")
|
| 34 |
+
return ""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def caption_image(image_bytes: bytes, page: int | None = None) -> str:
|
| 38 |
+
"""Generate a caption for a single image.
|
| 39 |
+
|
| 40 |
+
Order of operations:
|
| 41 |
+
- If an external VLM provider is configured, attempt to call it (not implemented as mandatory).
|
| 42 |
+
- Fall back to local OCR (pytesseract) if available.
|
| 43 |
+
- Otherwise return a simple placeholder caption including the page number.
|
| 44 |
+
"""
|
| 45 |
+
# Placeholder for provider-based captioning (e.g., OpenAI / LLaVA hooks)
|
| 46 |
+
provider = getattr(settings, "VISION_PROVIDER", None)
|
| 47 |
+
if provider == "openai":
|
| 48 |
+
try:
|
| 49 |
+
import openai
|
| 50 |
+
# Minimal integration: attempt a text-only caption via responses if available.
|
| 51 |
+
# This is a best-effort hook; users should adapt to their provider's API.
|
| 52 |
+
api_key = getattr(settings, "OPENAI_API_KEY", None)
|
| 53 |
+
if api_key:
|
| 54 |
+
openai.api_key = api_key
|
| 55 |
+
# Use a generic prompt: "Describe the following image"
|
| 56 |
+
# Note: concrete multimodal API usage may vary across SDK versions.
|
| 57 |
+
resp = openai.Image.create(
|
| 58 |
+
prompt="Describe this image in one concise sentence.",
|
| 59 |
+
n=1,
|
| 60 |
+
# We do not re-upload image bytes here; this is a placeholder to show
|
| 61 |
+
# where provider code would be invoked. For production, follow
|
| 62 |
+
# provider docs for sending image data.
|
| 63 |
+
)
|
| 64 |
+
# openai.Image.create returns generated images, not captions — so skip.
|
| 65 |
+
except Exception:
|
| 66 |
+
# If provider integration fails, fall back to OCR below
|
| 67 |
+
logger.debug("OpenAI vision provider failed, falling back to OCR")
|
| 68 |
+
|
| 69 |
+
# Try OCR caption
|
| 70 |
+
ocr = _ocr_caption(image_bytes)
|
| 71 |
+
if ocr:
|
| 72 |
+
# Keep it short if very long
|
| 73 |
+
return (ocr[:500] + "...") if len(ocr) > 500 else ocr
|
| 74 |
+
|
| 75 |
+
# Last-resort caption
|
| 76 |
+
if page:
|
| 77 |
+
return f"Image on page {page}."
|
| 78 |
+
return "Image."
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def generate_captions_for_chunks(chunks: List[Dict[str, Any]]) -> None:
|
| 82 |
+
"""Mutate chunks in-place: for any chunk containing `image_bytes` but empty `text`,
|
| 83 |
+
generate a caption and set `text`.
|
| 84 |
+
"""
|
| 85 |
+
for chunk in chunks:
|
| 86 |
+
if chunk.get("image_bytes") and not chunk.get("text"):
|
| 87 |
+
try:
|
| 88 |
+
caption = caption_image(chunk["image_bytes"], page=chunk.get("page"))
|
| 89 |
+
chunk["text"] = caption
|
| 90 |
+
# Remove raw bytes to avoid accidentally serializing them later
|
| 91 |
+
chunk.pop("image_bytes", None)
|
| 92 |
+
chunk["is_image"] = True
|
| 93 |
+
chunk["image_caption"] = caption
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.debug(f"Failed to caption image chunk: {e}")
|
| 96 |
+
# ensure we still mark it as image to avoid losing it
|
| 97 |
+
chunk.pop("image_bytes", None)
|
| 98 |
+
chunk["is_image"] = True
|
| 99 |
+
chunk.setdefault("text", f"Image on page {chunk.get('page')}")
|
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/auth.py
CHANGED
|
@@ -11,9 +11,10 @@ from sqlalchemy.orm import Session
|
|
| 11 |
from sqlalchemy import select
|
| 12 |
from app.config import get_settings
|
| 13 |
from app.database import get_db
|
| 14 |
-
from app.models import User
|
| 15 |
from app.schemas import (
|
| 16 |
GoogleLoginRequest,
|
|
|
|
| 17 |
RefreshRequest,
|
| 18 |
TokenResponse,
|
| 19 |
UpdatePassword,
|
|
@@ -23,6 +24,8 @@ from app.schemas import (
|
|
| 23 |
UserResponse,
|
| 24 |
UserUpdate,
|
| 25 |
UserUpdateResponse,
|
|
|
|
|
|
|
| 26 |
)
|
| 27 |
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, get_current_user, decode_token
|
| 28 |
|
|
@@ -277,6 +280,34 @@ def get_me(user: User = Depends(get_current_user)):
|
|
| 277 |
"""
|
| 278 |
return UserResponse.model_validate(user)
|
| 279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
@router.put("/update")
|
| 281 |
def update_user_info(payload:UserUpdate,
|
| 282 |
user: User = Depends(get_current_user),
|
|
@@ -383,6 +414,42 @@ def update_password(payload:UpdatePassword,
|
|
| 383 |
db.rollback()
|
| 384 |
raise HTTPException(status_code=400, detail="Database error")
|
| 385 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
@router.get("/config")
|
| 387 |
def get_auth_config():
|
| 388 |
"""Return public configuration for auth providers"""
|
|
|
|
| 11 |
from sqlalchemy import select
|
| 12 |
from app.config import get_settings
|
| 13 |
from app.database import get_db
|
| 14 |
+
from app.models import User, ApiKey
|
| 15 |
from app.schemas import (
|
| 16 |
GoogleLoginRequest,
|
| 17 |
+
HFTokenUpdate,
|
| 18 |
RefreshRequest,
|
| 19 |
TokenResponse,
|
| 20 |
UpdatePassword,
|
|
|
|
| 24 |
UserResponse,
|
| 25 |
UserUpdate,
|
| 26 |
UserUpdateResponse,
|
| 27 |
+
ApiKeyResponse,
|
| 28 |
+
ApiKeyCreateResponse,
|
| 29 |
)
|
| 30 |
from app.auth import hash_password, verify_password, create_access_token, create_refresh_token, get_current_user, decode_token
|
| 31 |
|
|
|
|
| 280 |
"""
|
| 281 |
return UserResponse.model_validate(user)
|
| 282 |
|
| 283 |
+
@router.put("/hf-token", response_model=UserResponse)
|
| 284 |
+
def update_hf_token(
|
| 285 |
+
payload: HFTokenUpdate,
|
| 286 |
+
user: User = Depends(get_current_user),
|
| 287 |
+
db: Session = Depends(get_db),
|
| 288 |
+
):
|
| 289 |
+
"""Update the HuggingFace token for the authenticated user.
|
| 290 |
+
|
| 291 |
+
Stores the provided HF token in the user's profile so it can be used
|
| 292 |
+
for HuggingFace API calls (e.g. InferenceClient) in place of the
|
| 293 |
+
globally configured ``HF_TOKEN`` environment variable.
|
| 294 |
+
|
| 295 |
+
Args:
|
| 296 |
+
payload: HFTokenUpdate object containing the new ``hf_token`` value.
|
| 297 |
+
user: The currently authenticated user, obtained from the
|
| 298 |
+
``get_current_user`` dependency.
|
| 299 |
+
db: SQLAlchemy database session, obtained from the dependency.
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
UserResponse: The updated user profile including the new ``hf_token``
|
| 303 |
+
field.
|
| 304 |
+
"""
|
| 305 |
+
user.hf_token = payload.hf_token
|
| 306 |
+
db.commit()
|
| 307 |
+
db.refresh(user)
|
| 308 |
+
return UserResponse.model_validate(user)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
@router.put("/update")
|
| 312 |
def update_user_info(payload:UserUpdate,
|
| 313 |
user: User = Depends(get_current_user),
|
|
|
|
| 414 |
db.rollback()
|
| 415 |
raise HTTPException(status_code=400, detail="Database error")
|
| 416 |
|
| 417 |
+
from typing import List
|
| 418 |
+
import hashlib
|
| 419 |
+
|
| 420 |
+
@router.post("/api-keys", response_model=ApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)
|
| 421 |
+
def create_api_key(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 422 |
+
"""Create a new API key for the authenticated user."""
|
| 423 |
+
raw_key = "rag_" + secrets.token_urlsafe(32)
|
| 424 |
+
hashed_key = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
| 425 |
+
|
| 426 |
+
api_key = ApiKey(
|
| 427 |
+
user_id=user.id,
|
| 428 |
+
key_prefix=raw_key[:10],
|
| 429 |
+
hashed_key=hashed_key,
|
| 430 |
+
)
|
| 431 |
+
db.add(api_key)
|
| 432 |
+
db.commit()
|
| 433 |
+
db.refresh(api_key)
|
| 434 |
+
|
| 435 |
+
return {"key": raw_key, "api_key": api_key}
|
| 436 |
+
|
| 437 |
+
@router.get("/api-keys", response_model=List[ApiKeyResponse])
|
| 438 |
+
def list_api_keys(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 439 |
+
"""List all API keys for the authenticated user."""
|
| 440 |
+
return db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
|
| 441 |
+
|
| 442 |
+
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 443 |
+
def delete_api_key(key_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
| 444 |
+
"""Revoke an API key."""
|
| 445 |
+
api_key = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
| 446 |
+
if not api_key:
|
| 447 |
+
raise HTTPException(status_code=404, detail="API key not found")
|
| 448 |
+
|
| 449 |
+
db.delete(api_key)
|
| 450 |
+
db.commit()
|
| 451 |
+
return None
|
| 452 |
+
|
| 453 |
@router.get("/config")
|
| 454 |
def get_auth_config():
|
| 455 |
"""Return public configuration for auth providers"""
|
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,18 +17,83 @@ 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
|
| 21 |
-
from app.schemas import ChatRequest, ChatResponse, ChatMessageResponse, ChatHistoryResponse, SourceChunk
|
| 22 |
from app.auth import get_current_user
|
| 23 |
-
from app.
|
|
|
|
|
|
|
| 24 |
from app.rate_limit import limiter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
| 28 |
router = APIRouter(prefix="/chat", tags=["Chat"])
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
@router.post("/ask", response_model=ChatResponse)
|
| 32 |
@limiter.limit("10/minute")
|
| 33 |
def ask_question(
|
|
@@ -63,38 +129,41 @@ def ask_question(
|
|
| 63 |
HTTPException: 400 if the document exists but its status is not
|
| 64 |
"ready" (e.g., still processing or failed).
|
| 65 |
"""
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
@router.post("/ask/stream")
|
|
@@ -156,6 +225,8 @@ def ask_question_stream(
|
|
| 156 |
detail=f"Document is still {doc.status}. Please wait for processing to complete.",
|
| 157 |
)
|
| 158 |
|
|
|
|
|
|
|
| 159 |
# Save user message immediately
|
| 160 |
_save_message(db, user.id, payload.document_id, "user", payload.question)
|
| 161 |
|
|
@@ -164,31 +235,34 @@ def ask_question_stream(
|
|
| 164 |
full_answer = ""
|
| 165 |
sources = []
|
| 166 |
|
| 167 |
-
for chunk in generate_answer_stream(
|
| 168 |
-
question=payload.question,
|
| 169 |
-
user_id=user.id,
|
| 170 |
-
document_id=payload.document_id,
|
| 171 |
-
):
|
| 172 |
-
yield chunk
|
| 173 |
-
|
| 174 |
-
# Parse to accumulate full answer for history
|
| 175 |
-
try:
|
| 176 |
-
if chunk.startswith("data: "):
|
| 177 |
-
data = json.loads(chunk[6:].strip())
|
| 178 |
-
if data.get("type") == "token":
|
| 179 |
-
full_answer += data.get("data", "")
|
| 180 |
-
elif data.get("type") == "sources":
|
| 181 |
-
sources = data.get("data", [])
|
| 182 |
-
except Exception:
|
| 183 |
-
pass
|
| 184 |
-
|
| 185 |
-
# Save assistant response to history
|
| 186 |
-
from app.database import SessionLocal
|
| 187 |
-
save_db = SessionLocal()
|
| 188 |
try:
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
finally:
|
| 191 |
-
|
| 192 |
|
| 193 |
return StreamingResponse(
|
| 194 |
event_stream(),
|
|
@@ -425,6 +499,23 @@ def _save_message(
|
|
| 425 |
db.commit()
|
| 426 |
|
| 427 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
def _format_markdown(doc, messages) -> str:
|
| 429 |
"""Format chat history as a Markdown document.
|
| 430 |
|
|
|
|
| 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,
|
| 28 |
+
ChatMessageResponse,
|
| 29 |
+
ChatHistoryResponse,
|
| 30 |
+
ShareAnswerResponse,
|
| 31 |
+
ShareLinkResponse,
|
| 32 |
+
SourceChunk,
|
| 33 |
+
)
|
| 34 |
|
| 35 |
logger = logging.getLogger(__name__)
|
| 36 |
|
| 37 |
router = APIRouter(prefix="/chat", tags=["Chat"])
|
| 38 |
|
| 39 |
|
| 40 |
+
@router.get("/share/{message_id}", response_model=ShareAnswerResponse)
|
| 41 |
+
def get_shared_answer(
|
| 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",
|
| 48 |
+
).first()
|
| 49 |
+
|
| 50 |
+
if not message or not db.query(SharedMessage).filter(SharedMessage.message_id == message.id).first():
|
| 51 |
+
raise HTTPException(status_code=404, detail="Shared answer not found")
|
| 52 |
+
|
| 53 |
+
return _share_answer_response(message)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.post("/share/{message_id}", response_model=ShareLinkResponse)
|
| 57 |
+
def create_share_link(
|
| 58 |
+
message_id: str,
|
| 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,
|
| 65 |
+
).first()
|
| 66 |
+
|
| 67 |
+
if not message:
|
| 68 |
+
raise HTTPException(status_code=404, detail="Message not found")
|
| 69 |
+
|
| 70 |
+
if message.role != "assistant":
|
| 71 |
+
raise HTTPException(status_code=400, detail="Only assistant messages can be shared")
|
| 72 |
+
|
| 73 |
+
shared_message = db.query(SharedMessage).filter(SharedMessage.message_id == message.id).first()
|
| 74 |
+
if not shared_message:
|
| 75 |
+
shared_message = SharedMessage(message_id=message.id)
|
| 76 |
+
db.add(shared_message)
|
| 77 |
+
db.commit()
|
| 78 |
+
|
| 79 |
+
return ShareLinkResponse(
|
| 80 |
+
message_id=message.id,
|
| 81 |
+
share_url=f"/share?message_id={message.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(),
|
|
|
|
| 499 |
db.commit()
|
| 500 |
|
| 501 |
|
| 502 |
+
def _share_answer_response(message: ChatMessage) -> ShareAnswerResponse:
|
| 503 |
+
"""Format a shared assistant message with only safe public fields."""
|
| 504 |
+
sources = []
|
| 505 |
+
if message.sources_json:
|
| 506 |
+
try:
|
| 507 |
+
sources = [SourceChunk(**item) for item in json.loads(message.sources_json)]
|
| 508 |
+
except Exception:
|
| 509 |
+
sources = []
|
| 510 |
+
|
| 511 |
+
return ShareAnswerResponse(
|
| 512 |
+
id=message.id,
|
| 513 |
+
content=message.content,
|
| 514 |
+
created_at=message.created_at,
|
| 515 |
+
sources=sources,
|
| 516 |
+
)
|
| 517 |
+
|
| 518 |
+
|
| 519 |
def _format_markdown(doc, messages) -> str:
|
| 520 |
"""Format chat history as a Markdown document.
|
| 521 |
|
backend/app/schemas.py
CHANGED
|
@@ -53,11 +53,30 @@ class RefreshRequest(BaseModel):
|
|
| 53 |
refresh_token: str
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
class UserResponse(BaseModel):
|
| 57 |
id: str
|
| 58 |
username: str
|
| 59 |
email: str
|
| 60 |
is_admin: bool
|
|
|
|
| 61 |
created_at: datetime
|
| 62 |
|
| 63 |
class Config:
|
|
@@ -99,6 +118,24 @@ class DocumentListResponse(BaseModel):
|
|
| 99 |
pages: int
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
# ── Chat ─────────────────────────────────────────────
|
| 103 |
|
| 104 |
class ChatRequest(BaseModel):
|
|
@@ -136,5 +173,17 @@ class ChatHistoryResponse(BaseModel):
|
|
| 136 |
document_id: Optional[str] = None
|
| 137 |
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
# Rebuild models for forward references
|
| 140 |
TokenResponse.model_rebuild()
|
|
|
|
| 53 |
refresh_token: str
|
| 54 |
|
| 55 |
|
| 56 |
+
class HFTokenUpdate(BaseModel):
|
| 57 |
+
"""Request schema for updating the user's HuggingFace token."""
|
| 58 |
+
hf_token: str
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ApiKeyResponse(BaseModel):
|
| 62 |
+
id: str
|
| 63 |
+
key_preview: str
|
| 64 |
+
created_at: datetime
|
| 65 |
+
|
| 66 |
+
class Config:
|
| 67 |
+
from_attributes = True
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class ApiKeyCreateResponse(ApiKeyResponse):
|
| 71 |
+
raw_key: str
|
| 72 |
+
|
| 73 |
+
|
| 74 |
class UserResponse(BaseModel):
|
| 75 |
id: str
|
| 76 |
username: str
|
| 77 |
email: str
|
| 78 |
is_admin: bool
|
| 79 |
+
hf_token: Optional[str] = None
|
| 80 |
created_at: datetime
|
| 81 |
|
| 82 |
class Config:
|
|
|
|
| 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):
|
|
|
|
| 173 |
document_id: Optional[str] = None
|
| 174 |
|
| 175 |
|
| 176 |
+
class ShareAnswerResponse(BaseModel):
|
| 177 |
+
id: str
|
| 178 |
+
content: str
|
| 179 |
+
sources: List[SourceChunk] = []
|
| 180 |
+
created_at: datetime
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
class ShareLinkResponse(BaseModel):
|
| 184 |
+
message_id: str
|
| 185 |
+
share_url: str
|
| 186 |
+
|
| 187 |
+
|
| 188 |
# Rebuild models for forward references
|
| 189 |
TokenResponse.model_rebuild()
|
backend/requirements.txt
CHANGED
|
@@ -31,6 +31,7 @@ langchain
|
|
| 31 |
langchain-community
|
| 32 |
langchain-huggingface
|
| 33 |
langchain-text-splitters
|
|
|
|
| 34 |
|
| 35 |
# Embeddings & ML
|
| 36 |
sentence-transformers
|
|
|
|
| 31 |
langchain-community
|
| 32 |
langchain-huggingface
|
| 33 |
langchain-text-splitters
|
| 34 |
+
langsmith
|
| 35 |
|
| 36 |
# Embeddings & ML
|
| 37 |
sentence-transformers
|
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")
|
|
@@ -83,7 +84,7 @@ sys.modules.setdefault("slowapi.util", slowapi_util)
|
|
| 83 |
from app.auth import create_access_token, create_refresh_token, hash_password
|
| 84 |
from app.database import Base, get_db
|
| 85 |
from app.main import app
|
| 86 |
-
from app.models import Document, User
|
| 87 |
|
| 88 |
|
| 89 |
@pytest.fixture()
|
|
@@ -140,6 +141,19 @@ def user(db_session):
|
|
| 140 |
return instance
|
| 141 |
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
@pytest.fixture()
|
| 144 |
def auth_headers(user):
|
| 145 |
token = create_access_token(user.id)
|
|
@@ -181,3 +195,43 @@ def pending_document(db_session, user):
|
|
| 181 |
db_session.commit()
|
| 182 |
db_session.refresh(instance)
|
| 183 |
return instance
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
| 84 |
from app.auth import create_access_token, create_refresh_token, hash_password
|
| 85 |
from app.database import Base, get_db
|
| 86 |
from app.main import app
|
| 87 |
+
from app.models import ChatMessage, Document, User
|
| 88 |
|
| 89 |
|
| 90 |
@pytest.fixture()
|
|
|
|
| 141 |
return instance
|
| 142 |
|
| 143 |
|
| 144 |
+
@pytest.fixture()
|
| 145 |
+
def other_user(db_session):
|
| 146 |
+
instance = User(
|
| 147 |
+
username="other",
|
| 148 |
+
email="other@example.com",
|
| 149 |
+
hashed_password=hash_password("password123"),
|
| 150 |
+
)
|
| 151 |
+
db_session.add(instance)
|
| 152 |
+
db_session.commit()
|
| 153 |
+
db_session.refresh(instance)
|
| 154 |
+
return instance
|
| 155 |
+
|
| 156 |
+
|
| 157 |
@pytest.fixture()
|
| 158 |
def auth_headers(user):
|
| 159 |
token = create_access_token(user.id)
|
|
|
|
| 195 |
db_session.commit()
|
| 196 |
db_session.refresh(instance)
|
| 197 |
return instance
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@pytest.fixture()
|
| 201 |
+
def assistant_message(db_session, user):
|
| 202 |
+
instance = ChatMessage(
|
| 203 |
+
user_id=user.id,
|
| 204 |
+
role="assistant",
|
| 205 |
+
content="Shared assistant answer",
|
| 206 |
+
sources_json='[{"text":"Source text","filename":"file.txt","page":1,"score":0.9,"confidence":95.0}]',
|
| 207 |
+
)
|
| 208 |
+
db_session.add(instance)
|
| 209 |
+
db_session.commit()
|
| 210 |
+
db_session.refresh(instance)
|
| 211 |
+
return instance
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@pytest.fixture()
|
| 215 |
+
def user_message(db_session, user):
|
| 216 |
+
instance = ChatMessage(
|
| 217 |
+
user_id=user.id,
|
| 218 |
+
role="user",
|
| 219 |
+
content="Private user prompt",
|
| 220 |
+
)
|
| 221 |
+
db_session.add(instance)
|
| 222 |
+
db_session.commit()
|
| 223 |
+
db_session.refresh(instance)
|
| 224 |
+
return instance
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@pytest.fixture()
|
| 228 |
+
def other_user_assistant_message(db_session, other_user):
|
| 229 |
+
instance = ChatMessage(
|
| 230 |
+
user_id=other_user.id,
|
| 231 |
+
role="assistant",
|
| 232 |
+
content="Other user's answer",
|
| 233 |
+
)
|
| 234 |
+
db_session.add(instance)
|
| 235 |
+
db_session.commit()
|
| 236 |
+
db_session.refresh(instance)
|
| 237 |
+
return instance
|
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
|
backend/tests/test_auth.py
CHANGED
|
@@ -79,3 +79,39 @@ def test_refresh_token_success(client, refresh_token):
|
|
| 79 |
assert payload["access_token"]
|
| 80 |
assert payload["refresh_token"]
|
| 81 |
assert payload["token_type"] == "bearer"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
assert payload["access_token"]
|
| 80 |
assert payload["refresh_token"]
|
| 81 |
assert payload["token_type"] == "bearer"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_update_hf_token_success(client, auth_headers):
|
| 85 |
+
response = client.put(
|
| 86 |
+
"/api/v1/auth/hf-token",
|
| 87 |
+
json={"hf_token": "hf_new_token_value"},
|
| 88 |
+
headers=auth_headers,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
assert response.status_code == 200
|
| 92 |
+
payload = response.json()
|
| 93 |
+
assert payload["hf_token"] == "hf_new_token_value"
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_update_hf_token_requires_auth(client):
|
| 97 |
+
response = client.put(
|
| 98 |
+
"/api/v1/auth/hf-token",
|
| 99 |
+
json={"hf_token": "hf_unauth"},
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
assert response.status_code in (401, 403)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_hf_token_appears_in_user_response(client, auth_headers, user, db_session):
|
| 106 |
+
# First update the token
|
| 107 |
+
put_resp = client.put(
|
| 108 |
+
"/api/v1/auth/hf-token",
|
| 109 |
+
json={"hf_token": "hf_persist_token"},
|
| 110 |
+
headers=auth_headers,
|
| 111 |
+
)
|
| 112 |
+
assert put_resp.status_code == 200
|
| 113 |
+
|
| 114 |
+
# Then verify it shows up in GET /me
|
| 115 |
+
me_resp = client.get("/api/v1/auth/me", headers=auth_headers)
|
| 116 |
+
assert me_resp.status_code == 200
|
| 117 |
+
assert me_resp.json()["hf_token"] == "hf_persist_token"
|
backend/tests/test_share.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def test_share_link_creation_success(client, auth_headers, assistant_message):
|
| 2 |
+
response = client.post(
|
| 3 |
+
f"/api/v1/chat/share/{assistant_message.id}",
|
| 4 |
+
headers=auth_headers,
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
assert response.status_code == 200
|
| 8 |
+
payload = response.json()
|
| 9 |
+
assert payload["message_id"] == assistant_message.id
|
| 10 |
+
assert payload["share_url"] == f"/share?message_id={assistant_message.id}"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_share_link_unauthorized_for_other_users_message(client, auth_headers, other_user_assistant_message):
|
| 14 |
+
response = client.post(
|
| 15 |
+
f"/api/v1/chat/share/{other_user_assistant_message.id}",
|
| 16 |
+
headers=auth_headers,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
assert response.status_code == 404
|
| 20 |
+
assert response.json()["detail"] == "Message not found"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_cannot_share_user_message(client, auth_headers, user_message):
|
| 24 |
+
response = client.post(
|
| 25 |
+
f"/api/v1/chat/share/{user_message.id}",
|
| 26 |
+
headers=auth_headers,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
assert response.status_code == 400
|
| 30 |
+
assert response.json()["detail"] == "Only assistant messages can be shared"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_public_fetch_fails_before_share(client, assistant_message):
|
| 34 |
+
response = client.get(f"/api/v1/chat/share/{assistant_message.id}")
|
| 35 |
+
|
| 36 |
+
assert response.status_code == 404
|
| 37 |
+
assert response.json()["detail"] == "Shared answer not found"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_public_fetch_shared_answer_success_after_share(client, auth_headers, assistant_message):
|
| 41 |
+
share_response = client.post(
|
| 42 |
+
f"/api/v1/chat/share/{assistant_message.id}",
|
| 43 |
+
headers=auth_headers,
|
| 44 |
+
)
|
| 45 |
+
assert share_response.status_code == 200
|
| 46 |
+
|
| 47 |
+
response = client.get(f"/api/v1/chat/share/{assistant_message.id}")
|
| 48 |
+
|
| 49 |
+
assert response.status_code == 200
|
| 50 |
+
payload = response.json()
|
| 51 |
+
assert payload["id"] == assistant_message.id
|
| 52 |
+
assert payload["content"] == "Shared assistant answer"
|
| 53 |
+
assert len(payload["sources"]) == 1
|
| 54 |
+
assert payload["sources"][0]["filename"] == "file.txt"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_missing_message_returns_404(client):
|
| 58 |
+
response = client.get("/api/v1/chat/share/missing-message-id")
|
| 59 |
+
|
| 60 |
+
assert response.status_code == 404
|
| 61 |
+
assert response.json()["detail"] == "Shared answer not found"
|
bots/discord/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Discord RAG Bot
|
| 2 |
+
|
| 3 |
+
This bot connects to the PDF-Assistant-RAG backend to answer questions based on your uploaded documents, directly from Discord.
|
| 4 |
+
|
| 5 |
+
## Setup
|
| 6 |
+
|
| 7 |
+
1. Install dependencies:
|
| 8 |
+
```bash
|
| 9 |
+
pip install -r requirements.txt
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
2. Create a Discord Bot on the [Discord Developer Portal](https://discord.com/developers/applications):
|
| 13 |
+
- Go to "Bot" tab and enable **Message Content Intent**.
|
| 14 |
+
- Copy the bot token.
|
| 15 |
+
- Invite the bot to your server via the OAuth2 URL Generator (check `bot` scope and `Send Messages` permission).
|
| 16 |
+
|
| 17 |
+
3. Generate an API Key from your PDF-Assistant-RAG profile dashboard.
|
| 18 |
+
|
| 19 |
+
4. Set the environment variables and run:
|
| 20 |
+
```bash
|
| 21 |
+
export DISCORD_TOKEN="your-discord-bot-token"
|
| 22 |
+
export RAG_API_KEY="rag_your-api-key"
|
| 23 |
+
|
| 24 |
+
# Optional: set API_URL if backend is not running on localhost:8000
|
| 25 |
+
# export API_URL="http://localhost:8000/api/v1"
|
| 26 |
+
|
| 27 |
+
python bot.py
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## Usage
|
| 31 |
+
In a Discord channel where the bot is present, simply use the `!ask` command:
|
| 32 |
+
|
| 33 |
+
```
|
| 34 |
+
!ask Summarize the latest uploaded report for me
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
The bot will query the backend API using your personal API key and reply with the generated answer.
|
bots/discord/bot.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import discord
|
| 3 |
+
import requests
|
| 4 |
+
from discord.ext import commands
|
| 5 |
+
|
| 6 |
+
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
|
| 7 |
+
API_URL = os.getenv("API_URL", "http://localhost:8000/api/v1")
|
| 8 |
+
RAG_API_KEY = os.getenv("RAG_API_KEY")
|
| 9 |
+
|
| 10 |
+
if not DISCORD_TOKEN or not RAG_API_KEY:
|
| 11 |
+
print("Error: DISCORD_TOKEN and RAG_API_KEY must be set in environment variables.")
|
| 12 |
+
exit(1)
|
| 13 |
+
|
| 14 |
+
intents = discord.Intents.default()
|
| 15 |
+
intents.message_content = True
|
| 16 |
+
bot = commands.Bot(command_prefix="!", intents=intents)
|
| 17 |
+
|
| 18 |
+
@bot.event
|
| 19 |
+
async def on_ready():
|
| 20 |
+
print(f"Logged in as {bot.user.name} ({bot.user.id})")
|
| 21 |
+
print("Ready to answer questions via '!ask <question>'")
|
| 22 |
+
|
| 23 |
+
@bot.command(name="ask")
|
| 24 |
+
async def ask_rag(ctx, *, question: str):
|
| 25 |
+
"""Ask the RAG Assistant a question. Example: !ask What is in my documents?"""
|
| 26 |
+
loading_msg = await ctx.send("🤔 Thinking...")
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
headers = {
|
| 30 |
+
"Authorization": f"Bearer {RAG_API_KEY}",
|
| 31 |
+
"Content-Type": "application/json"
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# We can also support document_id if we want, but for now we do global ask.
|
| 35 |
+
payload = {"question": question}
|
| 36 |
+
|
| 37 |
+
response = requests.post(
|
| 38 |
+
f"{API_URL}/chat/ask",
|
| 39 |
+
json=payload,
|
| 40 |
+
headers=headers,
|
| 41 |
+
timeout=30 # Give the RAG backend some time to process
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
if response.status_code == 200:
|
| 45 |
+
data = response.json()
|
| 46 |
+
answer = data.get("answer", "No answer provided.")
|
| 47 |
+
|
| 48 |
+
if len(answer) > 2000:
|
| 49 |
+
# Discord has a 2000 character limit per message
|
| 50 |
+
chunks = [answer[i:i+2000] for i in range(0, len(answer), 2000)]
|
| 51 |
+
await loading_msg.edit(content=chunks[0])
|
| 52 |
+
for chunk in chunks[1:]:
|
| 53 |
+
await ctx.send(chunk)
|
| 54 |
+
else:
|
| 55 |
+
await loading_msg.edit(content=answer)
|
| 56 |
+
else:
|
| 57 |
+
await loading_msg.edit(content=f"⚠️ Error from RAG API: `{response.status_code}`")
|
| 58 |
+
print(f"API Error: {response.text}")
|
| 59 |
+
|
| 60 |
+
except requests.exceptions.RequestException as e:
|
| 61 |
+
await loading_msg.edit(content=f"❌ Failed to connect to backend API.")
|
| 62 |
+
print(f"Request Error: {e}")
|
| 63 |
+
except Exception as e:
|
| 64 |
+
await loading_msg.edit(content=f"❌ An unexpected error occurred.")
|
| 65 |
+
print(f"Error: {e}")
|
| 66 |
+
|
| 67 |
+
if __name__ == "__main__":
|
| 68 |
+
bot.run(DISCORD_TOKEN)
|
bots/discord/requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
discord.py==2.3.2
|
| 2 |
+
requests==2.31.0
|
frontend/package-lock.json
CHANGED
|
@@ -11,12 +11,16 @@
|
|
| 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 |
"pdfjs-dist": "^5.6.205",
|
| 17 |
"react": "19.2.4",
|
| 18 |
"react-dom": "19.2.4",
|
| 19 |
"react-dropzone": "^15.0.0",
|
|
|
|
| 20 |
"react-markdown": "^10.1.0",
|
| 21 |
"react-pdf": "^10.4.1",
|
| 22 |
"rehype-highlight": "^7.0.2",
|
|
@@ -79,6 +83,7 @@
|
|
| 79 |
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
| 80 |
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 81 |
"license": "MIT",
|
|
|
|
| 82 |
"dependencies": {
|
| 83 |
"@babel/code-frame": "^7.29.0",
|
| 84 |
"@babel/generator": "^7.29.0",
|
|
@@ -672,6 +677,7 @@
|
|
| 672 |
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
| 673 |
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 674 |
"license": "MIT",
|
|
|
|
| 675 |
"engines": {
|
| 676 |
"node": ">=12"
|
| 677 |
},
|
|
@@ -2106,6 +2112,7 @@
|
|
| 2106 |
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
| 2107 |
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
| 2108 |
"license": "MIT",
|
|
|
|
| 2109 |
"engines": {
|
| 2110 |
"node": "^14.21.3 || >=16"
|
| 2111 |
},
|
|
@@ -2213,6 +2220,7 @@
|
|
| 2213 |
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
| 2214 |
"devOptional": true,
|
| 2215 |
"license": "Apache-2.0",
|
|
|
|
| 2216 |
"dependencies": {
|
| 2217 |
"playwright": "1.60.0"
|
| 2218 |
},
|
|
@@ -2681,6 +2689,7 @@
|
|
| 2681 |
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
| 2682 |
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
| 2683 |
"license": "MIT",
|
|
|
|
| 2684 |
"dependencies": {
|
| 2685 |
"undici-types": "~6.21.0"
|
| 2686 |
}
|
|
@@ -2690,6 +2699,7 @@
|
|
| 2690 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
| 2691 |
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
| 2692 |
"license": "MIT",
|
|
|
|
| 2693 |
"dependencies": {
|
| 2694 |
"csstype": "^3.2.2"
|
| 2695 |
}
|
|
@@ -2776,6 +2786,7 @@
|
|
| 2776 |
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
|
| 2777 |
"dev": true,
|
| 2778 |
"license": "MIT",
|
|
|
|
| 2779 |
"dependencies": {
|
| 2780 |
"@typescript-eslint/scope-manager": "8.59.0",
|
| 2781 |
"@typescript-eslint/types": "8.59.0",
|
|
@@ -3320,6 +3331,7 @@
|
|
| 3320 |
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
| 3321 |
"dev": true,
|
| 3322 |
"license": "MIT",
|
|
|
|
| 3323 |
"bin": {
|
| 3324 |
"acorn": "bin/acorn"
|
| 3325 |
},
|
|
@@ -3774,6 +3786,7 @@
|
|
| 3774 |
}
|
| 3775 |
],
|
| 3776 |
"license": "MIT",
|
|
|
|
| 3777 |
"dependencies": {
|
| 3778 |
"baseline-browser-mapping": "^2.10.12",
|
| 3779 |
"caniuse-lite": "^1.0.30001782",
|
|
@@ -4825,6 +4838,7 @@
|
|
| 4825 |
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
| 4826 |
"dev": true,
|
| 4827 |
"license": "MIT",
|
|
|
|
| 4828 |
"dependencies": {
|
| 4829 |
"@eslint-community/eslint-utils": "^4.8.0",
|
| 4830 |
"@eslint-community/regexpp": "^4.12.1",
|
|
@@ -5010,6 +5024,7 @@
|
|
| 5010 |
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
| 5011 |
"dev": true,
|
| 5012 |
"license": "MIT",
|
|
|
|
| 5013 |
"dependencies": {
|
| 5014 |
"@rtsao/scc": "^1.1.0",
|
| 5015 |
"array-includes": "^3.1.9",
|
|
@@ -5309,6 +5324,7 @@
|
|
| 5309 |
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
| 5310 |
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
| 5311 |
"license": "MIT",
|
|
|
|
| 5312 |
"dependencies": {
|
| 5313 |
"accepts": "^2.0.0",
|
| 5314 |
"body-parser": "^2.2.1",
|
|
@@ -5668,6 +5684,7 @@
|
|
| 5668 |
"version": "2.3.2",
|
| 5669 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 5670 |
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
|
|
| 5671 |
"hasInstallScript": true,
|
| 5672 |
"license": "MIT",
|
| 5673 |
"optional": true,
|
|
@@ -6132,10 +6149,20 @@
|
|
| 6132 |
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
| 6133 |
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
| 6134 |
"license": "MIT",
|
|
|
|
| 6135 |
"engines": {
|
| 6136 |
"node": ">=16.9.0"
|
| 6137 |
}
|
| 6138 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6139 |
"node_modules/html-url-attributes": {
|
| 6140 |
"version": "3.0.1",
|
| 6141 |
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
|
@@ -6188,6 +6215,44 @@
|
|
| 6188 |
"node": ">=18.18.0"
|
| 6189 |
}
|
| 6190 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6191 |
"node_modules/iconv-lite": {
|
| 6192 |
"version": "0.7.2",
|
| 6193 |
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
|
@@ -8691,6 +8756,16 @@
|
|
| 8691 |
}
|
| 8692 |
}
|
| 8693 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8694 |
"node_modules/next/node_modules/postcss": {
|
| 8695 |
"version": "8.4.31",
|
| 8696 |
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
|
@@ -9516,6 +9591,7 @@
|
|
| 9516 |
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
| 9517 |
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
| 9518 |
"license": "MIT",
|
|
|
|
| 9519 |
"engines": {
|
| 9520 |
"node": ">=0.10.0"
|
| 9521 |
}
|
|
@@ -9525,6 +9601,7 @@
|
|
| 9525 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
| 9526 |
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
| 9527 |
"license": "MIT",
|
|
|
|
| 9528 |
"dependencies": {
|
| 9529 |
"scheduler": "^0.27.0"
|
| 9530 |
},
|
|
@@ -9549,6 +9626,33 @@
|
|
| 9549 |
"react": ">= 16.8 || 18.0.0"
|
| 9550 |
}
|
| 9551 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9552 |
"node_modules/react-is": {
|
| 9553 |
"version": "16.13.1",
|
| 9554 |
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
|
@@ -10822,6 +10926,7 @@
|
|
| 10822 |
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
| 10823 |
"dev": true,
|
| 10824 |
"license": "MIT",
|
|
|
|
| 10825 |
"engines": {
|
| 10826 |
"node": ">=12"
|
| 10827 |
},
|
|
@@ -11090,6 +11195,7 @@
|
|
| 11090 |
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
| 11091 |
"devOptional": true,
|
| 11092 |
"license": "Apache-2.0",
|
|
|
|
| 11093 |
"bin": {
|
| 11094 |
"tsc": "bin/tsc",
|
| 11095 |
"tsserver": "bin/tsserver"
|
|
@@ -11423,6 +11529,15 @@
|
|
| 11423 |
"url": "https://opencollective.com/unified"
|
| 11424 |
}
|
| 11425 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11426 |
"node_modules/warning": {
|
| 11427 |
"version": "4.0.3",
|
| 11428 |
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
|
@@ -11763,6 +11878,7 @@
|
|
| 11763 |
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
| 11764 |
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
| 11765 |
"license": "MIT",
|
|
|
|
| 11766 |
"funding": {
|
| 11767 |
"url": "https://github.com/sponsors/colinhacks"
|
| 11768 |
}
|
|
|
|
| 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",
|
| 19 |
"pdfjs-dist": "^5.6.205",
|
| 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",
|
|
|
|
| 8756 |
}
|
| 8757 |
}
|
| 8758 |
},
|
| 8759 |
+
"node_modules/next-themes": {
|
| 8760 |
+
"version": "0.4.6",
|
| 8761 |
+
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
| 8762 |
+
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
| 8763 |
+
"license": "MIT",
|
| 8764 |
+
"peerDependencies": {
|
| 8765 |
+
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
| 8766 |
+
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
| 8767 |
+
}
|
| 8768 |
+
},
|
| 8769 |
"node_modules/next/node_modules/postcss": {
|
| 8770 |
"version": "8.4.31",
|
| 8771 |
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.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,12 +14,16 @@
|
|
| 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 |
"pdfjs-dist": "^5.6.205",
|
| 20 |
"react": "19.2.4",
|
| 21 |
"react-dom": "19.2.4",
|
| 22 |
"react-dropzone": "^15.0.0",
|
|
|
|
| 23 |
"react-markdown": "^10.1.0",
|
| 24 |
"react-pdf": "^10.4.1",
|
| 25 |
"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",
|
| 22 |
"pdfjs-dist": "^5.6.205",
|
| 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/dashboard/page.tsx
CHANGED
|
@@ -63,11 +63,23 @@ export default function DashboardPage() {
|
|
| 63 |
const [viewerOpen, setViewerOpen] = useState(true);
|
| 64 |
const [connectionError, setConnectionError] = useState("");
|
| 65 |
|
| 66 |
-
|
| 67 |
useEffect(() => {
|
| 68 |
if (!loading && !user) router.replace("/login");
|
| 69 |
}, [user, loading, router]);
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
// Load documents
|
| 72 |
const loadDocuments = useCallback(async () => {
|
| 73 |
try {
|
|
|
|
| 63 |
const [viewerOpen, setViewerOpen] = useState(true);
|
| 64 |
const [connectionError, setConnectionError] = useState("");
|
| 65 |
|
| 66 |
+
// Auth guard
|
| 67 |
useEffect(() => {
|
| 68 |
if (!loading && !user) router.replace("/login");
|
| 69 |
}, [user, loading, router]);
|
| 70 |
|
| 71 |
+
// Intercept dashboard if Hugging Face token configuration is missing
|
| 72 |
+
useEffect(() => {
|
| 73 |
+
if (user) {
|
| 74 |
+
const existingHfToken = localStorage.getItem("hf_token");
|
| 75 |
+
|
| 76 |
+
if (!existingHfToken) {
|
| 77 |
+
console.warn("Hugging Face API configuration key missing.");
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}, [user]);
|
| 81 |
+
|
| 82 |
+
|
| 83 |
// Load documents
|
| 84 |
const loadDocuments = useCallback(async () => {
|
| 85 |
try {
|
frontend/src/app/globals.css
CHANGED
|
@@ -83,6 +83,35 @@
|
|
| 83 |
--sidebar-ring: oklch(0.65 0.2 265);
|
| 84 |
}
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
.light {
|
| 87 |
--background: oklch(0.985 0 0);
|
| 88 |
--foreground: oklch(0.145 0 0);
|
|
|
|
| 83 |
--sidebar-ring: oklch(0.65 0.2 265);
|
| 84 |
}
|
| 85 |
|
| 86 |
+
.dark {
|
| 87 |
+
--background: oklch(0.145 0 0);
|
| 88 |
+
--foreground: oklch(0.985 0 0);
|
| 89 |
+
--card: oklch(0.178 0 0);
|
| 90 |
+
--card-foreground: oklch(0.985 0 0);
|
| 91 |
+
--popover: oklch(0.178 0 0);
|
| 92 |
+
--popover-foreground: oklch(0.985 0 0);
|
| 93 |
+
--primary: oklch(0.65 0.2 265);
|
| 94 |
+
--primary-foreground: oklch(0.985 0 0);
|
| 95 |
+
--secondary: oklch(0.22 0 0);
|
| 96 |
+
--secondary-foreground: oklch(0.985 0 0);
|
| 97 |
+
--muted: oklch(0.22 0 0);
|
| 98 |
+
--muted-foreground: oklch(0.6 0 0);
|
| 99 |
+
--accent: oklch(0.55 0.18 265);
|
| 100 |
+
--accent-foreground: oklch(0.985 0 0);
|
| 101 |
+
--destructive: oklch(0.704 0.191 22.216);
|
| 102 |
+
--border: oklch(1 0 0 / 10%);
|
| 103 |
+
--input: oklch(1 0 0 / 12%);
|
| 104 |
+
--ring: oklch(0.65 0.2 265);
|
| 105 |
+
--sidebar: oklch(0.12 0 0);
|
| 106 |
+
--sidebar-foreground: oklch(0.985 0 0);
|
| 107 |
+
--sidebar-primary: oklch(0.65 0.2 265);
|
| 108 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 109 |
+
--sidebar-accent: oklch(0.22 0 0);
|
| 110 |
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
| 111 |
+
--sidebar-border: oklch(1 0 0 / 8%);
|
| 112 |
+
--sidebar-ring: oklch(0.65 0.2 265);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
.light {
|
| 116 |
--background: oklch(0.985 0 0);
|
| 117 |
--foreground: oklch(0.145 0 0);
|
frontend/src/app/layout.tsx
CHANGED
|
@@ -3,6 +3,8 @@ import { Inter } from "next/font/google";
|
|
| 3 |
import "./globals.css";
|
| 4 |
import { AuthProvider } from "@/lib/auth";
|
| 5 |
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
|
|
|
|
|
| 6 |
|
| 7 |
const inter = Inter({
|
| 8 |
variable: "--font-sans",
|
|
@@ -23,13 +25,20 @@ export default function RootLayout({
|
|
| 23 |
children: React.ReactNode;
|
| 24 |
}>) {
|
| 25 |
return (
|
| 26 |
-
<html lang="en" className={`${inter.variable}
|
| 27 |
<body className="min-h-full flex flex-col bg-background text-foreground">
|
| 28 |
-
<
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
</body>
|
| 34 |
</html>
|
| 35 |
);
|
|
|
|
| 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({
|
| 10 |
variable: "--font-sans",
|
|
|
|
| 25 |
children: React.ReactNode;
|
| 26 |
}>) {
|
| 27 |
return (
|
| 28 |
+
<html lang="en" className={`${inter.variable} h-full antialiased`} suppressHydrationWarning>
|
| 29 |
<body className="min-h-full flex flex-col bg-background text-foreground">
|
| 30 |
+
<ThemeProvider
|
| 31 |
+
attribute="class"
|
| 32 |
+
defaultTheme="dark"
|
| 33 |
+
enableSystem={false}
|
| 34 |
+
disableTransitionOnChange
|
| 35 |
+
>
|
| 36 |
+
<AuthProvider>
|
| 37 |
+
<I18nProvider>
|
| 38 |
+
<TooltipProvider>{children}</TooltipProvider>
|
| 39 |
+
</I18nProvider>
|
| 40 |
+
</AuthProvider>
|
| 41 |
+
</ThemeProvider>
|
| 42 |
</body>
|
| 43 |
</html>
|
| 44 |
);
|
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/app/share/page.tsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Suspense, useEffect, useState } from "react";
|
| 4 |
+
import { useSearchParams } from "next/navigation";
|
| 5 |
+
import ReactMarkdown, { type Components } from "react-markdown";
|
| 6 |
+
import rehypeHighlight from "rehype-highlight";
|
| 7 |
+
import remarkGfm from "remark-gfm";
|
| 8 |
+
import { Brain } from "lucide-react";
|
| 9 |
+
import { api } from "@/lib/api";
|
| 10 |
+
|
| 11 |
+
interface SharedSource {
|
| 12 |
+
text: string;
|
| 13 |
+
filename: string;
|
| 14 |
+
page: number;
|
| 15 |
+
score: number;
|
| 16 |
+
confidence: number;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
interface SharedAnswer {
|
| 20 |
+
id: string;
|
| 21 |
+
content: string;
|
| 22 |
+
created_at: string;
|
| 23 |
+
sources: SharedSource[];
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const markdownComponents: Components = {
|
| 27 |
+
table: ({ children }) => (
|
| 28 |
+
<div className="my-3 overflow-x-auto rounded-lg border border-border/70">
|
| 29 |
+
<table className="min-w-full border-collapse text-left text-sm">
|
| 30 |
+
{children}
|
| 31 |
+
</table>
|
| 32 |
+
</div>
|
| 33 |
+
),
|
| 34 |
+
thead: ({ children }) => (
|
| 35 |
+
<thead className="bg-muted/60 text-foreground">{children}</thead>
|
| 36 |
+
),
|
| 37 |
+
th: ({ children }) => (
|
| 38 |
+
<th className="border-b border-border/70 px-3 py-2 font-semibold">
|
| 39 |
+
{children}
|
| 40 |
+
</th>
|
| 41 |
+
),
|
| 42 |
+
td: ({ children }) => (
|
| 43 |
+
<td className="border-b border-border/50 px-3 py-2 align-top">
|
| 44 |
+
{children}
|
| 45 |
+
</td>
|
| 46 |
+
),
|
| 47 |
+
pre: ({ children }) => (
|
| 48 |
+
<pre className="not-prose my-3 overflow-x-auto rounded-lg border border-border/70 bg-zinc-950 p-3 text-sm text-zinc-100">
|
| 49 |
+
{children}
|
| 50 |
+
</pre>
|
| 51 |
+
),
|
| 52 |
+
code: ({ className, children, ...props }) => {
|
| 53 |
+
const language = /language-(\w+)/.exec(className ?? "")?.[1];
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<code className={className} data-language={language} {...props}>
|
| 57 |
+
{children}
|
| 58 |
+
</code>
|
| 59 |
+
);
|
| 60 |
+
},
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
function ShareAnswerContent() {
|
| 64 |
+
const searchParams = useSearchParams();
|
| 65 |
+
const messageId = searchParams.get("message_id");
|
| 66 |
+
const missingMessageId = !messageId;
|
| 67 |
+
const [answer, setAnswer] = useState<SharedAnswer | null>(null);
|
| 68 |
+
const [error, setError] = useState("");
|
| 69 |
+
const loading = !error && !answer && !missingMessageId;
|
| 70 |
+
|
| 71 |
+
useEffect(() => {
|
| 72 |
+
if (missingMessageId) {
|
| 73 |
+
return;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
let cancelled = false;
|
| 77 |
+
|
| 78 |
+
void api
|
| 79 |
+
.get<SharedAnswer>(`/api/v1/chat/share/${messageId}`)
|
| 80 |
+
.then((data) => {
|
| 81 |
+
if (cancelled) return;
|
| 82 |
+
setAnswer(data);
|
| 83 |
+
setError("");
|
| 84 |
+
})
|
| 85 |
+
.catch((err: unknown) => {
|
| 86 |
+
if (cancelled) return;
|
| 87 |
+
setAnswer(null);
|
| 88 |
+
setError(err instanceof Error ? err.message : "Shared answer not found");
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
return () => {
|
| 92 |
+
cancelled = true;
|
| 93 |
+
};
|
| 94 |
+
}, [messageId, missingMessageId]);
|
| 95 |
+
|
| 96 |
+
if (missingMessageId) {
|
| 97 |
+
return (
|
| 98 |
+
<div className="min-h-screen flex items-center justify-center px-4">
|
| 99 |
+
<div className="rounded-xl border border-border/50 bg-card/80 px-6 py-5 text-center">
|
| 100 |
+
<p className="text-lg font-semibold mb-1">Shared answer unavailable</p>
|
| 101 |
+
<p className="text-sm text-muted-foreground">This shared answer could not be found.</p>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
if (loading) {
|
| 108 |
+
return (
|
| 109 |
+
<div className="min-h-screen flex items-center justify-center px-4">
|
| 110 |
+
<div className="text-sm text-muted-foreground">Loading shared answer...</div>
|
| 111 |
+
</div>
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
if (error || !answer) {
|
| 116 |
+
return (
|
| 117 |
+
<div className="min-h-screen flex items-center justify-center px-4">
|
| 118 |
+
<div className="rounded-xl border border-border/50 bg-card/80 px-6 py-5 text-center">
|
| 119 |
+
<p className="text-lg font-semibold mb-1">Shared answer unavailable</p>
|
| 120 |
+
<p className="text-sm text-muted-foreground">{error || "This shared answer could not be found."}</p>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
return (
|
| 127 |
+
<div className="min-h-screen px-4 py-10 bg-background text-foreground">
|
| 128 |
+
<div className="max-w-3xl mx-auto">
|
| 129 |
+
<div className="rounded-2xl border border-border/50 bg-card/80 backdrop-blur-sm p-6">
|
| 130 |
+
<div className="flex items-center gap-3 mb-5">
|
| 131 |
+
<div className="w-10 h-10 rounded-xl bg-primary/15 flex items-center justify-center">
|
| 132 |
+
<Brain className="w-5 h-5 text-primary" />
|
| 133 |
+
</div>
|
| 134 |
+
<div>
|
| 135 |
+
<h1 className="text-xl font-semibold">Shared AI Answer</h1>
|
| 136 |
+
<p className="text-sm text-muted-foreground">
|
| 137 |
+
{new Date(answer.created_at).toLocaleString()}
|
| 138 |
+
</p>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<div className="prose-chat text-sm">
|
| 143 |
+
<ReactMarkdown
|
| 144 |
+
remarkPlugins={[remarkGfm]}
|
| 145 |
+
rehypePlugins={[rehypeHighlight]}
|
| 146 |
+
components={markdownComponents}
|
| 147 |
+
>
|
| 148 |
+
{answer.content}
|
| 149 |
+
</ReactMarkdown>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
{answer.sources.length > 0 && (
|
| 153 |
+
<div className="mt-6 border-t border-border/50 pt-4">
|
| 154 |
+
<h2 className="text-sm font-semibold mb-3">Sources</h2>
|
| 155 |
+
<div className="space-y-2">
|
| 156 |
+
{answer.sources.map((source, index) => (
|
| 157 |
+
<div
|
| 158 |
+
key={`${answer.id}-${index}`}
|
| 159 |
+
className="rounded-lg border border-border/50 bg-background/60 p-3"
|
| 160 |
+
>
|
| 161 |
+
<p className="text-xs font-medium mb-1">
|
| 162 |
+
{source.filename} • Page {source.page}
|
| 163 |
+
</p>
|
| 164 |
+
<p className="text-xs text-muted-foreground">{source.text}</p>
|
| 165 |
+
</div>
|
| 166 |
+
))}
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
)}
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
export default function ShareAnswerPage() {
|
| 177 |
+
return (
|
| 178 |
+
<Suspense
|
| 179 |
+
fallback={
|
| 180 |
+
<div className="min-h-screen flex items-center justify-center px-4">
|
| 181 |
+
<div className="text-sm text-muted-foreground">Loading shared answer...</div>
|
| 182 |
+
</div>
|
| 183 |
+
}
|
| 184 |
+
>
|
| 185 |
+
<ShareAnswerContent />
|
| 186 |
+
</Suspense>
|
| 187 |
+
);
|
| 188 |
+
}
|
frontend/src/components/auth/ApiKeyManager.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
| 6 |
+
import { api } from "@/lib/api";
|
| 7 |
+
import { Key, Plus, Trash2, Copy, Check } from "lucide-react";
|
| 8 |
+
|
| 9 |
+
interface ApiKey {
|
| 10 |
+
id: string;
|
| 11 |
+
key_prefix: string;
|
| 12 |
+
created_at: string;
|
| 13 |
+
last_used: string | null;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function ApiKeyManager() {
|
| 17 |
+
const [keys, setKeys] = useState<ApiKey[]>([]);
|
| 18 |
+
const [newKey, setNewKey] = useState<string | null>(null);
|
| 19 |
+
const [loading, setLoading] = useState(false);
|
| 20 |
+
const [copied, setCopied] = useState(false);
|
| 21 |
+
|
| 22 |
+
const fetchKeys = async () => {
|
| 23 |
+
try {
|
| 24 |
+
setLoading(true);
|
| 25 |
+
const data = await api.get<ApiKey[]>("/api/v1/auth/api-keys");
|
| 26 |
+
setKeys(data || []);
|
| 27 |
+
} catch (err) {
|
| 28 |
+
console.error("Failed to load API keys", err);
|
| 29 |
+
} finally {
|
| 30 |
+
setLoading(false);
|
| 31 |
+
}
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
const timer = setTimeout(() => {
|
| 36 |
+
fetchKeys();
|
| 37 |
+
}, 0);
|
| 38 |
+
return () => clearTimeout(timer);
|
| 39 |
+
}, []);
|
| 40 |
+
|
| 41 |
+
const generateKey = async () => {
|
| 42 |
+
try {
|
| 43 |
+
setLoading(true);
|
| 44 |
+
const data = await api.post<{ key: string; api_key: ApiKey }>("/api/v1/auth/api-keys");
|
| 45 |
+
setNewKey(data.key);
|
| 46 |
+
setKeys((prev) => [...prev, data.api_key]);
|
| 47 |
+
} catch (err) {
|
| 48 |
+
console.error("Failed to generate API key", err);
|
| 49 |
+
} finally {
|
| 50 |
+
setLoading(false);
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const revokeKey = async (id: string) => {
|
| 55 |
+
if (!confirm("Are you sure you want to revoke this key? Any integrations using it will immediately break.")) return;
|
| 56 |
+
|
| 57 |
+
try {
|
| 58 |
+
await api.delete(`/api/v1/auth/api-keys/${id}`);
|
| 59 |
+
setKeys((prev) => prev.filter((k) => k.id !== id));
|
| 60 |
+
} catch (err) {
|
| 61 |
+
console.error("Failed to revoke API key", err);
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const copyToClipboard = () => {
|
| 66 |
+
if (newKey) {
|
| 67 |
+
navigator.clipboard.writeText(newKey);
|
| 68 |
+
setCopied(true);
|
| 69 |
+
setTimeout(() => setCopied(false), 2000);
|
| 70 |
+
}
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<Dialog onOpenChange={(open) => { if (!open) setNewKey(null); }}>
|
| 75 |
+
<DialogTrigger
|
| 76 |
+
render={
|
| 77 |
+
<button className="flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
|
| 78 |
+
<Key className="mr-2 h-4 w-4" />
|
| 79 |
+
<span>API Keys</span>
|
| 80 |
+
</button>
|
| 81 |
+
}
|
| 82 |
+
/>
|
| 83 |
+
<DialogContent className="max-w-2xl sm:rounded-2xl border-border/40 p-6 md:p-8 bg-background/95 backdrop-blur-xl shadow-2xl">
|
| 84 |
+
|
| 85 |
+
<DialogHeader>
|
| 86 |
+
<DialogTitle className="text-2xl font-bold tracking-tight">API Keys</DialogTitle>
|
| 87 |
+
<p className="text-sm text-muted-foreground mt-1.5">
|
| 88 |
+
Manage API keys to access the RAG engine programmatically from your own applications or scripts.
|
| 89 |
+
</p>
|
| 90 |
+
</DialogHeader>
|
| 91 |
+
|
| 92 |
+
{newKey && (
|
| 93 |
+
<div className="my-6 p-5 border border-primary/20 bg-primary/5 rounded-xl space-y-3 animate-in fade-in zoom-in-95 duration-300">
|
| 94 |
+
<h4 className="font-semibold text-primary flex items-center gap-2">
|
| 95 |
+
<Key className="w-4 h-4" /> Save your new API key
|
| 96 |
+
</h4>
|
| 97 |
+
<p className="text-sm text-muted-foreground">
|
| 98 |
+
Please copy this key and store it somewhere safe. For security reasons, you will <strong>never</strong> be able to view it again.
|
| 99 |
+
</p>
|
| 100 |
+
<div className="flex items-center gap-2 mt-2">
|
| 101 |
+
<code className="flex-1 bg-background/80 border border-border/50 px-4 py-2.5 rounded-lg text-sm font-mono break-all text-foreground shadow-inner">
|
| 102 |
+
{newKey}
|
| 103 |
+
</code>
|
| 104 |
+
<Button onClick={copyToClipboard} variant={copied ? "default" : "secondary"} className="shrink-0 shadow-sm">
|
| 105 |
+
{copied ? <Check className="w-4 h-4 mr-2" /> : <Copy className="w-4 h-4 mr-2" />}
|
| 106 |
+
{copied ? "Copied!" : "Copy"}
|
| 107 |
+
</Button>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
)}
|
| 111 |
+
|
| 112 |
+
<div className="space-y-4 mt-6">
|
| 113 |
+
<div className="flex items-center justify-between">
|
| 114 |
+
<h3 className="text-sm font-medium text-foreground/80 uppercase tracking-wider">Active Keys</h3>
|
| 115 |
+
<Button onClick={generateKey} disabled={loading} size="sm" className="rounded-full shadow-sm hover:shadow-md transition-shadow">
|
| 116 |
+
<Plus className="w-4 h-4 mr-1.5" />
|
| 117 |
+
Generate New Key
|
| 118 |
+
</Button>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div className="rounded-xl border border-border/50 bg-card overflow-hidden shadow-sm">
|
| 122 |
+
{keys.length === 0 ? (
|
| 123 |
+
<div className="p-8 text-center text-sm text-muted-foreground bg-muted/20">
|
| 124 |
+
<Key className="w-8 h-8 mx-auto mb-3 opacity-20" />
|
| 125 |
+
You don't have any API keys yet.
|
| 126 |
+
</div>
|
| 127 |
+
) : (
|
| 128 |
+
<div className="divide-y divide-border/50">
|
| 129 |
+
{keys.map((key) => (
|
| 130 |
+
<div key={key.id} className="flex items-center justify-between p-4 hover:bg-muted/30 transition-colors group">
|
| 131 |
+
<div className="space-y-1">
|
| 132 |
+
<div className="font-mono text-sm font-medium tracking-tight">
|
| 133 |
+
{key.key_prefix}••••••••••••••••••••••
|
| 134 |
+
</div>
|
| 135 |
+
<div className="text-xs text-muted-foreground flex gap-4">
|
| 136 |
+
<span>Created: {new Date(key.created_at).toLocaleDateString()}</span>
|
| 137 |
+
<span>Last used: {key.last_used ? new Date(key.last_used).toLocaleDateString() : "Never"}</span>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
<Button
|
| 141 |
+
variant="ghost"
|
| 142 |
+
size="icon"
|
| 143 |
+
onClick={() => revokeKey(key.id)}
|
| 144 |
+
className="text-destructive/70 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-all"
|
| 145 |
+
title="Revoke key"
|
| 146 |
+
>
|
| 147 |
+
<Trash2 className="w-4 h-4" />
|
| 148 |
+
</Button>
|
| 149 |
+
</div>
|
| 150 |
+
))}
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
</DialogContent>
|
| 156 |
+
</Dialog>
|
| 157 |
+
);
|
| 158 |
+
}
|
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/chat/MessageBubble.tsx
CHANGED
|
@@ -5,7 +5,8 @@ import ReactMarkdown, { type Components } from "react-markdown";
|
|
| 5 |
import rehypeHighlight from "rehype-highlight";
|
| 6 |
import remarkGfm from "remark-gfm";
|
| 7 |
import type { ChatMsg } from "@/store/chat-store";
|
| 8 |
-
import {
|
|
|
|
| 9 |
import { Button } from "@/components/ui/button";
|
| 10 |
|
| 11 |
interface Props {
|
|
@@ -52,7 +53,10 @@ const markdownComponents: Components = {
|
|
| 52 |
export default function MessageBubble({ message }: Props) {
|
| 53 |
const isUser = message.role === "user";
|
| 54 |
const [copied, setCopied] = useState(false);
|
|
|
|
|
|
|
| 55 |
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
| 56 |
|
| 57 |
const handleCopy = async () => {
|
| 58 |
if (!message.content) return;
|
|
@@ -66,6 +70,31 @@ export default function MessageBubble({ message }: Props) {
|
|
| 66 |
}
|
| 67 |
};
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
return (
|
| 70 |
<div
|
| 71 |
className={`flex gap-3 py-3 animate-fade-in-up ${isUser ? "justify-end" : "justify-start"}`}
|
|
@@ -88,26 +117,50 @@ export default function MessageBubble({ message }: Props) {
|
|
| 88 |
) : (
|
| 89 |
<>
|
| 90 |
{message.content && (
|
| 91 |
-
<
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
)}
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
)}
|
| 110 |
-
<div className={`prose-chat text-sm ${message.content ? "pr-
|
| 111 |
{message.content ? (
|
| 112 |
<ReactMarkdown
|
| 113 |
remarkPlugins={[remarkGfm]}
|
|
|
|
| 5 |
import rehypeHighlight from "rehype-highlight";
|
| 6 |
import remarkGfm from "remark-gfm";
|
| 7 |
import type { ChatMsg } from "@/store/chat-store";
|
| 8 |
+
import { api } from "@/lib/api";
|
| 9 |
+
import { Brain, User, Copy, Check, Share2, Link2, X } from "lucide-react";
|
| 10 |
import { Button } from "@/components/ui/button";
|
| 11 |
|
| 12 |
interface Props {
|
|
|
|
| 53 |
export default function MessageBubble({ message }: Props) {
|
| 54 |
const isUser = message.role === "user";
|
| 55 |
const [copied, setCopied] = useState(false);
|
| 56 |
+
const [shared, setShared] = useState(false);
|
| 57 |
+
const [shareFailed, setShareFailed] = useState(false);
|
| 58 |
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 59 |
+
const sharedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 60 |
|
| 61 |
const handleCopy = async () => {
|
| 62 |
if (!message.content) return;
|
|
|
|
| 70 |
}
|
| 71 |
};
|
| 72 |
|
| 73 |
+
const handleShare = async () => {
|
| 74 |
+
if (!message.content || message.isStreaming) return;
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const data = await api.post<{ message_id: string; share_url: string }>(
|
| 78 |
+
`/api/v1/chat/share/${message.id}`
|
| 79 |
+
);
|
| 80 |
+
await navigator.clipboard.writeText(`${window.location.origin}${data.share_url}`);
|
| 81 |
+
setShared(true);
|
| 82 |
+
setShareFailed(false);
|
| 83 |
+
if (sharedTimeoutRef.current) clearTimeout(sharedTimeoutRef.current);
|
| 84 |
+
sharedTimeoutRef.current = setTimeout(() => {
|
| 85 |
+
setShared(false);
|
| 86 |
+
setShareFailed(false);
|
| 87 |
+
}, 2000);
|
| 88 |
+
} catch {
|
| 89 |
+
setShareFailed(true);
|
| 90 |
+
setShared(false);
|
| 91 |
+
if (sharedTimeoutRef.current) clearTimeout(sharedTimeoutRef.current);
|
| 92 |
+
sharedTimeoutRef.current = setTimeout(() => {
|
| 93 |
+
setShareFailed(false);
|
| 94 |
+
}, 2000);
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
return (
|
| 99 |
<div
|
| 100 |
className={`flex gap-3 py-3 animate-fade-in-up ${isUser ? "justify-end" : "justify-start"}`}
|
|
|
|
| 117 |
) : (
|
| 118 |
<>
|
| 119 |
{message.content && (
|
| 120 |
+
<>
|
| 121 |
+
{!message.isStreaming && (
|
| 122 |
+
<Button
|
| 123 |
+
type="button"
|
| 124 |
+
variant="ghost"
|
| 125 |
+
size="icon-xs"
|
| 126 |
+
className={`absolute top-2 right-2 text-muted-foreground hover:text-foreground transition-opacity ${
|
| 127 |
+
shared || shareFailed
|
| 128 |
+
? "opacity-100"
|
| 129 |
+
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
|
| 130 |
+
}`}
|
| 131 |
+
onClick={handleShare}
|
| 132 |
+
aria-label={shared ? "Link copied" : shareFailed ? "Share failed" : "Share response"}
|
| 133 |
+
>
|
| 134 |
+
{shared ? (
|
| 135 |
+
<Link2 className="w-3.5 h-3.5 text-emerald-400" />
|
| 136 |
+
) : shareFailed ? (
|
| 137 |
+
<X className="w-3.5 h-3.5 text-destructive" />
|
| 138 |
+
) : (
|
| 139 |
+
<Share2 className="w-3.5 h-3.5" />
|
| 140 |
+
)}
|
| 141 |
+
</Button>
|
| 142 |
)}
|
| 143 |
+
<Button
|
| 144 |
+
type="button"
|
| 145 |
+
variant="ghost"
|
| 146 |
+
size="icon-xs"
|
| 147 |
+
className={`absolute top-2 right-9 text-muted-foreground hover:text-foreground transition-opacity ${
|
| 148 |
+
copied
|
| 149 |
+
? "opacity-100"
|
| 150 |
+
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
|
| 151 |
+
}`}
|
| 152 |
+
onClick={handleCopy}
|
| 153 |
+
aria-label={copied ? "Copied" : "Copy response"}
|
| 154 |
+
>
|
| 155 |
+
{copied ? (
|
| 156 |
+
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
| 157 |
+
) : (
|
| 158 |
+
<Copy className="w-3.5 h-3.5" />
|
| 159 |
+
)}
|
| 160 |
+
</Button>
|
| 161 |
+
</>
|
| 162 |
)}
|
| 163 |
+
<div className={`prose-chat text-sm ${message.content ? "pr-14" : ""}`}>
|
| 164 |
{message.content ? (
|
| 165 |
<ReactMarkdown
|
| 166 |
remarkPlugins={[remarkGfm]}
|
frontend/src/components/chat/SourceCard.tsx
CHANGED
|
@@ -4,7 +4,14 @@ import { useState } from "react";
|
|
| 4 |
import type { SourceChunk } from "@/store/chat-store";
|
| 5 |
import { Badge } from "@/components/ui/badge";
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
interface Props {
|
| 10 |
sources: SourceChunk[];
|
|
@@ -13,89 +20,125 @@ interface Props {
|
|
| 13 |
|
| 14 |
export default function SourceCard({ sources = [], onPageClick }: Props) {
|
| 15 |
const [expanded, setExpanded] = useState(false);
|
|
|
|
| 16 |
|
| 17 |
if (sources.length === 0) return null;
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return (
|
| 20 |
<div className="rounded-lg border border-border/50 bg-card/50 overflow-hidden">
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
variant="secondary"
|
| 44 |
-
className="text-[10px] h-5 cursor-pointer hover:bg-primary/20 transition-colors"
|
| 45 |
-
onClick={() => onPageClick(src.page + 1)}
|
| 46 |
-
>
|
| 47 |
-
p.{src.page + 1} • {src.confidence}%
|
| 48 |
-
</Badge>
|
| 49 |
-
))}
|
| 50 |
-
</div>
|
| 51 |
-
)}
|
| 52 |
-
|
| 53 |
-
{/* ── Expanded: Full source cards ─────────────── */}
|
| 54 |
-
{expanded && (
|
| 55 |
-
<div className="border-t border-border/30">
|
| 56 |
-
{sources.map((src, i) => (
|
| 57 |
-
<div
|
| 58 |
-
key={i}
|
| 59 |
-
className="px-3 py-2.5 border-b border-border/20 last:border-b-0 hover:bg-accent/20 transition-colors"
|
| 60 |
-
>
|
| 61 |
-
<div className="flex items-center justify-between mb-1.5">
|
| 62 |
-
<div className="flex items-center gap-2">
|
| 63 |
-
<span className="text-[10px] font-medium text-muted-foreground">
|
| 64 |
-
{src.filename}
|
| 65 |
-
</span>
|
| 66 |
-
<Badge variant="outline" className="text-[9px] h-4 px-1.5">
|
| 67 |
-
Page {src.page + 1}
|
| 68 |
-
</Badge>
|
| 69 |
<Badge
|
| 70 |
variant="secondary"
|
| 71 |
-
className=
|
| 72 |
-
|
| 73 |
-
? "text-emerald-400 bg-emerald-400/10"
|
| 74 |
-
: src.confidence >= 50
|
| 75 |
-
? "text-yellow-400 bg-yellow-400/10"
|
| 76 |
-
: "text-muted-foreground"
|
| 77 |
-
}`}
|
| 78 |
>
|
| 79 |
-
{src.confidence}%
|
| 80 |
</Badge>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
</div>
|
| 82 |
-
<
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
onClick={() => onPageClick(src.page + 1)}
|
| 87 |
>
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
</div>
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
</div>
|
| 96 |
-
))}
|
| 97 |
-
</div>
|
| 98 |
-
)}
|
| 99 |
</div>
|
| 100 |
);
|
| 101 |
}
|
|
|
|
| 4 |
import type { SourceChunk } from "@/store/chat-store";
|
| 5 |
import { Badge } from "@/components/ui/badge";
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
+
import {
|
| 8 |
+
Tooltip,
|
| 9 |
+
TooltipContent,
|
| 10 |
+
TooltipTrigger,
|
| 11 |
+
} from "@/components/ui/tooltip";
|
| 12 |
+
import { ChevronDown, ChevronUp, FileText, Eye, TextQuote } from "lucide-react";
|
| 13 |
+
|
| 14 |
+
const EXCERPT_THRESHOLD = 200;
|
| 15 |
|
| 16 |
interface Props {
|
| 17 |
sources: SourceChunk[];
|
|
|
|
| 20 |
|
| 21 |
export default function SourceCard({ sources = [], onPageClick }: Props) {
|
| 22 |
const [expanded, setExpanded] = useState(false);
|
| 23 |
+
const [excerptOpen, setExcerptOpen] = useState<Set<number>>(new Set());
|
| 24 |
|
| 25 |
if (sources.length === 0) return null;
|
| 26 |
|
| 27 |
+
const toggleExcerpt = (i: number) => {
|
| 28 |
+
const next = new Set(excerptOpen);
|
| 29 |
+
if (next.has(i)) {
|
| 30 |
+
next.delete(i);
|
| 31 |
+
} else {
|
| 32 |
+
next.add(i);
|
| 33 |
+
}
|
| 34 |
+
setExcerptOpen(next);
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
return (
|
| 38 |
<div className="rounded-lg border border-border/50 bg-card/50 overflow-hidden">
|
| 39 |
+
{/* ── Header ──────────────────────────────────── */}
|
| 40 |
+
<button
|
| 41 |
+
onClick={() => setExpanded(!expanded)}
|
| 42 |
+
className="w-full flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/30 transition-colors"
|
| 43 |
+
>
|
| 44 |
+
<span className="flex items-center gap-1.5 text-muted-foreground">
|
| 45 |
+
<FileText className="w-3.5 h-3.5" />
|
| 46 |
+
{sources.length} source{sources.length > 1 ? "s" : ""} cited
|
| 47 |
+
</span>
|
| 48 |
+
{expanded ? (
|
| 49 |
+
<ChevronUp className="w-3.5 h-3.5 text-muted-foreground" />
|
| 50 |
+
) : (
|
| 51 |
+
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
| 52 |
+
)}
|
| 53 |
+
</button>
|
| 54 |
|
| 55 |
+
{/* ── Collapsed: Mini badges with hover preview ── */}
|
| 56 |
+
{!expanded && (
|
| 57 |
+
<div className="px-3 pb-2 flex flex-wrap gap-1">
|
| 58 |
+
{sources.map((src, i) => (
|
| 59 |
+
<Tooltip key={i}>
|
| 60 |
+
<TooltipTrigger className="inline-flex">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<Badge
|
| 62 |
variant="secondary"
|
| 63 |
+
className="text-[10px] h-5 cursor-pointer hover:bg-primary/20 transition-colors"
|
| 64 |
+
onClick={() => onPageClick(src.page + 1)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
>
|
| 66 |
+
p.{src.page + 1} • {src.confidence}%
|
| 67 |
</Badge>
|
| 68 |
+
</TooltipTrigger>
|
| 69 |
+
<TooltipContent
|
| 70 |
+
side="top"
|
| 71 |
+
align="center"
|
| 72 |
+
className="max-w-xs p-2"
|
| 73 |
+
>
|
| 74 |
+
<p className="text-[11px] leading-relaxed line-clamp-6">
|
| 75 |
+
{src.text}
|
| 76 |
+
</p>
|
| 77 |
+
</TooltipContent>
|
| 78 |
+
</Tooltip>
|
| 79 |
+
))}
|
| 80 |
+
</div>
|
| 81 |
+
)}
|
| 82 |
+
|
| 83 |
+
{/* ── Expanded: Full source cards ─────────────── */}
|
| 84 |
+
{expanded && (
|
| 85 |
+
<div className="border-t border-border/30">
|
| 86 |
+
{sources.map((src, i) => (
|
| 87 |
+
<div
|
| 88 |
+
key={i}
|
| 89 |
+
className="px-3 py-2.5 border-b border-border/20 last:border-b-0 hover:bg-accent/20 transition-colors"
|
| 90 |
+
>
|
| 91 |
+
<div className="flex items-center justify-between mb-1.5">
|
| 92 |
+
<div className="flex items-center gap-2">
|
| 93 |
+
<span className="text-[10px] font-medium text-muted-foreground">
|
| 94 |
+
{src.filename}
|
| 95 |
+
</span>
|
| 96 |
+
<Badge variant="outline" className="text-[9px] h-4 px-1.5">
|
| 97 |
+
Page {src.page + 1}
|
| 98 |
+
</Badge>
|
| 99 |
+
<Badge
|
| 100 |
+
variant="secondary"
|
| 101 |
+
className={`text-[9px] h-4 px-1.5 ${
|
| 102 |
+
src.confidence >= 80
|
| 103 |
+
? "text-emerald-400 bg-emerald-400/10"
|
| 104 |
+
: src.confidence >= 50
|
| 105 |
+
? "text-yellow-400 bg-yellow-400/10"
|
| 106 |
+
: "text-muted-foreground"
|
| 107 |
+
}`}
|
| 108 |
+
>
|
| 109 |
+
{src.confidence}% match
|
| 110 |
+
</Badge>
|
| 111 |
+
</div>
|
| 112 |
+
<Button
|
| 113 |
+
variant="ghost"
|
| 114 |
+
size="sm"
|
| 115 |
+
className="h-6 px-2 text-[10px]"
|
| 116 |
+
onClick={() => onPageClick(src.page + 1)}
|
| 117 |
+
>
|
| 118 |
+
<Eye className="w-3 h-3 mr-1" />
|
| 119 |
+
View
|
| 120 |
+
</Button>
|
| 121 |
</div>
|
| 122 |
+
<p
|
| 123 |
+
className={`text-[11px] text-muted-foreground leading-relaxed ${
|
| 124 |
+
excerptOpen.has(i) ? "" : "line-clamp-3"
|
| 125 |
+
}`}
|
|
|
|
| 126 |
>
|
| 127 |
+
{src.text}
|
| 128 |
+
</p>
|
| 129 |
+
{src.text.length > EXCERPT_THRESHOLD && (
|
| 130 |
+
<button
|
| 131 |
+
onClick={() => toggleExcerpt(i)}
|
| 132 |
+
className="mt-1.5 flex items-center gap-1 text-[10px] text-primary/70 hover:text-primary transition-colors"
|
| 133 |
+
>
|
| 134 |
+
<TextQuote className="w-3 h-3" />
|
| 135 |
+
{excerptOpen.has(i) ? "Hide excerpt" : "Show excerpt"}
|
| 136 |
+
</button>
|
| 137 |
+
)}
|
| 138 |
</div>
|
| 139 |
+
))}
|
| 140 |
+
</div>
|
| 141 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
</div>
|
| 143 |
);
|
| 144 |
}
|
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
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useState } from "react";
|
| 4 |
import { useAuth } from "@/lib/auth";
|
|
|
|
| 5 |
import { useRouter } from "next/navigation";
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
@@ -20,6 +21,7 @@ import {
|
|
| 20 |
PanelRightOpen,
|
| 21 |
LogOut,
|
| 22 |
Moon,
|
|
|
|
| 23 |
Sun,
|
| 24 |
Menu,
|
| 25 |
X,
|
|
@@ -48,6 +50,7 @@ export default function Header({
|
|
| 48 |
mobileSheetContent,
|
| 49 |
}: HeaderProps) {
|
| 50 |
const { user, logout } = useAuth();
|
|
|
|
| 51 |
const router = useRouter();
|
| 52 |
const { theme, setTheme } = useTheme();
|
| 53 |
const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
@@ -61,6 +64,19 @@ export default function Header({
|
|
| 61 |
router.replace("/login");
|
| 62 |
};
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
return (
|
| 65 |
<>
|
| 66 |
<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">
|
|
@@ -205,4 +221,4 @@ export default function Header({
|
|
| 205 |
</aside>
|
| 206 |
</>
|
| 207 |
);
|
| 208 |
-
}
|
|
|
|
| 2 |
|
| 3 |
import { useState } from "react";
|
| 4 |
import { useAuth } from "@/lib/auth";
|
| 5 |
+
import { useTranslation } from "react-i18next";
|
| 6 |
import { useRouter } from "next/navigation";
|
| 7 |
import { Button } from "@/components/ui/button";
|
| 8 |
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
|
|
| 21 |
PanelRightOpen,
|
| 22 |
LogOut,
|
| 23 |
Moon,
|
| 24 |
+
Shield,
|
| 25 |
Sun,
|
| 26 |
Menu,
|
| 27 |
X,
|
|
|
|
| 50 |
mobileSheetContent,
|
| 51 |
}: HeaderProps) {
|
| 52 |
const { user, logout } = useAuth();
|
| 53 |
+
const { t, i18n } = useTranslation();
|
| 54 |
const router = useRouter();
|
| 55 |
const { theme, setTheme } = useTheme();
|
| 56 |
const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
|
|
| 64 |
router.replace("/login");
|
| 65 |
};
|
| 66 |
|
| 67 |
+
const languageLabel = (language: string) => {
|
| 68 |
+
switch (language) {
|
| 69 |
+
case "hi":
|
| 70 |
+
return t("common.hindi");
|
| 71 |
+
case "es":
|
| 72 |
+
return t("common.spanish");
|
| 73 |
+
case "fr":
|
| 74 |
+
return t("common.french");
|
| 75 |
+
default:
|
| 76 |
+
return t("common.english");
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
return (
|
| 81 |
<>
|
| 82 |
<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">
|
|
|
|
| 221 |
</aside>
|
| 222 |
</>
|
| 223 |
);
|
| 224 |
+
}
|
frontend/src/components/layout/ThemeProvider.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
| 4 |
+
import { type ThemeProviderProps } from "next-themes";
|
| 5 |
+
|
| 6 |
+
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
| 7 |
+
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
| 8 |
+
}
|
frontend/src/components/layout/ThemeToggle.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useTheme } from "next-themes";
|
| 4 |
+
import { useSyncExternalStore } from "react";
|
| 5 |
+
import { Sun, Moon } from "lucide-react";
|
| 6 |
+
|
| 7 |
+
// useSyncExternalStore with identical server/client snapshots = no hydration mismatch
|
| 8 |
+
const subscribe = () => () => {};
|
| 9 |
+
const getSnapshot = () => true;
|
| 10 |
+
const getServerSnapshot = () => false;
|
| 11 |
+
|
| 12 |
+
export function ThemeToggle() {
|
| 13 |
+
const { theme, setTheme } = useTheme();
|
| 14 |
+
const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
| 15 |
+
|
| 16 |
+
if (!mounted) return null;
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<button
|
| 20 |
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
| 21 |
+
aria-label="Toggle theme"
|
| 22 |
+
className="rounded-md p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
|
| 23 |
+
>
|
| 24 |
+
{theme === "dark" ? (
|
| 25 |
+
<Sun className="h-5 w-5 text-yellow-400" />
|
| 26 |
+
) : (
|
| 27 |
+
<Moon className="h-5 w-5 text-gray-700" />
|
| 28 |
+
)}
|
| 29 |
+
</button>
|
| 30 |
+
);
|
| 31 |
+
}
|
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/api.ts
CHANGED
|
@@ -201,6 +201,29 @@ class ApiClient {
|
|
| 201 |
return res.json();
|
| 202 |
}
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
|
| 205 |
const token = options?.token || this.getToken();
|
| 206 |
const headers: HeadersInit = {};
|
|
|
|
| 201 |
return res.json();
|
| 202 |
}
|
| 203 |
|
| 204 |
+
async put<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
|
| 205 |
+
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 206 |
+
method: "PUT",
|
| 207 |
+
headers: this.getHeaders(options?.token),
|
| 208 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 209 |
+
...options,
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
// Auto-refresh on 401
|
| 213 |
+
if (res.status === 401 && !options?._skipRefresh) {
|
| 214 |
+
const newToken = await this.tryRefreshToken();
|
| 215 |
+
if (newToken) {
|
| 216 |
+
return this.put<T>(path, body, { ...options, token: newToken, _skipRefresh: true });
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
if (!res.ok) {
|
| 221 |
+
throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
return res.json();
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
|
| 228 |
const token = options?.token || this.getToken();
|
| 229 |
const headers: HeadersInit = {};
|