mirror of
https://github.com/google-gemini/gemini-fullstack-langgraph-quickstart.git
synced 2025-08-08 00:41:45 +03:00
init
This commit is contained in:
202
.gitignore
vendored
Normal file
202
.gitignore
vendored
Normal file
@@ -0,0 +1,202 @@
|
||||
# Node / Frontend
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/coverage/
|
||||
.DS_Store
|
||||
*.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Optional backend venv (if created in root)
|
||||
#.venv/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
uv.lock
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
backend/.langgraph_api
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# Stage 1: Build React Frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
# Set working directory for frontend
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy frontend package files and install dependencies
|
||||
COPY frontend/package.json ./
|
||||
COPY frontend/package-lock.json ./
|
||||
# If you use yarn or pnpm, adjust accordingly (e.g., copy yarn.lock or pnpm-lock.yaml and use yarn install or pnpm install)
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the frontend source code
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build the frontend
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Python Backend
|
||||
FROM docker.io/langchain/langgraph-api:3.11
|
||||
|
||||
# -- Copy built frontend from builder stage --
|
||||
# The app.py expects the frontend build to be at ../frontend/dist relative to its own location.
|
||||
# If app.py is at /deps/backend/src/agent/app.py, then ../frontend/dist resolves to /deps/frontend/dist.
|
||||
COPY --from=frontend-builder /app/frontend/dist /deps/frontend/dist
|
||||
# -- End of copying built frontend --
|
||||
|
||||
# -- Adding local package . --
|
||||
ADD backend/ /deps/backend
|
||||
# -- End of local package . --
|
||||
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/backend
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGGRAPH_HTTP='{"app": "/deps/backend/src/agent/app.py:app"}'
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/backend/src/agent/graph.py:graph"}'
|
||||
|
||||
# -- Ensure user deps didn't inadvertently overwrite langgraph-api
|
||||
RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir --no-deps -e /api
|
||||
# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
|
||||
# -- Removing pip from the final image ~<:===~~~ --
|
||||
RUN pip uninstall -y pip setuptools wheel && rm -rf /usr/local/lib/python*/site-packages/pip* /usr/local/lib/python*/site-packages/setuptools* /usr/local/lib/python*/site-packages/wheel* && find /usr/local/bin -name "pip*" -delete
|
||||
# -- End of pip removal --
|
||||
|
||||
WORKDIR /deps/backend
|
||||
20
Makefile
Normal file
20
Makefile
Normal file
@@ -0,0 +1,20 @@
|
||||
.PHONY: help dev-frontend dev-backend dev
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make dev-frontend - Starts the frontend development server (Vite)"
|
||||
@echo " make dev-backend - Starts the backend development server (Uvicorn with reload)"
|
||||
@echo " make dev - Starts both frontend and backend development servers"
|
||||
|
||||
dev-frontend:
|
||||
@echo "Starting frontend development server..."
|
||||
@cd frontend && npm run dev
|
||||
|
||||
dev-backend:
|
||||
@echo "Starting backend development server..."
|
||||
@cd backend && langgraph dev
|
||||
|
||||
# Run frontend and backend concurrently
|
||||
dev:
|
||||
@echo "Starting both frontend and backend development servers..."
|
||||
@make dev-frontend & make dev-backend
|
||||
106
README.md
Normal file
106
README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Gemini DeepSearch LangGraph Quickstart
|
||||
|
||||
This project demonstrates a fullstack application using a React frontend and a LangGraph-powered backend agent. The agent is designed to perform comprehensive research on a user's query by dynamically generating search terms, querying the web using Google Search, reflecting on the results to identify knowledge gaps, and iteratively refining its search until it can provide a well-supported answer with citations. This application serves as an example of building research-augmented conversational AI using LangGraph and Google's Gemini models.
|
||||
|
||||
## Features
|
||||
|
||||
- 💬 Fullstack application with a React frontend and LangGraph backend.
|
||||
- 🧠 Powered by a LangGraph agent for advanced research and conversational AI.
|
||||
- 🔍 Dynamic search query generation using Google Gemini models.
|
||||
- 🌐 Integrated web research via Google Search API.
|
||||
- 🤔 Reflective reasoning to identify knowledge gaps and refine searches.
|
||||
- 📄 Generates answers with citations from gathered sources.
|
||||
- 🔄 Hot-reloading for both frontend and backend development during development.
|
||||
|
||||
## Project Structure
|
||||
|
||||
The project is divided into two main directories:
|
||||
|
||||
- `frontend/`: Contains the React application built with Vite.
|
||||
- `backend/`: Contains the LangGraph/FastAPI application, including the research agent logic.
|
||||
|
||||
## Getting Started: Development and Local Testing
|
||||
|
||||
Follow these steps to get the application running locally for development and testing.
|
||||
|
||||
**1. Prerequisites:**
|
||||
|
||||
- Node.js and npm (or yarn/pnpm)
|
||||
- Python 3.8+
|
||||
- **`GEMINI_API_KEY`**: The backend agent requires a Google Gemini API key.
|
||||
1. Navigate to the `backend/` directory.
|
||||
2. Create a file named `.env` by copying the `backend/.env.example` file.
|
||||
3. Open the `.env` file and add your Gemini API key: `GEMINI_API_KEY="YOUR_ACTUAL_API_KEY"`
|
||||
|
||||
**2. Install Dependencies:**
|
||||
|
||||
**Backend:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install .
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
**3. Run Development Servers:**
|
||||
|
||||
**Backend & Frontend:**
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
This will run the backend and frontend development servers. Open your browser and navigate to the frontend development server URL (e.g., `http://localhost:5173/app`).
|
||||
|
||||
_Alternatively, you can run the backend and frontend development servers separately. For the backend, open a terminal in the `backend/` directory and run `langgraph dev`. The backend API will be available at `http://127.0.0.1:2024`. It will also open a browser window to the LangGraph UI. For the frontend, open a terminal in the `frontend/` directory and run `npm run dev`. The frontend will be available at `http://localhost:5173`._
|
||||
|
||||
## How the Backend Agent Works (High-Level)
|
||||
|
||||
The core of the backend is a LangGraph agent defined in `backend/src/agent/graph.py`. It follows these steps:
|
||||
|
||||

|
||||
|
||||
1. **Generate Initial Queries:** Based on your input, it generates a set of initial search queries using a Gemini model.
|
||||
2. **Web Research:** For each query, it uses the Gemini model with the Google Search API to find relevant web pages.
|
||||
3. **Reflection & Knowledge Gap Analysis:** The agent analyzes the search results to determine if the information is sufficient or if there are knowledge gaps. It uses a Gemini model for this reflection process.
|
||||
4. **Iterative Refinement:** If gaps are found or the information is insufficient, it generates follow-up queries and repeats the web research and reflection steps (up to a configured maximum number of loops).
|
||||
5. **Finalize Answer:** Once the research is deemed sufficient, the agent synthesizes the gathered information into a coherent answer, including citations from the web sources, using a Gemini model.
|
||||
|
||||
## Deployment
|
||||
|
||||
In production, the backend server serves the optimized static frontend build. LangGraph requires a Redis instance and a Postgres database. Redis is used as a pub-sub broker to enable streaming real time output from background runs. Postgres is used to store assistants, threads, runs, persist thread state and long term memory, and to manage the state of the background task queue with 'exactly once' semantics. For more details on how to deploy the backend server, take a look at the [LangGraph Documentation](https://langchain-ai.github.io/langgraph/concepts/deployment_options/). Below is an example of how to build a Docker image that includes the optimized frontend build and the backend server and run it via `docker-compose`.
|
||||
|
||||
_Note: For the docker-compose.yml example you need a LangSmith API key, you can get one from [LangSmith](https://smith.langchain.com/settings)._
|
||||
|
||||
_Note: If you are not running the docker-compose.yml example or exposing the backend server to the public internet, you update the `apiUrl` in the `frontend/src/App.tsx` file your host. Currently the `apiUrl` is set to `http://localhost:8123` for docker-compose or `http://localhost:2024` for development._
|
||||
|
||||
**1. Build the Docker Image:**
|
||||
|
||||
Run the following command from the **project root directory**:
|
||||
```bash
|
||||
docker build -t deepsearch -f Dockerfile .
|
||||
```
|
||||
**2. Run the Production Server:**
|
||||
|
||||
```bash
|
||||
GEMINI_API_KEY=<your_gemini_api_key> LANGSMITH_API_KEY=<your_langsmith_api_key> docker-compose up
|
||||
```
|
||||
|
||||
Open your browser and navigate to `http://localhost:8123/app/` to see the application. The API will be available at `http://localhost:8123`.
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- [React](https://reactjs.org/) (with [Vite](https://vitejs.dev/)) - For the frontend user interface.
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - For styling.
|
||||
- [Shadcn UI](https://ui.shadcn.com/) - For components.
|
||||
- [LangGraph](https://github.com/langchain-ai/langgraph) - For building the backend research agent.
|
||||
- [Google Gemini](https://ai.google.dev/models/gemini) - LLM for query generation, reflection, and answer synthesis.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details.
|
||||
1
backend/.env.example
Normal file
1
backend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
# GEMINI_API_KEY=
|
||||
163
backend/.gitignore
vendored
Normal file
163
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,163 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
uv.lock
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
21
backend/LICENSE
Normal file
21
backend/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Philipp Schmid
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
64
backend/Makefile
Normal file
64
backend/Makefile
Normal file
@@ -0,0 +1,64 @@
|
||||
.PHONY: all format lint test tests test_watch integration_tests docker_tests help extended_tests
|
||||
|
||||
# Default target executed when no arguments are given to make.
|
||||
all: help
|
||||
|
||||
# Define a variable for the test file path.
|
||||
TEST_FILE ?= tests/unit_tests/
|
||||
|
||||
test:
|
||||
uv run --with-editable . pytest $(TEST_FILE)
|
||||
|
||||
test_watch:
|
||||
uv run --with-editable . ptw --snapshot-update --now . -- -vv tests/unit_tests
|
||||
|
||||
test_profile:
|
||||
uv run --with-editable . pytest -vv tests/unit_tests/ --profile-svg
|
||||
|
||||
extended_tests:
|
||||
uv run --with-editable . pytest --only-extended $(TEST_FILE)
|
||||
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
PYTHON_FILES=src/
|
||||
MYPY_CACHE=.mypy_cache
|
||||
lint format: PYTHON_FILES=.
|
||||
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --diff-filter=d main | grep -E '\.py$$|\.ipynb$$')
|
||||
lint_package: PYTHON_FILES=src
|
||||
lint_tests: PYTHON_FILES=tests
|
||||
lint_tests: MYPY_CACHE=.mypy_cache_test
|
||||
|
||||
lint lint_diff lint_package lint_tests:
|
||||
uv run ruff check .
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run mypy --strict $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && uv run mypy --strict $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
|
||||
spell_check:
|
||||
codespell --toml pyproject.toml
|
||||
|
||||
spell_fix:
|
||||
codespell --toml pyproject.toml -w
|
||||
|
||||
######################
|
||||
# HELP
|
||||
######################
|
||||
|
||||
help:
|
||||
@echo '----'
|
||||
@echo 'format - run code formatters'
|
||||
@echo 'lint - run linters'
|
||||
@echo 'test - run unit tests'
|
||||
@echo 'tests - run unit tests'
|
||||
@echo 'test TEST_FILE=<test_file> - run all tests in file'
|
||||
@echo 'test_watch - run unit tests in watch mode'
|
||||
|
||||
10
backend/langgraph.json
Normal file
10
backend/langgraph.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./src/agent/graph.py:graph"
|
||||
},
|
||||
"http": {
|
||||
"app": "./src/agent/app.py:app"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
59
backend/pyproject.toml
Normal file
59
backend/pyproject.toml
Normal file
@@ -0,0 +1,59 @@
|
||||
[project]
|
||||
name = "agent"
|
||||
version = "0.0.1"
|
||||
description = "Backend for the LangGraph agent"
|
||||
authors = [
|
||||
{ name = "Philipp Schmid", email = "schmidphilipp1995@gmail.com" },
|
||||
]
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.11,<4.0"
|
||||
dependencies = [
|
||||
"langgraph>=0.2.6",
|
||||
"langchain>=0.3.19",
|
||||
"langchain-google-genai",
|
||||
"python-dotenv>=1.0.1",
|
||||
"langgraph-sdk>=0.1.57",
|
||||
"langgraph-cli",
|
||||
"langgraph-api",
|
||||
"fastapi",
|
||||
"google-genai",
|
||||
]
|
||||
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["mypy>=1.11.1", "ruff>=0.6.1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=73.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"D", # pydocstyle
|
||||
"D401", # First line should be in imperative mood
|
||||
"T201",
|
||||
"UP",
|
||||
]
|
||||
lint.ignore = [
|
||||
"UP006",
|
||||
"UP007",
|
||||
# We actually do want to import from typing_extensions
|
||||
"UP035",
|
||||
# Relax the convention by _not_ requiring documentation for every function parameter.
|
||||
"D417",
|
||||
"E501",
|
||||
]
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["D", "UP"]
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"langgraph-cli[inmem]>=0.1.71",
|
||||
"pytest>=8.3.5",
|
||||
]
|
||||
3
backend/src/agent/__init__.py
Normal file
3
backend/src/agent/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from agent.graph import graph
|
||||
|
||||
__all__ = ["graph"]
|
||||
61
backend/src/agent/app.py
Normal file
61
backend/src/agent/app.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# mypy: disable - error - code = "no-untyped-def,misc"
|
||||
import pathlib
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import fastapi.exceptions
|
||||
|
||||
# Define the FastAPI app
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def create_frontend_router(build_dir="../frontend/dist"):
|
||||
"""Creates a router to serve the React frontend.
|
||||
|
||||
Args:
|
||||
build_dir: Path to the React build directory relative to this file.
|
||||
|
||||
Returns:
|
||||
A Starlette application serving the frontend.
|
||||
"""
|
||||
build_path = pathlib.Path(__file__).parent.parent.parent / build_dir
|
||||
static_files_path = build_path / "assets" # Vite uses 'assets' subdir
|
||||
|
||||
if not build_path.is_dir() or not (build_path / "index.html").is_file():
|
||||
print(
|
||||
f"WARN: Frontend build directory not found or incomplete at {build_path}. Serving frontend will likely fail."
|
||||
)
|
||||
# Return a dummy router if build isn't ready
|
||||
from starlette.routing import Route
|
||||
|
||||
async def dummy_frontend(request):
|
||||
return Response(
|
||||
"Frontend not built. Run 'npm run build' in the frontend directory.",
|
||||
media_type="text/plain",
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
return Route("/{path:path}", endpoint=dummy_frontend)
|
||||
|
||||
build_dir = pathlib.Path(build_dir)
|
||||
|
||||
react = FastAPI(openapi_url="")
|
||||
react.mount(
|
||||
"/assets", StaticFiles(directory=static_files_path), name="static_assets"
|
||||
)
|
||||
|
||||
@react.get("/{path:path}")
|
||||
async def handle_catch_all(request: Request, path: str):
|
||||
fp = build_path / path
|
||||
if not fp.exists() or not fp.is_file():
|
||||
fp = build_path / "index.html"
|
||||
return fastapi.responses.FileResponse(fp)
|
||||
|
||||
return react
|
||||
|
||||
|
||||
# Mount the frontend under /app to not conflict with the LangGraph API routes
|
||||
app.mount(
|
||||
"/app",
|
||||
create_frontend_router(),
|
||||
name="frontend",
|
||||
)
|
||||
60
backend/src/agent/configuration.py
Normal file
60
backend/src/agent/configuration.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import os
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any, Optional
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
|
||||
class Configuration(BaseModel):
|
||||
"""The configuration for the agent."""
|
||||
|
||||
query_generator_model: str = Field(
|
||||
default="gemini-2.0-flash",
|
||||
metadata={
|
||||
"description": "The name of the language model to use for the agent's query generation."
|
||||
},
|
||||
)
|
||||
|
||||
reflection_model: str = Field(
|
||||
default="gemini-2.5-flash-preview-04-17",
|
||||
metadata={
|
||||
"description": "The name of the language model to use for the agent's reflection."
|
||||
},
|
||||
)
|
||||
|
||||
answer_model: str = Field(
|
||||
default="gemini-2.5-pro-preview-05-06",
|
||||
metadata={
|
||||
"description": "The name of the language model to use for the agent's answer."
|
||||
},
|
||||
)
|
||||
|
||||
number_of_initial_queries: int = Field(
|
||||
default=3,
|
||||
metadata={"description": "The number of initial search queries to generate."},
|
||||
)
|
||||
|
||||
max_research_loops: int = Field(
|
||||
default=2,
|
||||
metadata={"description": "The maximum number of research loops to perform."},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_runnable_config(
|
||||
cls, config: Optional[RunnableConfig] = None
|
||||
) -> "Configuration":
|
||||
"""Create a Configuration instance from a RunnableConfig."""
|
||||
configurable = (
|
||||
config["configurable"] if config and "configurable" in config else {}
|
||||
)
|
||||
|
||||
# Get raw values from environment or config
|
||||
raw_values: dict[str, Any] = {
|
||||
name: os.environ.get(name.upper(), configurable.get(name))
|
||||
for name in cls.model_fields.keys()
|
||||
}
|
||||
|
||||
# Filter out None values
|
||||
values = {k: v for k, v in raw_values.items() if v is not None}
|
||||
|
||||
return cls(**values)
|
||||
293
backend/src/agent/graph.py
Normal file
293
backend/src/agent/graph.py
Normal file
@@ -0,0 +1,293 @@
|
||||
import os
|
||||
|
||||
from agent.tools_and_schemas import SearchQueryList, Reflection
|
||||
from dotenv import load_dotenv
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.types import Send
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.graph import START, END
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from google.genai import Client
|
||||
|
||||
from agent.state import (
|
||||
OverallState,
|
||||
QueryGenerationState,
|
||||
ReflectionState,
|
||||
WebSearchState,
|
||||
)
|
||||
from agent.configuration import Configuration
|
||||
from agent.prompts import (
|
||||
get_current_date,
|
||||
query_writer_instructions,
|
||||
web_searcher_instructions,
|
||||
reflection_instructions,
|
||||
answer_instructions,
|
||||
)
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
from agent.utils import (
|
||||
get_citations,
|
||||
get_research_topic,
|
||||
insert_citation_markers,
|
||||
resolve_urls,
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
if os.getenv("GEMINI_API_KEY") is None:
|
||||
raise ValueError("GEMINI_API_KEY is not set")
|
||||
|
||||
# Used for Google Search API
|
||||
genai_client = Client(api_key=os.getenv("GEMINI_API_KEY"))
|
||||
|
||||
|
||||
# Nodes
|
||||
def generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerationState:
|
||||
"""LangGraph node that generates a search queries based on the User's question.
|
||||
|
||||
Uses Gemini 2.0 Flash to create an optimized search query for web research based on
|
||||
the User's question.
|
||||
|
||||
Args:
|
||||
state: Current graph state containing the User's question
|
||||
config: Configuration for the runnable, including LLM provider settings
|
||||
|
||||
Returns:
|
||||
Dictionary with state update, including search_query key containing the generated query
|
||||
"""
|
||||
configurable = Configuration.from_runnable_config(config)
|
||||
|
||||
# check for custom initial search query count
|
||||
if state.get("initial_search_query_count") is None:
|
||||
state["initial_search_query_count"] = configurable.number_of_initial_queries
|
||||
|
||||
# init Gemini 2.0 Flash
|
||||
llm = ChatGoogleGenerativeAI(
|
||||
model=configurable.query_generator_model,
|
||||
temperature=1.0,
|
||||
max_retries=2,
|
||||
api_key=os.getenv("GEMINI_API_KEY"),
|
||||
)
|
||||
structured_llm = llm.with_structured_output(SearchQueryList)
|
||||
|
||||
# Format the prompt
|
||||
current_date = get_current_date()
|
||||
formatted_prompt = query_writer_instructions.format(
|
||||
current_date=current_date,
|
||||
research_topic=get_research_topic(state["messages"]),
|
||||
number_queries=state["initial_search_query_count"],
|
||||
)
|
||||
# Generate the search queries
|
||||
result = structured_llm.invoke(formatted_prompt)
|
||||
return {"query_list": result.query}
|
||||
|
||||
|
||||
def continue_to_web_research(state: QueryGenerationState):
|
||||
"""LangGraph node that sends the search queries to the web research node.
|
||||
|
||||
This is used to spawn n number of web research nodes, one for each search query.
|
||||
"""
|
||||
return [
|
||||
Send("web_research", {"search_query": search_query, "id": int(idx)})
|
||||
for idx, search_query in enumerate(state["query_list"])
|
||||
]
|
||||
|
||||
|
||||
def web_research(state: WebSearchState, config: RunnableConfig) -> OverallState:
|
||||
"""LangGraph node that performs web research using the native Google Search API tool.
|
||||
|
||||
Executes a web search using the native Google Search API tool in combination with Gemini 2.0 Flash.
|
||||
|
||||
Args:
|
||||
state: Current graph state containing the search query and research loop count
|
||||
config: Configuration for the runnable, including search API settings
|
||||
|
||||
Returns:
|
||||
Dictionary with state update, including sources_gathered, research_loop_count, and web_research_results
|
||||
"""
|
||||
# Configure
|
||||
configurable = Configuration.from_runnable_config(config)
|
||||
formatted_prompt = web_searcher_instructions.format(
|
||||
current_date=get_current_date(),
|
||||
research_topic=state["search_query"],
|
||||
)
|
||||
|
||||
# Uses the google genai client as the langchain client doesn't return grounding metadata
|
||||
response = genai_client.models.generate_content(
|
||||
model=configurable.query_generator_model,
|
||||
contents=formatted_prompt,
|
||||
config={
|
||||
"tools": [{"google_search": {}}],
|
||||
"temperature": 0,
|
||||
},
|
||||
)
|
||||
# resolve the urls to short urls for saving tokens and time
|
||||
resolved_urls = resolve_urls(
|
||||
response.candidates[0].grounding_metadata.grounding_chunks, state["id"]
|
||||
)
|
||||
# Gets the citations and adds them to the generated text
|
||||
citations = get_citations(response, resolved_urls)
|
||||
modified_text = insert_citation_markers(response.text, citations)
|
||||
sources_gathered = [item for citation in citations for item in citation["segments"]]
|
||||
|
||||
return {
|
||||
"sources_gathered": sources_gathered,
|
||||
"search_query": [state["search_query"]],
|
||||
"web_research_result": [modified_text],
|
||||
}
|
||||
|
||||
|
||||
def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState:
|
||||
"""LangGraph node that identifies knowledge gaps and generates potential follow-up queries.
|
||||
|
||||
Analyzes the current summary to identify areas for further research and generates
|
||||
potential follow-up queries. Uses structured output to extract
|
||||
the follow-up query in JSON format.
|
||||
|
||||
Args:
|
||||
state: Current graph state containing the running summary and research topic
|
||||
config: Configuration for the runnable, including LLM provider settings
|
||||
|
||||
Returns:
|
||||
Dictionary with state update, including search_query key containing the generated follow-up query
|
||||
"""
|
||||
configurable = Configuration.from_runnable_config(config)
|
||||
# Increment the research loop count and get the reasoning model
|
||||
state["research_loop_count"] = state.get("research_loop_count", 0) + 1
|
||||
reasoning_model = state.get("reasoning_model") or configurable.reasoning_model
|
||||
|
||||
# Format the prompt
|
||||
current_date = get_current_date()
|
||||
formatted_prompt = reflection_instructions.format(
|
||||
current_date=current_date,
|
||||
research_topic=get_research_topic(state["messages"]),
|
||||
summaries="\n\n---\n\n".join(state["web_research_result"]),
|
||||
)
|
||||
# init Reasoning Model
|
||||
llm = ChatGoogleGenerativeAI(
|
||||
model=reasoning_model,
|
||||
temperature=1.0,
|
||||
max_retries=2,
|
||||
api_key=os.getenv("GEMINI_API_KEY"),
|
||||
)
|
||||
result = llm.with_structured_output(Reflection).invoke(formatted_prompt)
|
||||
|
||||
return {
|
||||
"is_sufficient": result.is_sufficient,
|
||||
"knowledge_gap": result.knowledge_gap,
|
||||
"follow_up_queries": result.follow_up_queries,
|
||||
"research_loop_count": state["research_loop_count"],
|
||||
"number_of_ran_queries": len(state["search_query"]),
|
||||
}
|
||||
|
||||
|
||||
def evaluate_research(
|
||||
state: ReflectionState,
|
||||
config: RunnableConfig,
|
||||
) -> OverallState:
|
||||
"""LangGraph routing function that determines the next step in the research flow.
|
||||
|
||||
Controls the research loop by deciding whether to continue gathering information
|
||||
or to finalize the summary based on the configured maximum number of research loops.
|
||||
|
||||
Args:
|
||||
state: Current graph state containing the research loop count
|
||||
config: Configuration for the runnable, including max_research_loops setting
|
||||
|
||||
Returns:
|
||||
String literal indicating the next node to visit ("web_research" or "finalize_summary")
|
||||
"""
|
||||
configurable = Configuration.from_runnable_config(config)
|
||||
max_research_loops = (
|
||||
state.get("max_research_loops")
|
||||
if state.get("max_research_loops") is not None
|
||||
else configurable.max_research_loops
|
||||
)
|
||||
if state["is_sufficient"] or state["research_loop_count"] >= max_research_loops:
|
||||
return "finalize_answer"
|
||||
else:
|
||||
return [
|
||||
Send(
|
||||
"web_research",
|
||||
{
|
||||
"search_query": follow_up_query,
|
||||
"id": state["number_of_ran_queries"] + int(idx),
|
||||
},
|
||||
)
|
||||
for idx, follow_up_query in enumerate(state["follow_up_queries"])
|
||||
]
|
||||
|
||||
|
||||
def finalize_answer(state: OverallState, config: RunnableConfig):
|
||||
"""LangGraph node that finalizes the research summary.
|
||||
|
||||
Prepares the final output by deduplicating and formatting sources, then
|
||||
combining them with the running summary to create a well-structured
|
||||
research report with proper citations.
|
||||
|
||||
Args:
|
||||
state: Current graph state containing the running summary and sources gathered
|
||||
|
||||
Returns:
|
||||
Dictionary with state update, including running_summary key containing the formatted final summary with sources
|
||||
"""
|
||||
configurable = Configuration.from_runnable_config(config)
|
||||
reasoning_model = state.get("reasoning_model") or configurable.reasoning_model
|
||||
|
||||
# Format the prompt
|
||||
current_date = get_current_date()
|
||||
formatted_prompt = answer_instructions.format(
|
||||
current_date=current_date,
|
||||
research_topic=get_research_topic(state["messages"]),
|
||||
summaries="\n---\n\n".join(state["web_research_result"]),
|
||||
)
|
||||
|
||||
# init Reasoning Model, default to Gemini 2.5 Flash
|
||||
llm = ChatGoogleGenerativeAI(
|
||||
model=reasoning_model,
|
||||
temperature=0,
|
||||
max_retries=2,
|
||||
api_key=os.getenv("GEMINI_API_KEY"),
|
||||
)
|
||||
result = llm.invoke(formatted_prompt)
|
||||
|
||||
# Replace the short urls with the original urls and add all used urls to the sources_gathered
|
||||
unique_sources = []
|
||||
for source in state["sources_gathered"]:
|
||||
if source["short_url"] in result.content:
|
||||
result.content = result.content.replace(
|
||||
source["short_url"], source["value"]
|
||||
)
|
||||
unique_sources.append(source)
|
||||
|
||||
return {
|
||||
"messages": [AIMessage(content=result.content)],
|
||||
"sources_gathered": unique_sources,
|
||||
}
|
||||
|
||||
|
||||
# Create our Agent Graph
|
||||
builder = StateGraph(OverallState, config_schema=Configuration)
|
||||
|
||||
# Define the nodes we will cycle between
|
||||
builder.add_node("generate_query", generate_query)
|
||||
builder.add_node("web_research", web_research)
|
||||
builder.add_node("reflection", reflection)
|
||||
builder.add_node("finalize_answer", finalize_answer)
|
||||
|
||||
# Set the entrypoint as `generate_query`
|
||||
# This means that this node is the first one called
|
||||
builder.add_edge(START, "generate_query")
|
||||
# Add conditional edge to continue with search queries in a parallel branch
|
||||
builder.add_conditional_edges(
|
||||
"generate_query", continue_to_web_research, ["web_research"]
|
||||
)
|
||||
# Reflect on the web research
|
||||
builder.add_edge("web_research", "reflection")
|
||||
# Evaluate the research
|
||||
builder.add_conditional_edges(
|
||||
"reflection", evaluate_research, ["web_research", "finalize_answer"]
|
||||
)
|
||||
# Finalize the answer
|
||||
builder.add_edge("finalize_answer", END)
|
||||
|
||||
graph = builder.compile(name="pro-search-agent")
|
||||
96
backend/src/agent/prompts.py
Normal file
96
backend/src/agent/prompts.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Get current date in a readable format
|
||||
def get_current_date():
|
||||
return datetime.now().strftime("%B %d, %Y")
|
||||
|
||||
|
||||
query_writer_instructions = """Your goal is to generate sophisticated and diverse web search queries. These queries are intended for an advanced automated web research tool capable of analyzing complex results, following links, and synthesizing information.
|
||||
|
||||
Instructions:
|
||||
- Always prefer a single search query, only add another query if the original question requests multiple aspects or elements and one query is not enough.
|
||||
- Each query should focus on one specific aspect of the original question.
|
||||
- Don't produce more than {number_queries} queries.
|
||||
- Queries should be diverse, if the topic is broad, generate more than 1 query.
|
||||
- Don't generate multiple similar queries, 1 is enough.
|
||||
- Query should ensure that the most current information is gathered. The current date is {current_date}.
|
||||
|
||||
Format:
|
||||
- Format your response as a JSON object with ALL three of these exact keys:
|
||||
- "rationale": Brief explanation of why these queries are relevant
|
||||
- "query": A list of search queries
|
||||
|
||||
Example:
|
||||
|
||||
Topic: What revenue grew more last year apple stock or the number of people buying an iphone
|
||||
```json
|
||||
{{
|
||||
"rationale": "To answer this comparative growth question accurately, we need specific data points on Apple's stock performance and iPhone sales metrics. These queries target the precise financial information needed: company revenue trends, product-specific unit sales figures, and stock price movement over the same fiscal period for direct comparison.",
|
||||
"query": ["Apple total revenue growth fiscal year 2024", "iPhone unit sales growth fiscal year 2024", "Apple stock price growth fiscal year 2024"],
|
||||
}}
|
||||
```
|
||||
|
||||
Context: {research_topic}"""
|
||||
|
||||
|
||||
web_searcher_instructions = """Conduct targeted Google Searches to gather the most recent, credible information on "{research_topic}" and synthesize it into a verifiable text artifact.
|
||||
|
||||
Instructions:
|
||||
- Query should ensure that the most current information is gathered. The current date is {current_date}.
|
||||
- Conduct multiple, diverse searches to gather comprehensive information.
|
||||
- Consolidate key findings while meticulously tracking the source(s) for each specific piece of information.
|
||||
- The output should be a well-written summary or report based on your search findings.
|
||||
- Only include the information found in the search results, don't make up any information.
|
||||
|
||||
Research Topic:
|
||||
{research_topic}
|
||||
"""
|
||||
|
||||
reflection_instructions = """You are an expert research assistant analyzing summaries about "{research_topic}".
|
||||
|
||||
Instructions:
|
||||
- Identify knowledge gaps or areas that need deeper exploration and generate a follow-up query. (1 or multiple).
|
||||
- If provided summaries are sufficient to answer the user's question, don't generate a follow-up query.
|
||||
- If there is a knowledge gap, generate a follow-up query that would help expand your understanding.
|
||||
- Focus on technical details, implementation specifics, or emerging trends that weren't fully covered.
|
||||
|
||||
Requirements:
|
||||
- Ensure the follow-up query is self-contained and includes necessary context for web search.
|
||||
|
||||
Output Format:
|
||||
- Format your response as a JSON object with these exact keys:
|
||||
- "is_sufficient": true or false
|
||||
- "knowledge_gap": Describe what information is missing or needs clarification
|
||||
- "follow_up_queries": Write a specific question to address this gap
|
||||
|
||||
Example:
|
||||
```json
|
||||
{{
|
||||
"is_sufficient": true, // or false
|
||||
"knowledge_gap": "The summary lacks information about performance metrics and benchmarks", // "" if is_sufficient is true
|
||||
"follow_up_queries": ["What are typical performance benchmarks and metrics used to evaluate [specific technology]?"] // [] if is_sufficient is true
|
||||
}}
|
||||
```
|
||||
|
||||
Reflect carefully on the Summaries to identify knowledge gaps and produce a follow-up query. Then, produce your output following this JSON format:
|
||||
|
||||
Summaries:
|
||||
{summaries}
|
||||
"""
|
||||
|
||||
answer_instructions = """Generate a high-quality answer to the user's question based on the provided summaries.
|
||||
|
||||
Instructions:
|
||||
- The current date is {current_date}.
|
||||
- You are the finaly step of a multi-step research process, don't mention that you are the final step.
|
||||
- You have access to all the information gathered from the previous steps.
|
||||
- You have access to the user's question.
|
||||
- Generate a high-quality answer to the user's question based on the provided summaries and the user's question.
|
||||
- you MUST include all the citations from the summaries in the answer correctly.
|
||||
|
||||
User Context:
|
||||
- {research_topic}
|
||||
|
||||
Summaries:
|
||||
{summaries}"""
|
||||
50
backend/src/agent/state.py
Normal file
50
backend/src/agent/state.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TypedDict
|
||||
|
||||
from langgraph.graph import add_messages
|
||||
from typing_extensions import Annotated
|
||||
|
||||
|
||||
import operator
|
||||
from dataclasses import dataclass, field
|
||||
from typing_extensions import Annotated
|
||||
|
||||
|
||||
class OverallState(TypedDict):
|
||||
messages: Annotated[list, add_messages]
|
||||
search_query: Annotated[list, operator.add]
|
||||
web_research_result: Annotated[list, operator.add]
|
||||
sources_gathered: Annotated[list, operator.add]
|
||||
initial_search_query_count: int
|
||||
max_research_loops: int
|
||||
research_loop_count: int
|
||||
reasoning_model: str
|
||||
|
||||
|
||||
class ReflectionState(TypedDict):
|
||||
is_sufficient: bool
|
||||
knowledge_gap: str
|
||||
follow_up_queries: Annotated[list, operator.add]
|
||||
research_loop_count: int
|
||||
number_of_ran_queries: int
|
||||
|
||||
|
||||
class Query(TypedDict):
|
||||
query: str
|
||||
rationale: str
|
||||
|
||||
|
||||
class QueryGenerationState(TypedDict):
|
||||
query_list: list[Query]
|
||||
|
||||
|
||||
class WebSearchState(TypedDict):
|
||||
search_query: str
|
||||
id: str
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class SearchStateOutput:
|
||||
running_summary: str = field(default=None) # Final report
|
||||
23
backend/src/agent/tools_and_schemas.py
Normal file
23
backend/src/agent/tools_and_schemas.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from typing import List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchQueryList(BaseModel):
|
||||
query: List[str] = Field(
|
||||
description="A list of search queries to be used for web research."
|
||||
)
|
||||
rationale: str = Field(
|
||||
description="A brief explanation of why these queries are relevant to the research topic."
|
||||
)
|
||||
|
||||
|
||||
class Reflection(BaseModel):
|
||||
is_sufficient: bool = Field(
|
||||
description="Whether the provided summaries are sufficient to answer the user's question."
|
||||
)
|
||||
knowledge_gap: str = Field(
|
||||
description="A description of what information is missing or needs clarification."
|
||||
)
|
||||
follow_up_queries: List[str] = Field(
|
||||
description="A list of follow-up queries to address the knowledge gap."
|
||||
)
|
||||
166
backend/src/agent/utils.py
Normal file
166
backend/src/agent/utils.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from typing import Any, Dict, List
|
||||
from langchain_core.messages import AnyMessage, AIMessage, HumanMessage
|
||||
|
||||
|
||||
def get_research_topic(messages: List[AnyMessage]) -> str:
|
||||
"""
|
||||
Get the research topic from the messages.
|
||||
"""
|
||||
# check if request has a history and combine the messages into a single string
|
||||
if len(messages) == 1:
|
||||
research_topic = messages[-1].content
|
||||
else:
|
||||
research_topic = ""
|
||||
for message in messages:
|
||||
if isinstance(message, HumanMessage):
|
||||
research_topic += f"User: {message.content}\n"
|
||||
elif isinstance(message, AIMessage):
|
||||
research_topic += f"Assistant: {message.content}\n"
|
||||
return research_topic
|
||||
|
||||
|
||||
def resolve_urls(urls_to_resolve: List[Any], id: int) -> Dict[str, str]:
|
||||
"""
|
||||
Create a map of the vertex ai search urls (very long) to a short url with a unique id for each url.
|
||||
Ensures each original URL gets a consistent shortened form while maintaining uniqueness.
|
||||
"""
|
||||
prefix = f"https://vertexaisearch.cloud.google.com/id/"
|
||||
urls = [site.web.uri for site in urls_to_resolve]
|
||||
|
||||
# Create a dictionary that maps each unique URL to its first occurrence index
|
||||
resolved_map = {}
|
||||
for idx, url in enumerate(urls):
|
||||
if url not in resolved_map:
|
||||
resolved_map[url] = f"{prefix}{id}-{idx}"
|
||||
|
||||
return resolved_map
|
||||
|
||||
|
||||
def insert_citation_markers(text, citations_list):
|
||||
"""
|
||||
Inserts citation markers into a text string based on start and end indices.
|
||||
|
||||
Args:
|
||||
text (str): The original text string.
|
||||
citations_list (list): A list of dictionaries, where each dictionary
|
||||
contains 'start_index', 'end_index', and
|
||||
'segment_string' (the marker to insert).
|
||||
Indices are assumed to be for the original text.
|
||||
|
||||
Returns:
|
||||
str: The text with citation markers inserted.
|
||||
"""
|
||||
# Sort citations by end_index in descending order.
|
||||
# If end_index is the same, secondary sort by start_index descending.
|
||||
# This ensures that insertions at the end of the string don't affect
|
||||
# the indices of earlier parts of the string that still need to be processed.
|
||||
sorted_citations = sorted(
|
||||
citations_list, key=lambda c: (c["end_index"], c["start_index"]), reverse=True
|
||||
)
|
||||
|
||||
modified_text = text
|
||||
for citation_info in sorted_citations:
|
||||
# These indices refer to positions in the *original* text,
|
||||
# but since we iterate from the end, they remain valid for insertion
|
||||
# relative to the parts of the string already processed.
|
||||
end_idx = citation_info["end_index"]
|
||||
marker_to_insert = ""
|
||||
for segment in citation_info["segments"]:
|
||||
marker_to_insert += f" [{segment['label']}]({segment['short_url']})"
|
||||
# Insert the citation marker at the original end_idx position
|
||||
modified_text = (
|
||||
modified_text[:end_idx] + marker_to_insert + modified_text[end_idx:]
|
||||
)
|
||||
|
||||
return modified_text
|
||||
|
||||
|
||||
def get_citations(response, resolved_urls_map):
|
||||
"""
|
||||
Extracts and formats citation information from a Gemini model's response.
|
||||
|
||||
This function processes the grounding metadata provided in the response to
|
||||
construct a list of citation objects. Each citation object includes the
|
||||
start and end indices of the text segment it refers to, and a string
|
||||
containing formatted markdown links to the supporting web chunks.
|
||||
|
||||
Args:
|
||||
response: The response object from the Gemini model, expected to have
|
||||
a structure including `candidates[0].grounding_metadata`.
|
||||
It also relies on a `resolved_map` being available in its
|
||||
scope to map chunk URIs to resolved URLs.
|
||||
|
||||
Returns:
|
||||
list: A list of dictionaries, where each dictionary represents a citation
|
||||
and has the following keys:
|
||||
- "start_index" (int): The starting character index of the cited
|
||||
segment in the original text. Defaults to 0
|
||||
if not specified.
|
||||
- "end_index" (int): The character index immediately after the
|
||||
end of the cited segment (exclusive).
|
||||
- "segments" (list[str]): A list of individual markdown-formatted
|
||||
links for each grounding chunk.
|
||||
- "segment_string" (str): A concatenated string of all markdown-
|
||||
formatted links for the citation.
|
||||
Returns an empty list if no valid candidates or grounding supports
|
||||
are found, or if essential data is missing.
|
||||
"""
|
||||
citations = []
|
||||
|
||||
# Ensure response and necessary nested structures are present
|
||||
if not response or not response.candidates:
|
||||
return citations
|
||||
|
||||
candidate = response.candidates[0]
|
||||
if (
|
||||
not hasattr(candidate, "grounding_metadata")
|
||||
or not candidate.grounding_metadata
|
||||
or not hasattr(candidate.grounding_metadata, "grounding_supports")
|
||||
):
|
||||
return citations
|
||||
|
||||
for support in candidate.grounding_metadata.grounding_supports:
|
||||
citation = {}
|
||||
|
||||
# Ensure segment information is present
|
||||
if not hasattr(support, "segment") or support.segment is None:
|
||||
continue # Skip this support if segment info is missing
|
||||
|
||||
start_index = (
|
||||
support.segment.start_index
|
||||
if support.segment.start_index is not None
|
||||
else 0
|
||||
)
|
||||
|
||||
# Ensure end_index is present to form a valid segment
|
||||
if support.segment.end_index is None:
|
||||
continue # Skip if end_index is missing, as it's crucial
|
||||
|
||||
# Add 1 to end_index to make it an exclusive end for slicing/range purposes
|
||||
# (assuming the API provides an inclusive end_index)
|
||||
citation["start_index"] = start_index
|
||||
citation["end_index"] = support.segment.end_index
|
||||
|
||||
citation["segments"] = []
|
||||
if (
|
||||
hasattr(support, "grounding_chunk_indices")
|
||||
and support.grounding_chunk_indices
|
||||
):
|
||||
for ind in support.grounding_chunk_indices:
|
||||
try:
|
||||
chunk = candidate.grounding_metadata.grounding_chunks[ind]
|
||||
resolved_url = resolved_urls_map.get(chunk.web.uri, None)
|
||||
citation["segments"].append(
|
||||
{
|
||||
"label": chunk.web.title.split(".")[:-1][0],
|
||||
"short_url": resolved_url,
|
||||
"value": chunk.web.uri,
|
||||
}
|
||||
)
|
||||
except (IndexError, AttributeError, NameError):
|
||||
# Handle cases where chunk, web, uri, or resolved_map might be problematic
|
||||
# For simplicity, we'll just skip adding this particular segment link
|
||||
# In a production system, you might want to log this.
|
||||
pass
|
||||
citations.append(citation)
|
||||
return citations
|
||||
530
backend/test-agent.ipynb
Normal file
530
backend/test-agent.ipynb
Normal file
File diff suppressed because one or more lines are too long
BIN
deepsearch.png
Normal file
BIN
deepsearch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
services:
|
||||
langgraph-redis:
|
||||
image: docker.io/redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-postgres:
|
||||
image: docker.io/postgres:16
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- langgraph-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
interval: 5s
|
||||
langgraph-api:
|
||||
image: deepsearch
|
||||
ports:
|
||||
- "8123:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||
LANGSMITH_API_KEY: ${LANGSMITH_API_KEY}
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
28
frontend/eslint.config.js
Normal file
28
frontend/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5829
frontend/package-lock.json
generated
Normal file
5829
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/package.json
Normal file
46
frontend/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/core": "^0.3.55",
|
||||
"@langchain/langgraph-sdk": "^0.0.74",
|
||||
"@radix-ui/react-scroll-area": "^1.2.8",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.508.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-router-dom": "^7.5.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@types/node": "^22.15.17",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.3",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"vite": "^6.3.4"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
184
frontend/src/App.tsx
Normal file
184
frontend/src/App.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { ProcessedEvent } from "@/components/ActivityTimeline";
|
||||
import { WelcomeScreen } from "@/components/WelcomeScreen";
|
||||
import { ChatMessagesView } from "@/components/ChatMessagesView";
|
||||
|
||||
export default function App() {
|
||||
const [processedEventsTimeline, setProcessedEventsTimeline] = useState<
|
||||
ProcessedEvent[]
|
||||
>([]);
|
||||
const [historicalActivities, setHistoricalActivities] = useState<
|
||||
Record<string, ProcessedEvent[]>
|
||||
>({});
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const hasFinalizeEventOccurredRef = useRef(false);
|
||||
|
||||
const thread = useStream<{
|
||||
messages: Message[];
|
||||
initial_search_query_count: number;
|
||||
max_research_loops: number;
|
||||
reasoning_model: string;
|
||||
}>({
|
||||
apiUrl: import.meta.env.DEV
|
||||
? "http://localhost:2024"
|
||||
: "http://localhost:8123",
|
||||
assistantId: "agent",
|
||||
messagesKey: "messages",
|
||||
onFinish: (event: any) => {
|
||||
console.log(event);
|
||||
},
|
||||
onUpdateEvent: (event: any) => {
|
||||
let processedEvent: ProcessedEvent | null = null;
|
||||
if (event.generate_query) {
|
||||
processedEvent = {
|
||||
title: "Generating Search Queries",
|
||||
data: event.generate_query.query_list.join(", "),
|
||||
};
|
||||
} else if (event.web_research) {
|
||||
const sources = event.web_research.sources_gathered || [];
|
||||
const numSources = sources.length;
|
||||
const uniqueLabels = [
|
||||
...new Set(sources.map((s: any) => s.label).filter(Boolean)),
|
||||
];
|
||||
const exampleLabels = uniqueLabels.slice(0, 3).join(", ");
|
||||
processedEvent = {
|
||||
title: "Web Research",
|
||||
data: `Gathered ${numSources} sources. Related to: ${
|
||||
exampleLabels || "N/A"
|
||||
}.`,
|
||||
};
|
||||
} else if (event.reflection) {
|
||||
processedEvent = {
|
||||
title: "Reflection",
|
||||
data: event.reflection.is_sufficient
|
||||
? "Search successful, generating final answer."
|
||||
: `Need more information, searching for ${event.reflection.follow_up_queries.join(
|
||||
", "
|
||||
)}`,
|
||||
};
|
||||
} else if (event.finalize_answer) {
|
||||
processedEvent = {
|
||||
title: "Finalizing Answer",
|
||||
data: "Composing and presenting the final answer.",
|
||||
};
|
||||
hasFinalizeEventOccurredRef.current = true;
|
||||
}
|
||||
if (processedEvent) {
|
||||
setProcessedEventsTimeline((prevEvents) => [
|
||||
...prevEvents,
|
||||
processedEvent!,
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
const scrollViewport = scrollAreaRef.current.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
);
|
||||
if (scrollViewport) {
|
||||
scrollViewport.scrollTop = scrollViewport.scrollHeight;
|
||||
}
|
||||
}
|
||||
}, [thread.messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasFinalizeEventOccurredRef.current &&
|
||||
!thread.isLoading &&
|
||||
thread.messages.length > 0
|
||||
) {
|
||||
const lastMessage = thread.messages[thread.messages.length - 1];
|
||||
if (lastMessage && lastMessage.type === "ai" && lastMessage.id) {
|
||||
setHistoricalActivities((prev) => ({
|
||||
...prev,
|
||||
[lastMessage.id!]: [...processedEventsTimeline],
|
||||
}));
|
||||
}
|
||||
hasFinalizeEventOccurredRef.current = false;
|
||||
}
|
||||
}, [thread.messages, thread.isLoading, processedEventsTimeline]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(submittedInputValue: string, effort: string, model: string) => {
|
||||
if (!submittedInputValue.trim()) return;
|
||||
setProcessedEventsTimeline([]);
|
||||
hasFinalizeEventOccurredRef.current = false;
|
||||
|
||||
// convert effort to, initial_search_query_count and max_research_loops
|
||||
// low means max 1 loop and 1 query
|
||||
// medium means max 3 loops and 3 queries
|
||||
// high means max 10 loops and 5 queries
|
||||
let initial_search_query_count = 0;
|
||||
let max_research_loops = 0;
|
||||
switch (effort) {
|
||||
case "low":
|
||||
initial_search_query_count = 1;
|
||||
max_research_loops = 1;
|
||||
break;
|
||||
case "medium":
|
||||
initial_search_query_count = 3;
|
||||
max_research_loops = 3;
|
||||
break;
|
||||
case "high":
|
||||
initial_search_query_count = 5;
|
||||
max_research_loops = 10;
|
||||
break;
|
||||
}
|
||||
|
||||
const newMessages: Message[] = [
|
||||
...(thread.messages || []),
|
||||
{
|
||||
type: "human",
|
||||
content: submittedInputValue,
|
||||
id: Date.now().toString(),
|
||||
},
|
||||
];
|
||||
thread.submit({
|
||||
messages: newMessages,
|
||||
initial_search_query_count: initial_search_query_count,
|
||||
max_research_loops: max_research_loops,
|
||||
reasoning_model: model,
|
||||
});
|
||||
},
|
||||
[thread]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
thread.stop();
|
||||
window.location.reload();
|
||||
}, [thread]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-neutral-800 text-neutral-100 font-sans antialiased">
|
||||
<main className="flex-1 flex flex-col overflow-hidden max-w-4xl mx-auto w-full">
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto ${
|
||||
thread.messages.length === 0 ? "flex" : ""
|
||||
}`}
|
||||
>
|
||||
{thread.messages.length === 0 ? (
|
||||
<WelcomeScreen
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={thread.isLoading}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessagesView
|
||||
messages={thread.messages}
|
||||
isLoading={thread.isLoading}
|
||||
scrollAreaRef={scrollAreaRef}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
liveActivityEvents={processedEventsTimeline}
|
||||
historicalActivities={historicalActivities}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/ActivityTimeline.tsx
Normal file
146
frontend/src/components/ActivityTimeline.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Loader2,
|
||||
Activity,
|
||||
Info,
|
||||
Search,
|
||||
TextSearch,
|
||||
Brain,
|
||||
Pen,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface ProcessedEvent {
|
||||
title: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
processedEvents: ProcessedEvent[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function ActivityTimeline({
|
||||
processedEvents,
|
||||
isLoading,
|
||||
}: ActivityTimelineProps) {
|
||||
const [isTimelineCollapsed, setIsTimelineCollapsed] =
|
||||
useState<boolean>(false);
|
||||
const getEventIcon = (title: string, index: number) => {
|
||||
if (index === 0 && isLoading && processedEvents.length === 0) {
|
||||
return <Loader2 className="h-4 w-4 text-neutral-400 animate-spin" />;
|
||||
}
|
||||
if (title.toLowerCase().includes("generating")) {
|
||||
return <TextSearch className="h-4 w-4 text-neutral-400" />;
|
||||
} else if (title.toLowerCase().includes("thinking")) {
|
||||
return <Loader2 className="h-4 w-4 text-neutral-400 animate-spin" />;
|
||||
} else if (title.toLowerCase().includes("reflection")) {
|
||||
return <Brain className="h-4 w-4 text-neutral-400" />;
|
||||
} else if (title.toLowerCase().includes("research")) {
|
||||
return <Search className="h-4 w-4 text-neutral-400" />;
|
||||
} else if (title.toLowerCase().includes("finalizing")) {
|
||||
return <Pen className="h-4 w-4 text-neutral-400" />;
|
||||
}
|
||||
return <Activity className="h-4 w-4 text-neutral-400" />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && processedEvents.length !== 0) {
|
||||
setIsTimelineCollapsed(true);
|
||||
}
|
||||
}, [isLoading, processedEvents]);
|
||||
|
||||
return (
|
||||
<Card className="border-none rounded-lg bg-neutral-700 max-h-96">
|
||||
<CardHeader>
|
||||
<CardDescription className="flex items-center justify-between">
|
||||
<div
|
||||
className="flex items-center justify-start text-sm w-full cursor-pointer gap-2 text-neutral-100"
|
||||
onClick={() => setIsTimelineCollapsed(!isTimelineCollapsed)}
|
||||
>
|
||||
Research
|
||||
{isTimelineCollapsed ? (
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{!isTimelineCollapsed && (
|
||||
<ScrollArea className="max-h-96 overflow-y-auto">
|
||||
<CardContent>
|
||||
{isLoading && processedEvents.length === 0 && (
|
||||
<div className="relative pl-8 pb-4">
|
||||
<div className="absolute left-3 top-3.5 h-full w-0.5 bg-neutral-800" />
|
||||
<div className="absolute left-0.5 top-2 h-5 w-5 rounded-full bg-neutral-800 flex items-center justify-center ring-4 ring-neutral-900">
|
||||
<Loader2 className="h-3 w-3 text-neutral-400 animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-neutral-300 font-medium">
|
||||
Searching...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{processedEvents.length > 0 ? (
|
||||
<div className="space-y-0">
|
||||
{processedEvents.map((eventItem, index) => (
|
||||
<div key={index} className="relative pl-8 pb-4">
|
||||
{index < processedEvents.length - 1 ||
|
||||
(isLoading && index === processedEvents.length - 1) ? (
|
||||
<div className="absolute left-3 top-3.5 h-full w-0.5 bg-neutral-600" />
|
||||
) : null}
|
||||
<div className="absolute left-0.5 top-2 h-6 w-6 rounded-full bg-neutral-600 flex items-center justify-center ring-4 ring-neutral-700">
|
||||
{getEventIcon(eventItem.title, index)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-neutral-200 font-medium mb-0.5">
|
||||
{eventItem.title}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-300 leading-relaxed">
|
||||
{typeof eventItem.data === "string"
|
||||
? eventItem.data
|
||||
: Array.isArray(eventItem.data)
|
||||
? (eventItem.data as string[]).join(", ")
|
||||
: JSON.stringify(eventItem.data)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && processedEvents.length > 0 && (
|
||||
<div className="relative pl-8 pb-4">
|
||||
<div className="absolute left-0.5 top-2 h-5 w-5 rounded-full bg-neutral-600 flex items-center justify-center ring-4 ring-neutral-700">
|
||||
<Loader2 className="h-3 w-3 text-neutral-400 animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-neutral-300 font-medium">
|
||||
Searching...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !isLoading ? ( // Only show "No activity" if not loading and no events
|
||||
<div className="flex flex-col items-center justify-center h-full text-neutral-500 pt-10">
|
||||
<Info className="h-6 w-6 mb-3" />
|
||||
<p className="text-sm">No activity to display.</p>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
Timeline will update during processing.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
321
frontend/src/components/ChatMessagesView.tsx
Normal file
321
frontend/src/components/ChatMessagesView.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import type React from "react";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, Copy, CopyCheck } from "lucide-react";
|
||||
import { InputForm } from "@/components/InputForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ActivityTimeline,
|
||||
ProcessedEvent,
|
||||
} from "@/components/ActivityTimeline"; // Assuming ActivityTimeline is in the same dir or adjust path
|
||||
|
||||
// Markdown component props type from former ReportView
|
||||
type MdComponentProps = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
// Markdown components (from former ReportView.tsx)
|
||||
const mdComponents = {
|
||||
h1: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<h1 className={cn("text-2xl font-bold mt-4 mb-2", className)} {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<h2 className={cn("text-xl font-bold mt-3 mb-2", className)} {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<h3 className={cn("text-lg font-bold mt-3 mb-1", className)} {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<p className={cn("mb-3 leading-7", className)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ className, children, href, ...props }: MdComponentProps) => (
|
||||
<Badge className="text-xs mx-0.5">
|
||||
<a
|
||||
className={cn("text-blue-400 hover:text-blue-300 text-xs", className)}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Badge>
|
||||
),
|
||||
ul: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<ul className={cn("list-disc pl-6 mb-3", className)} {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<ol className={cn("list-decimal pl-6 mb-3", className)} {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<li className={cn("mb-1", className)} {...props}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
blockquote: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"border-l-4 border-neutral-600 pl-4 italic my-3 text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<code
|
||||
className={cn(
|
||||
"bg-neutral-900 rounded px-1 py-0.5 font-mono text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"bg-neutral-900 p-3 rounded-lg overflow-x-auto font-mono text-xs my-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
hr: ({ className, ...props }: MdComponentProps) => (
|
||||
<hr className={cn("border-neutral-600 my-4", className)} {...props} />
|
||||
),
|
||||
table: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table className={cn("border-collapse w-full", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<th
|
||||
className={cn(
|
||||
"border border-neutral-600 px-3 py-2 text-left font-bold",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ className, children, ...props }: MdComponentProps) => (
|
||||
<td
|
||||
className={cn("border border-neutral-600 px-3 py-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
};
|
||||
|
||||
// Props for HumanMessageBubble
|
||||
interface HumanMessageBubbleProps {
|
||||
message: Message;
|
||||
mdComponents: typeof mdComponents;
|
||||
}
|
||||
|
||||
// HumanMessageBubble Component
|
||||
const HumanMessageBubble: React.FC<HumanMessageBubbleProps> = ({
|
||||
message,
|
||||
mdComponents,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`text-white rounded-3xl break-words min-h-7 bg-neutral-700 max-w-[100%] sm:max-w-[90%] px-4 pt-3 rounded-br-lg`}
|
||||
>
|
||||
<ReactMarkdown components={mdComponents}>
|
||||
{typeof message.content === "string"
|
||||
? message.content
|
||||
: JSON.stringify(message.content)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Props for AiMessageBubble
|
||||
interface AiMessageBubbleProps {
|
||||
message: Message;
|
||||
historicalActivity: ProcessedEvent[] | undefined;
|
||||
liveActivity: ProcessedEvent[] | undefined;
|
||||
isLastMessage: boolean;
|
||||
isOverallLoading: boolean;
|
||||
mdComponents: typeof mdComponents;
|
||||
handleCopy: (text: string, messageId: string) => void;
|
||||
copiedMessageId: string | null;
|
||||
}
|
||||
|
||||
// AiMessageBubble Component
|
||||
const AiMessageBubble: React.FC<AiMessageBubbleProps> = ({
|
||||
message,
|
||||
historicalActivity,
|
||||
liveActivity,
|
||||
isLastMessage,
|
||||
isOverallLoading,
|
||||
mdComponents,
|
||||
handleCopy,
|
||||
copiedMessageId,
|
||||
}) => {
|
||||
// Determine which activity events to show and if it's for a live loading message
|
||||
const activityForThisBubble =
|
||||
isLastMessage && isOverallLoading ? liveActivity : historicalActivity;
|
||||
const isLiveActivityForThisBubble = isLastMessage && isOverallLoading;
|
||||
|
||||
return (
|
||||
<div className={`relative break-words flex flex-col`}>
|
||||
{activityForThisBubble && activityForThisBubble.length > 0 && (
|
||||
<div className="mb-3 border-b border-neutral-700 pb-3 text-xs">
|
||||
<ActivityTimeline
|
||||
processedEvents={activityForThisBubble}
|
||||
isLoading={isLiveActivityForThisBubble}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ReactMarkdown components={mdComponents}>
|
||||
{typeof message.content === "string"
|
||||
? message.content
|
||||
: JSON.stringify(message.content)}
|
||||
</ReactMarkdown>
|
||||
<Button
|
||||
variant="default"
|
||||
className="cursor-pointer bg-neutral-700 border-neutral-600 text-neutral-300 self-end"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: JSON.stringify(message.content),
|
||||
message.id!
|
||||
)
|
||||
}
|
||||
>
|
||||
{copiedMessageId === message.id ? "Copied" : "Copy"}
|
||||
{copiedMessageId === message.id ? <CopyCheck /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChatMessagesViewProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
|
||||
onSubmit: (inputValue: string, effort: string, model: string) => void;
|
||||
onCancel: () => void;
|
||||
liveActivityEvents: ProcessedEvent[];
|
||||
historicalActivities: Record<string, ProcessedEvent[]>;
|
||||
}
|
||||
|
||||
export function ChatMessagesView({
|
||||
messages,
|
||||
isLoading,
|
||||
scrollAreaRef,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
liveActivityEvents,
|
||||
historicalActivities,
|
||||
}: ChatMessagesViewProps) {
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = async (text: string, messageId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedMessageId(messageId);
|
||||
setTimeout(() => setCopiedMessageId(null), 2000); // Reset after 2 seconds
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ScrollArea className="flex-grow" ref={scrollAreaRef}>
|
||||
<div className="p-4 md:p-6 space-y-2 max-w-4xl mx-auto pt-16">
|
||||
{messages.map((message, index) => {
|
||||
const isLast = index === messages.length - 1;
|
||||
return (
|
||||
<div key={message.id || `msg-${index}`} className="space-y-3">
|
||||
<div
|
||||
className={`flex items-start gap-3 ${
|
||||
message.type === "human" ? "justify-end" : ""
|
||||
}`}
|
||||
>
|
||||
{message.type === "human" ? (
|
||||
<HumanMessageBubble
|
||||
message={message}
|
||||
mdComponents={mdComponents}
|
||||
/>
|
||||
) : (
|
||||
<AiMessageBubble
|
||||
message={message}
|
||||
historicalActivity={historicalActivities[message.id!]}
|
||||
liveActivity={liveActivityEvents} // Pass global live events
|
||||
isLastMessage={isLast}
|
||||
isOverallLoading={isLoading} // Pass global loading state
|
||||
mdComponents={mdComponents}
|
||||
handleCopy={handleCopy}
|
||||
copiedMessageId={copiedMessageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isLoading &&
|
||||
(messages.length === 0 ||
|
||||
messages[messages.length - 1].type === "human") && (
|
||||
<div className="flex items-start gap-3 mt-3">
|
||||
{" "}
|
||||
{/* AI message row structure */}
|
||||
<div className="relative group max-w-[85%] md:max-w-[80%] rounded-xl p-3 shadow-sm break-words bg-neutral-800 text-neutral-100 rounded-bl-none w-full min-h-[56px]">
|
||||
{liveActivityEvents.length > 0 ? (
|
||||
<div className="text-xs">
|
||||
<ActivityTimeline
|
||||
processedEvents={liveActivityEvents}
|
||||
isLoading={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-start h-full">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-neutral-400 mr-2" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<InputForm
|
||||
onSubmit={onSubmit}
|
||||
isLoading={isLoading}
|
||||
onCancel={onCancel}
|
||||
hasHistory={messages.length > 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
frontend/src/components/InputForm.tsx
Normal file
180
frontend/src/components/InputForm.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SquarePen, Brain, Send, StopCircle, Zap, Cpu } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
// Updated InputFormProps
|
||||
interface InputFormProps {
|
||||
onSubmit: (inputValue: string, effort: string, model: string) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
hasHistory: boolean;
|
||||
}
|
||||
|
||||
export const InputForm: React.FC<InputFormProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
hasHistory,
|
||||
}) => {
|
||||
const [internalInputValue, setInternalInputValue] = useState("");
|
||||
const [effort, setEffort] = useState("medium");
|
||||
const [model, setModel] = useState("gemini-2.5-flash-preview-04-17");
|
||||
|
||||
const handleInternalSubmit = (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!internalInputValue.trim()) return;
|
||||
onSubmit(internalInputValue, effort, model);
|
||||
setInternalInputValue("");
|
||||
};
|
||||
|
||||
const handleInternalKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>
|
||||
) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleInternalSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitDisabled = !internalInputValue.trim() || isLoading;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleInternalSubmit}
|
||||
className={`flex flex-col gap-2 p-3 `}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-row items-center justify-between text-white rounded-3xl rounded-bl-sm ${
|
||||
hasHistory ? "rounded-br-sm" : ""
|
||||
} break-words min-h-7 bg-neutral-700 px-4 pt-3 `}
|
||||
>
|
||||
<Textarea
|
||||
value={internalInputValue}
|
||||
onChange={(e) => setInternalInputValue(e.target.value)}
|
||||
onKeyDown={handleInternalKeyDown}
|
||||
placeholder="Who won the Euro 2024 and scored the most goals?"
|
||||
className={`w-full text-neutral-100 placeholder-neutral-500 resize-none border-0 focus:outline-none focus:ring-0 outline-none focus-visible:ring-0 shadow-none
|
||||
md:text-base min-h-[56px] max-h-[200px]`}
|
||||
rows={1}
|
||||
/>
|
||||
<div className="-mt-3">
|
||||
{isLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-red-500 hover:text-red-400 hover:bg-red-500/10 p-2 cursor-pointer rounded-full transition-all duration-200"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<StopCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
className={`${
|
||||
isSubmitDisabled
|
||||
? "text-neutral-500"
|
||||
: "text-blue-500 hover:text-blue-400 hover:bg-blue-500/10"
|
||||
} p-2 cursor-pointer rounded-full transition-all duration-200 text-base`}
|
||||
disabled={isSubmitDisabled}
|
||||
>
|
||||
Search
|
||||
<Send className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="flex flex-row gap-2 bg-neutral-700 border-neutral-600 text-neutral-300 focus:ring-neutral-500 rounded-xl rounded-t-sm pl-2 max-w-[100%] sm:max-w-[90%]">
|
||||
<div className="flex flex-row items-center text-sm">
|
||||
<Brain className="h-4 w-4 mr-2" />
|
||||
Effort
|
||||
</div>
|
||||
<Select value={effort} onValueChange={setEffort}>
|
||||
<SelectTrigger className="w-[120px] bg-transparent border-none cursor-pointer">
|
||||
<SelectValue placeholder="Effort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
|
||||
<SelectItem
|
||||
value="low"
|
||||
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||
>
|
||||
Low
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="medium"
|
||||
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||
>
|
||||
Medium
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="high"
|
||||
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||
>
|
||||
High
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 bg-neutral-700 border-neutral-600 text-neutral-300 focus:ring-neutral-500 rounded-xl rounded-t-sm pl-2 max-w-[100%] sm:max-w-[90%]">
|
||||
<div className="flex flex-row items-center text-sm ml-2">
|
||||
<Cpu className="h-4 w-4 mr-2" />
|
||||
Model
|
||||
</div>
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger className="w-[150px] bg-transparent border-none cursor-pointer">
|
||||
<SelectValue placeholder="Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
|
||||
<SelectItem
|
||||
value="gemini-2.0-flash"
|
||||
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Zap className="h-4 w-4 mr-2 text-yellow-400" /> 2.0 Flash
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="gemini-2.5-flash-preview-04-17"
|
||||
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Zap className="h-4 w-4 mr-2 text-orange-400" /> 2.5 Flash
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="gemini-2.5-pro-preview-05-06"
|
||||
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Cpu className="h-4 w-4 mr-2 text-purple-400" /> 2.5 Pro
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{hasHistory && (
|
||||
<Button
|
||||
className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer rounded-xl rounded-t-sm pl-2 "
|
||||
variant="default"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<SquarePen size={16} />
|
||||
New Search
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
39
frontend/src/components/WelcomeScreen.tsx
Normal file
39
frontend/src/components/WelcomeScreen.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { InputForm } from "./InputForm";
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
handleSubmit: (
|
||||
submittedInputValue: string,
|
||||
effort: string,
|
||||
model: string
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({
|
||||
handleSubmit,
|
||||
onCancel,
|
||||
isLoading,
|
||||
}) => (
|
||||
<div className="flex flex-col items-center justify-center text-center px-4 flex-1 w-full max-w-3xl mx-auto gap-4">
|
||||
<div>
|
||||
<h1 className="text-5xl md:text-6xl font-semibold text-neutral-100 mb-3">
|
||||
Welcome to a new Search.
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-neutral-400">
|
||||
How can I help you today?
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full mt-4">
|
||||
<InputForm
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
onCancel={onCancel}
|
||||
hasHistory={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Powered by Google Gemini and LangChain LangGraph.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
46
frontend/src/components/ui/badge.tsx
Normal file
46
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
59
frontend/src/components/ui/button.tsx
Normal file
59
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
56
frontend/src/components/ui/scroll-area.tsx
Normal file
56
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
183
frontend/src/components/ui/select.tsx
Normal file
183
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
64
frontend/src/components/ui/tabs.tsx
Normal file
64
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
154
frontend/src/global.css
Normal file
154
frontend/src/global.css
Normal file
@@ -0,0 +1,154 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation Delays */
|
||||
.animation-delay-200 { animation-delay: 0.2s; }
|
||||
.animation-delay-400 { animation-delay: 0.4s; }
|
||||
.animation-delay-600 { animation-delay: 0.6s; }
|
||||
.animation-delay-800 { animation-delay: 0.8s; }
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeInUpSmooth {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Animation Classes */
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
.animate-fadeInUp {
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
}
|
||||
.animate-fadeInUpSmooth {
|
||||
animation: fadeInUpSmooth 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Ensure your body or html has a dark background if not already set, e.g.: */
|
||||
/* body { background-color: #0c0c0d; } */ /* This is similar to neutral-950 */
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import "./global.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
28
frontend/tsconfig.json
Normal file
28
frontend/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable","dom"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src","vite.config.ts"]
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
26
frontend/vite.config.ts
Normal file
26
frontend/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: "/app/",
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(new URL(".", import.meta.url).pathname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// Proxy API requests to the backend server
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8000", // Default backend address
|
||||
changeOrigin: true,
|
||||
// Optionally rewrite path if needed (e.g., remove /api prefix if backend doesn't expect it)
|
||||
// rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user