From 49233370382286cffec22b2996023a32523cc590 Mon Sep 17 00:00:00 2001 From: paituo <330435863@qq.com> Date: Thu, 8 Aug 2024 18:33:08 +0800 Subject: [PATCH] Initial commit from Create Llama --- .devcontainer/devcontainer.json | 47 ++++ README.md | 18 ++ backend/.gitignore | 4 + backend/Dockerfile | 26 ++ backend/README.md | 101 +++++++ backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/routers/__init__.py | 0 backend/app/api/routers/chat.py | 148 ++++++++++ backend/app/api/routers/events.py | 149 +++++++++++ backend/app/api/routers/models.py | 252 ++++++++++++++++++ backend/app/api/routers/upload.py | 25 ++ backend/app/api/routers/vercel_response.py | 109 ++++++++ backend/app/api/services/file.py | 113 ++++++++ backend/app/api/services/llama_cloud.py | 114 ++++++++ backend/app/api/services/suggestion.py | 48 ++++ backend/app/engine/__init__.py | 31 +++ backend/app/engine/generate.py | 51 ++++ backend/app/engine/index.py | 31 +++ backend/app/engine/loaders/__init__.py | 37 +++ backend/app/engine/loaders/db.py | 26 ++ backend/app/engine/loaders/file.py | 79 ++++++ backend/app/engine/loaders/web.py | 36 +++ backend/app/engine/tools/__init__.py | 56 ++++ backend/app/engine/tools/duckduckgo.py | 36 +++ backend/app/engine/tools/img_gen.py | 108 ++++++++ backend/app/engine/tools/interpreter.py | 143 ++++++++++ backend/app/engine/tools/openapi_action.py | 78 ++++++ backend/app/engine/tools/weather.py | 73 +++++ backend/app/llmhub.py | 61 +++++ backend/app/observability.py | 2 + backend/app/settings.py | 172 ++++++++++++ backend/config/loaders.yaml | 10 + backend/config/tools.yaml | 4 + backend/data/101.pdf | Bin 0 -> 47931 bytes backend/main.py | 64 +++++ backend/pyproject.toml | 48 ++++ backend/tests/__init__.py | 0 frontend/.env | 6 + frontend/.eslintrc.json | 7 + frontend/.gitignore | 37 +++ frontend/Dockerfile | 16 ++ frontend/README.md | 71 +++++ frontend/app/components/chat-section.tsx | 51 ++++ frontend/app/components/header.tsx | 28 ++ frontend/app/components/ui/README.md | 1 + frontend/app/components/ui/button.tsx | 56 ++++ .../app/components/ui/chat/chat-actions.tsx | 28 ++ .../app/components/ui/chat/chat-input.tsx | 127 +++++++++ .../ui/chat/chat-message/chat-avatar.tsx | 25 ++ .../ui/chat/chat-message/chat-events.tsx | 50 ++++ .../ui/chat/chat-message/chat-files.tsx | 13 + .../ui/chat/chat-message/chat-image.tsx | 17 ++ .../ui/chat/chat-message/chat-sources.tsx | 123 +++++++++ .../chat-message/chat-suggestedQuestions.tsx | 32 +++ .../ui/chat/chat-message/chat-tools.tsx | 26 ++ .../ui/chat/chat-message/codeblock.tsx | 139 ++++++++++ .../components/ui/chat/chat-message/index.tsx | 156 +++++++++++ .../ui/chat/chat-message/markdown.tsx | 88 ++++++ .../app/components/ui/chat/chat-messages.tsx | 95 +++++++ .../app/components/ui/chat/chat.interface.ts | 25 ++ .../components/ui/chat/hooks/use-config.ts | 31 +++ .../ui/chat/hooks/use-copy-to-clipboard.tsx | 33 +++ .../app/components/ui/chat/hooks/use-file.ts | 153 +++++++++++ frontend/app/components/ui/chat/index.ts | 91 +++++++ .../ui/chat/widgets/LlamaCloudSelector.tsx | 151 +++++++++++ .../components/ui/chat/widgets/PdfDialog.tsx | 67 +++++ .../ui/chat/widgets/WeatherCard.tsx | 213 +++++++++++++++ frontend/app/components/ui/collapsible.tsx | 11 + .../app/components/ui/document-preview.tsx | 119 +++++++++ frontend/app/components/ui/drawer.tsx | 118 ++++++++ frontend/app/components/ui/file-uploader.tsx | 105 ++++++++ frontend/app/components/ui/hover-card.tsx | 29 ++ frontend/app/components/ui/icons/docx.svg | 10 + frontend/app/components/ui/icons/pdf.svg | 19 ++ frontend/app/components/ui/icons/sheet.svg | 90 +++++++ frontend/app/components/ui/icons/txt.svg | 21 ++ frontend/app/components/ui/input.tsx | 25 ++ frontend/app/components/ui/lib/utils.ts | 6 + frontend/app/components/ui/select.tsx | 159 +++++++++++ .../components/ui/upload-image-preview.tsx | 32 +++ frontend/app/favicon.ico | Bin 0 -> 15406 bytes frontend/app/globals.css | 97 +++++++ frontend/app/layout.tsx | 23 ++ frontend/app/markdown.css | 23 ++ frontend/app/observability/index.ts | 1 + frontend/app/page.tsx | 15 ++ frontend/config/tools.json | 7 + frontend/next.config.json | 16 ++ frontend/next.config.mjs | 10 + frontend/package.json | 65 +++++ frontend/postcss.config.js | 6 + frontend/prettier.config.js | 3 + frontend/public/llama.png | Bin 0 -> 36985 bytes frontend/tailwind.config.ts | 78 ++++++ frontend/tsconfig.json | 26 ++ frontend/webpack.config.mjs | 8 + 97 files changed, 5378 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 README.md create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/routers/__init__.py create mode 100644 backend/app/api/routers/chat.py create mode 100644 backend/app/api/routers/events.py create mode 100644 backend/app/api/routers/models.py create mode 100644 backend/app/api/routers/upload.py create mode 100644 backend/app/api/routers/vercel_response.py create mode 100644 backend/app/api/services/file.py create mode 100644 backend/app/api/services/llama_cloud.py create mode 100644 backend/app/api/services/suggestion.py create mode 100644 backend/app/engine/__init__.py create mode 100644 backend/app/engine/generate.py create mode 100644 backend/app/engine/index.py create mode 100644 backend/app/engine/loaders/__init__.py create mode 100644 backend/app/engine/loaders/db.py create mode 100644 backend/app/engine/loaders/file.py create mode 100644 backend/app/engine/loaders/web.py create mode 100644 backend/app/engine/tools/__init__.py create mode 100644 backend/app/engine/tools/duckduckgo.py create mode 100644 backend/app/engine/tools/img_gen.py create mode 100644 backend/app/engine/tools/interpreter.py create mode 100644 backend/app/engine/tools/openapi_action.py create mode 100644 backend/app/engine/tools/weather.py create mode 100644 backend/app/llmhub.py create mode 100644 backend/app/observability.py create mode 100644 backend/app/settings.py create mode 100644 backend/config/loaders.yaml create mode 100644 backend/config/tools.yaml create mode 100644 backend/data/101.pdf create mode 100644 backend/main.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 frontend/.env create mode 100644 frontend/.eslintrc.json create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/app/components/chat-section.tsx create mode 100644 frontend/app/components/header.tsx create mode 100644 frontend/app/components/ui/README.md create mode 100644 frontend/app/components/ui/button.tsx create mode 100644 frontend/app/components/ui/chat/chat-actions.tsx create mode 100644 frontend/app/components/ui/chat/chat-input.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/chat-avatar.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/chat-events.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/chat-files.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/chat-image.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/chat-sources.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/chat-suggestedQuestions.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/chat-tools.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/codeblock.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/index.tsx create mode 100644 frontend/app/components/ui/chat/chat-message/markdown.tsx create mode 100644 frontend/app/components/ui/chat/chat-messages.tsx create mode 100644 frontend/app/components/ui/chat/chat.interface.ts create mode 100644 frontend/app/components/ui/chat/hooks/use-config.ts create mode 100644 frontend/app/components/ui/chat/hooks/use-copy-to-clipboard.tsx create mode 100644 frontend/app/components/ui/chat/hooks/use-file.ts create mode 100644 frontend/app/components/ui/chat/index.ts create mode 100644 frontend/app/components/ui/chat/widgets/LlamaCloudSelector.tsx create mode 100644 frontend/app/components/ui/chat/widgets/PdfDialog.tsx create mode 100644 frontend/app/components/ui/chat/widgets/WeatherCard.tsx create mode 100644 frontend/app/components/ui/collapsible.tsx create mode 100644 frontend/app/components/ui/document-preview.tsx create mode 100644 frontend/app/components/ui/drawer.tsx create mode 100644 frontend/app/components/ui/file-uploader.tsx create mode 100644 frontend/app/components/ui/hover-card.tsx create mode 100644 frontend/app/components/ui/icons/docx.svg create mode 100644 frontend/app/components/ui/icons/pdf.svg create mode 100644 frontend/app/components/ui/icons/sheet.svg create mode 100644 frontend/app/components/ui/icons/txt.svg create mode 100644 frontend/app/components/ui/input.tsx create mode 100644 frontend/app/components/ui/lib/utils.ts create mode 100644 frontend/app/components/ui/select.tsx create mode 100644 frontend/app/components/ui/upload-image-preview.tsx create mode 100644 frontend/app/favicon.ico create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/markdown.css create mode 100644 frontend/app/observability/index.ts create mode 100644 frontend/app/page.tsx create mode 100644 frontend/config/tools.json create mode 100644 frontend/next.config.json create mode 100644 frontend/next.config.mjs create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/prettier.config.js create mode 100644 frontend/public/llama.png create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/webpack.config.mjs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..888481b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,47 @@ +{ + "image": "mcr.microsoft.com/vscode/devcontainers/typescript-node:dev-20-bullseye", + "features": { + "ghcr.io/devcontainers-contrib/features/turborepo-npm:1": {}, + "ghcr.io/devcontainers-contrib/features/typescript:2": {}, + "ghcr.io/devcontainers/features/python:1": { + "version": "3.11", + "toolsToInstall": [ + "flake8", + "black", + "mypy", + "poetry" + ] + } + }, + "customizations": { + "codespaces": { + "openFiles": [ + "README.md" + ] + }, + "vscode": { + "extensions": [ + "ms-vscode.typescript-language-features", + "esbenp.prettier-vscode", + "ms-python.python", + "ms-python.black-formatter", + "ms-python.vscode-flake8", + "ms-python.vscode-pylance" + ], + "settings": { + "python.formatting.provider": "black", + "python.languageServer": "Pylance", + "python.analysis.typeCheckingMode": "basic" + } + } + }, + "containerEnv": { + "POETRY_VIRTUALENVS_CREATE": "false", + "PYTHONPATH": "${PYTHONPATH}:${workspaceFolder}/backend" + }, + "forwardPorts": [ + 3000, + 8000 + ], + "postCreateCommand": "cd backend && poetry install && cd ../frontend && npm install" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a41b8c --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +This is a [LlamaIndex](https://www.llamaindex.ai/) project bootstrapped with [`create-llama`](https://github.com/run-llama/LlamaIndexTS/tree/main/packages/create-llama). + +## Getting Started + +First, startup the backend as described in the [backend README](./backend/README.md). + +Second, run the development server of the frontend as described in the [frontend README](./frontend/README.md). + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +## Learn More + +To learn more about LlamaIndex, take a look at the following resources: + +- [LlamaIndex Documentation](https://docs.llamaindex.ai) - learn about LlamaIndex (Python features). +- [LlamaIndexTS Documentation](https://ts.llamaindex.ai) - learn about LlamaIndex (Typescript features). + +You can check out [the LlamaIndexTS GitHub repository](https://github.com/run-llama/LlamaIndexTS) - your feedback and contributions are welcome! diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..ae22d34 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +storage +.env +output diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..624364b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11 as build + +WORKDIR /app + +ENV PYTHONPATH=/app + +# Install Poetry +RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python && \ + cd /usr/local/bin && \ + ln -s /opt/poetry/bin/poetry && \ + poetry config virtualenvs.create false + +# Install Chromium for web loader +# Can disable this if you don't use the web loader to reduce the image size +RUN apt update && apt install -y chromium chromium-driver + +# Install dependencies +COPY ./pyproject.toml ./poetry.lock* /app/ +RUN poetry install --no-root --no-cache --only main + +# ==================================== +FROM build as release + +COPY . . + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7969ff0 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,101 @@ +This is a [LlamaIndex](https://www.llamaindex.ai/) project using [FastAPI](https://fastapi.tiangolo.com/) bootstrapped with [`create-llama`](https://github.com/run-llama/LlamaIndexTS/tree/main/packages/create-llama). + +## Getting Started + +First, setup the environment with poetry: + +> **_Note:_** This step is not needed if you are using the dev-container. + +``` +poetry install +poetry shell +``` + +Then check the parameters that have been pre-configured in the `.env` file in this directory. (E.g. you might need to configure an `OPENAI_API_KEY` if you're using OpenAI as model provider). + +If you are using any tools or data sources, you can update their config files in the `config` folder. + +Second, generate the embeddings of the documents in the `./data` directory (if this folder exists - otherwise, skip this step): + +``` +poetry run generate +``` + +Third, run the development server: + +``` +python main.py +``` + +The example provides two different API endpoints: + +1. `/api/chat` - a streaming chat endpoint +2. `/api/chat/request` - a non-streaming chat endpoint + +You can test the streaming endpoint with the following curl request: + +``` +curl --location 'localhost:8000/api/chat' \ +--header 'Content-Type: application/json' \ +--data '{ "messages": [{ "role": "user", "content": "Hello" }] }' +``` + +And for the non-streaming endpoint run: + +``` +curl --location 'localhost:8000/api/chat/request' \ +--header 'Content-Type: application/json' \ +--data '{ "messages": [{ "role": "user", "content": "Hello" }] }' +``` + +You can start editing the API endpoints by modifying `app/api/routers/chat.py`. The endpoints auto-update as you save the file. You can delete the endpoint you're not using. + +Open [http://localhost:8000/docs](http://localhost:8000/docs) with your browser to see the Swagger UI of the API. + +The API allows CORS for all origins to simplify development. You can change this behavior by setting the `ENVIRONMENT` environment variable to `prod`: + +``` +ENVIRONMENT=prod python main.py +``` + +## Using Docker + +1. Build an image for the FastAPI app: + +``` +docker build -t . +``` + +2. Generate embeddings: + +Parse the data and generate the vector embeddings if the `./data` folder exists - otherwise, skip this step: + +``` +docker run \ + --rm \ + -v $(pwd)/.env:/app/.env \ # Use ENV variables and configuration from your file-system + -v $(pwd)/config:/app/config \ + -v $(pwd)/data:/app/data \ # Use your local folder to read the data + -v $(pwd)/storage:/app/storage \ # Use your file system to store the vector database + \ + poetry run generate +``` + +3. Start the API: + +``` +docker run \ + -v $(pwd)/.env:/app/.env \ # Use ENV variables and configuration from your file-system + -v $(pwd)/config:/app/config \ + -v $(pwd)/storage:/app/storage \ # Use your file system to store gea vector database + -p 8000:8000 \ + +``` + +## Learn More + +To learn more about LlamaIndex, take a look at the following resources: + +- [LlamaIndex Documentation](https://docs.llamaindex.ai) - learn about LlamaIndex. + +You can check out [the LlamaIndex GitHub repository](https://github.com/run-llama/llama_index) - your feedback and contributions are welcome! diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routers/__init__.py b/backend/app/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routers/chat.py b/backend/app/api/routers/chat.py new file mode 100644 index 0000000..cb7036d --- /dev/null +++ b/backend/app/api/routers/chat.py @@ -0,0 +1,148 @@ +import logging +import os +from typing import List + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status +from llama_index.core.chat_engine.types import BaseChatEngine, NodeWithScore +from llama_index.core.llms import MessageRole +from llama_index.core.vector_stores.types import MetadataFilter, MetadataFilters + +from app.api.routers.events import EventCallbackHandler +from app.api.routers.models import ( + ChatConfig, + ChatData, + Message, + Result, + SourceNodes, +) +from app.api.routers.vercel_response import VercelStreamResponse +from app.api.services.llama_cloud import LLamaCloudFileService +from app.engine import get_chat_engine + +chat_router = r = APIRouter() + +logger = logging.getLogger("uvicorn") + + +def process_response_nodes( + nodes: List[NodeWithScore], + background_tasks: BackgroundTasks, +): + """ + Start background tasks on the source nodes if needed. + """ + files_to_download = SourceNodes.get_download_files(nodes) + for file in files_to_download: + background_tasks.add_task( + LLamaCloudFileService.download_llamacloud_pipeline_file, file + ) + + +# streaming endpoint - delete if not needed +@r.post("") +async def chat( + request: Request, + data: ChatData, + background_tasks: BackgroundTasks, + chat_engine: BaseChatEngine = Depends(get_chat_engine), +): + try: + last_message_content = data.get_last_message_content() + messages = data.get_history_messages() + + doc_ids = data.get_chat_document_ids() + filters = generate_filters(doc_ids) + params = data.data or {} + logger.info("Creating chat engine with filters", filters.dict()) + chat_engine = get_chat_engine(filters=filters, params=params) + + event_handler = EventCallbackHandler() + chat_engine.callback_manager.handlers.append(event_handler) # type: ignore + + response = await chat_engine.astream_chat(last_message_content, messages) + process_response_nodes(response.source_nodes, background_tasks) + + return VercelStreamResponse(request, event_handler, response, data) + except Exception as e: + logger.exception("Error in chat engine", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error in chat engine: {e}", + ) from e + + +def generate_filters(doc_ids): + if len(doc_ids) > 0: + filters = MetadataFilters( + filters=[ + MetadataFilter( + key="private", + value=["true"], + operator="nin", # type: ignore + ), + MetadataFilter( + key="doc_id", + value=doc_ids, + operator="in", # type: ignore + ), + ], + condition="or", # type: ignore + ) + else: + filters = MetadataFilters( + # Use the "NIN" - "not in" operator to include all public documents (don't have the private key set) + filters=[ + MetadataFilter( + key="private", + value=["true"], + operator="nin", # type: ignore + ), + ] + ) + + return filters + + +# non-streaming endpoint - delete if not needed +@r.post("/request") +async def chat_request( + data: ChatData, + chat_engine: BaseChatEngine = Depends(get_chat_engine), +) -> Result: + last_message_content = data.get_last_message_content() + messages = data.get_history_messages() + + response = await chat_engine.achat(last_message_content, messages) + return Result( + result=Message(role=MessageRole.ASSISTANT, content=response.response), + nodes=SourceNodes.from_source_nodes(response.source_nodes), + ) + + +@r.get("/config") +async def chat_config() -> ChatConfig: + starter_questions = None + conversation_starters = os.getenv("CONVERSATION_STARTERS") + if conversation_starters and conversation_starters.strip(): + starter_questions = conversation_starters.strip().split("\n") + return ChatConfig(starter_questions=starter_questions) + + +@r.get("/config/llamacloud") +async def chat_llama_cloud_config(): + projects = LLamaCloudFileService.get_all_projects_with_pipelines() + pipeline = os.getenv("LLAMA_CLOUD_INDEX_NAME") + project = os.getenv("LLAMA_CLOUD_PROJECT_NAME") + pipeline_config = ( + pipeline + and project + and { + "pipeline": pipeline, + "project": project, + } + or None + ) + return { + "projects": projects, + "pipeline": pipeline_config, + } diff --git a/backend/app/api/routers/events.py b/backend/app/api/routers/events.py new file mode 100644 index 0000000..94cc585 --- /dev/null +++ b/backend/app/api/routers/events.py @@ -0,0 +1,149 @@ +import json +import asyncio +import logging +from typing import AsyncGenerator, Dict, Any, List, Optional +from llama_index.core.callbacks.base import BaseCallbackHandler +from llama_index.core.callbacks.schema import CBEventType +from llama_index.core.tools.types import ToolOutput +from pydantic import BaseModel + + +logger = logging.getLogger(__name__) + + +class CallbackEvent(BaseModel): + event_type: CBEventType + payload: Optional[Dict[str, Any]] = None + event_id: str = "" + + def get_retrieval_message(self) -> dict | None: + if self.payload: + nodes = self.payload.get("nodes") + if nodes: + msg = f"Retrieved {len(nodes)} sources to use as context for the query" + else: + msg = f"Retrieving context for query: '{self.payload.get('query_str')}'" + return { + "type": "events", + "data": {"title": msg}, + } + else: + return None + + def get_tool_message(self) -> dict | None: + func_call_args = self.payload.get("function_call") + if func_call_args is not None and "tool" in self.payload: + tool = self.payload.get("tool") + return { + "type": "events", + "data": { + "title": f"Calling tool: {tool.name} with inputs: {func_call_args}", + }, + } + + def _is_output_serializable(self, output: Any) -> bool: + try: + json.dumps(output) + return True + except TypeError: + return False + + def get_agent_tool_response(self) -> dict | None: + response = self.payload.get("response") + if response is not None: + sources = response.sources + for source in sources: + # Return the tool response here to include the toolCall information + if isinstance(source, ToolOutput): + if self._is_output_serializable(source.raw_output): + output = source.raw_output + else: + output = source.content + + return { + "type": "tools", + "data": { + "toolOutput": { + "output": output, + "isError": source.is_error, + }, + "toolCall": { + "id": None, # There is no tool id in the ToolOutput + "name": source.tool_name, + "input": source.raw_input, + }, + }, + } + + def to_response(self): + try: + match self.event_type: + case "retrieve": + return self.get_retrieval_message() + case "function_call": + return self.get_tool_message() + case "agent_step": + return self.get_agent_tool_response() + case _: + return None + except Exception as e: + logger.error(f"Error in converting event to response: {e}") + return None + + +class EventCallbackHandler(BaseCallbackHandler): + _aqueue: asyncio.Queue + is_done: bool = False + + def __init__( + self, + ): + """Initialize the base callback handler.""" + ignored_events = [ + CBEventType.CHUNKING, + CBEventType.NODE_PARSING, + CBEventType.EMBEDDING, + CBEventType.LLM, + CBEventType.TEMPLATING, + ] + super().__init__(ignored_events, ignored_events) + self._aqueue = asyncio.Queue() + + def on_event_start( + self, + event_type: CBEventType, + payload: Optional[Dict[str, Any]] = None, + event_id: str = "", + **kwargs: Any, + ) -> str: + event = CallbackEvent(event_id=event_id, event_type=event_type, payload=payload) + if event.to_response() is not None: + self._aqueue.put_nowait(event) + + def on_event_end( + self, + event_type: CBEventType, + payload: Optional[Dict[str, Any]] = None, + event_id: str = "", + **kwargs: Any, + ) -> None: + event = CallbackEvent(event_id=event_id, event_type=event_type, payload=payload) + if event.to_response() is not None: + self._aqueue.put_nowait(event) + + def start_trace(self, trace_id: Optional[str] = None) -> None: + """No-op.""" + + def end_trace( + self, + trace_id: Optional[str] = None, + trace_map: Optional[Dict[str, List[str]]] = None, + ) -> None: + """No-op.""" + + async def async_event_gen(self) -> AsyncGenerator[CallbackEvent, None]: + while not self._aqueue.empty() or not self.is_done: + try: + yield await asyncio.wait_for(self._aqueue.get(), timeout=0.1) + except asyncio.TimeoutError: + pass diff --git a/backend/app/api/routers/models.py b/backend/app/api/routers/models.py new file mode 100644 index 0000000..c9ea1ad --- /dev/null +++ b/backend/app/api/routers/models.py @@ -0,0 +1,252 @@ +import logging +import os +from typing import Any, Dict, List, Literal, Optional, Set + +from llama_index.core.llms import ChatMessage, MessageRole +from llama_index.core.schema import NodeWithScore +from pydantic import BaseModel, Field, validator +from pydantic.alias_generators import to_camel + +logger = logging.getLogger("uvicorn") + + +class FileContent(BaseModel): + type: Literal["text", "ref"] + # If the file is pure text then the value is be a string + # otherwise, it's a list of document IDs + value: str | List[str] + + +class File(BaseModel): + id: str + content: FileContent + filename: str + filesize: int + filetype: str + + +class AnnotationFileData(BaseModel): + files: List[File] = Field( + default=[], + description="List of files", + ) + + class Config: + json_schema_extra = { + "example": { + "csvFiles": [ + { + "content": "Name, Age\nAlice, 25\nBob, 30", + "filename": "example.csv", + "filesize": 123, + "id": "123", + "type": "text/csv", + } + ] + } + } + alias_generator = to_camel + + +class Annotation(BaseModel): + type: str + data: AnnotationFileData | List[str] + + def to_content(self) -> str | None: + if self.type == "document_file": + # We only support generating context content for CSV files for now + csv_files = [file for file in self.data.files if file.filetype == "csv"] + if len(csv_files) > 0: + return "Use data from following CSV raw content\n" + "\n".join( + [f"```csv\n{csv_file.content.value}\n```" for csv_file in csv_files] + ) + else: + logger.warning( + f"The annotation {self.type} is not supported for generating context content" + ) + return None + + +class Message(BaseModel): + role: MessageRole + content: str + annotations: List[Annotation] | None = None + + +class ChatData(BaseModel): + messages: List[Message] + data: Any = None + + class Config: + json_schema_extra = { + "example": { + "messages": [ + { + "role": "user", + "content": "What standards for letters exist?", + } + ] + } + } + + @validator("messages") + def messages_must_not_be_empty(cls, v): + if len(v) == 0: + raise ValueError("Messages must not be empty") + return v + + def get_last_message_content(self) -> str: + """ + Get the content of the last message along with the data content if available. + Fallback to use data content from previous messages + """ + if len(self.messages) == 0: + raise ValueError("There is not any message in the chat") + last_message = self.messages[-1] + message_content = last_message.content + for message in reversed(self.messages): + if message.role == MessageRole.USER and message.annotations is not None: + annotation_contents = filter( + None, + [annotation.to_content() for annotation in message.annotations], + ) + if not annotation_contents: + continue + annotation_text = "\n".join(annotation_contents) + message_content = f"{message_content}\n{annotation_text}" + break + return message_content + + def get_history_messages(self) -> List[ChatMessage]: + """ + Get the history messages + """ + return [ + ChatMessage(role=message.role, content=message.content) + for message in self.messages[:-1] + ] + + def is_last_message_from_user(self) -> bool: + return self.messages[-1].role == MessageRole.USER + + def get_chat_document_ids(self) -> List[str]: + """ + Get the document IDs from the chat messages + """ + document_ids: List[str] = [] + for message in self.messages: + if message.role == MessageRole.USER and message.annotations is not None: + for annotation in message.annotations: + if ( + annotation.type == "document_file" + and annotation.data.files is not None + ): + for fi in annotation.data.files: + if fi.content.type == "ref": + document_ids += fi.content.value + return list(set(document_ids)) + + +class LlamaCloudFile(BaseModel): + file_name: str + pipeline_id: str + + def __eq__(self, other): + if not isinstance(other, LlamaCloudFile): + return NotImplemented + return ( + self.file_name == other.file_name and self.pipeline_id == other.pipeline_id + ) + + def __hash__(self): + return hash((self.file_name, self.pipeline_id)) + + +class SourceNodes(BaseModel): + id: str + metadata: Dict[str, Any] + score: Optional[float] + text: str + url: Optional[str] + + @classmethod + def from_source_node(cls, source_node: NodeWithScore): + metadata = source_node.node.metadata + url = cls.get_url_from_metadata(metadata) + + return cls( + id=source_node.node.node_id, + metadata=metadata, + score=source_node.score, + text=source_node.node.text, # type: ignore + url=url, + ) + + @classmethod + def get_url_from_metadata(cls, metadata: Dict[str, Any]) -> str: + url_prefix = os.getenv("FILESERVER_URL_PREFIX") + if not url_prefix: + logger.warning( + "Warning: FILESERVER_URL_PREFIX not set in environment variables. Can't use file server" + ) + file_name = metadata.get("file_name") + if file_name and url_prefix: + # file_name exists and file server is configured + pipeline_id = metadata.get("pipeline_id") + if pipeline_id and metadata.get("private") is None: + # file is from LlamaCloud and was not ingested locally + file_name = f"{pipeline_id}${file_name}" + return f"{url_prefix}/output/llamacloud/{file_name}" + is_private = metadata.get("private", "false") == "true" + if is_private: + return f"{url_prefix}/output/uploaded/{file_name}" + return f"{url_prefix}/data/{file_name}" + else: + # fallback to URL in metadata (e.g. for websites) + return metadata.get("URL") + + @classmethod + def from_source_nodes(cls, source_nodes: List[NodeWithScore]): + return [cls.from_source_node(node) for node in source_nodes] + + @staticmethod + def get_download_files(nodes: List[NodeWithScore]) -> Set[LlamaCloudFile]: + source_nodes = SourceNodes.from_source_nodes(nodes) + llama_cloud_files = [ + LlamaCloudFile( + file_name=node.metadata.get("file_name"), + pipeline_id=node.metadata.get("pipeline_id"), + ) + for node in source_nodes + if ( + node.metadata.get("private") + is None # Only download files are from LlamaCloud and were not ingested locally + and node.metadata.get("pipeline_id") is not None + and node.metadata.get("file_name") is not None + ) + ] + # Remove duplicates and return + return set(llama_cloud_files) + + +class Result(BaseModel): + result: Message + nodes: List[SourceNodes] + + +class ChatConfig(BaseModel): + starter_questions: Optional[List[str]] = Field( + default=None, + description="List of starter questions", + serialization_alias="starterQuestions", + ) + + class Config: + json_schema_extra = { + "example": { + "starterQuestions": [ + "What standards for letters exist?", + "What are the requirements for a letter to be considered a letter?", + ] + } + } diff --git a/backend/app/api/routers/upload.py b/backend/app/api/routers/upload.py new file mode 100644 index 0000000..94f3ce7 --- /dev/null +++ b/backend/app/api/routers/upload.py @@ -0,0 +1,25 @@ +import logging +from typing import List + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.api.services.file import PrivateFileService + +file_upload_router = r = APIRouter() + +logger = logging.getLogger("uvicorn") + + +class FileUploadRequest(BaseModel): + base64: str + + +@r.post("") +def upload_file(request: FileUploadRequest) -> List[str]: + try: + logger.info("Processing file") + return PrivateFileService.process_file(request.base64) + except Exception as e: + logger.error(f"Error processing file: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Error processing file") diff --git a/backend/app/api/routers/vercel_response.py b/backend/app/api/routers/vercel_response.py new file mode 100644 index 0000000..0222a14 --- /dev/null +++ b/backend/app/api/routers/vercel_response.py @@ -0,0 +1,109 @@ +import json + +from aiostream import stream +from fastapi import Request +from fastapi.responses import StreamingResponse +from llama_index.core.chat_engine.types import StreamingAgentChatResponse + +from app.api.routers.events import EventCallbackHandler +from app.api.routers.models import ChatData, Message, SourceNodes +from app.api.services.suggestion import NextQuestionSuggestion + + +class VercelStreamResponse(StreamingResponse): + """ + Class to convert the response from the chat engine to the streaming format expected by Vercel + """ + + TEXT_PREFIX = "0:" + DATA_PREFIX = "8:" + + @classmethod + def convert_text(cls, token: str): + # Escape newlines and double quotes to avoid breaking the stream + token = json.dumps(token) + return f"{cls.TEXT_PREFIX}{token}\n" + + @classmethod + def convert_data(cls, data: dict): + data_str = json.dumps(data) + return f"{cls.DATA_PREFIX}[{data_str}]\n" + + def __init__( + self, + request: Request, + event_handler: EventCallbackHandler, + response: StreamingAgentChatResponse, + chat_data: ChatData, + ): + content = VercelStreamResponse.content_generator( + request, event_handler, response, chat_data + ) + super().__init__(content=content) + + @classmethod + async def content_generator( + cls, + request: Request, + event_handler: EventCallbackHandler, + response: StreamingAgentChatResponse, + chat_data: ChatData, + ): + # Yield the text response + async def _chat_response_generator(): + final_response = "" + async for token in response.async_response_gen(): + final_response += token + yield VercelStreamResponse.convert_text(token) + + # Generate questions that user might interested to + conversation = chat_data.messages + [ + Message(role="assistant", content=final_response) + ] + questions = await NextQuestionSuggestion.suggest_next_questions( + conversation + ) + if len(questions) > 0: + yield VercelStreamResponse.convert_data( + { + "type": "suggested_questions", + "data": questions, + } + ) + + # the text_generator is the leading stream, once it's finished, also finish the event stream + event_handler.is_done = True + + # Yield the source nodes + yield cls.convert_data( + { + "type": "sources", + "data": { + "nodes": [ + SourceNodes.from_source_node(node).dict() + for node in response.source_nodes + ] + }, + } + ) + + # Yield the events from the event handler + async def _event_generator(): + async for event in event_handler.async_event_gen(): + event_response = event.to_response() + if event_response is not None: + yield VercelStreamResponse.convert_data(event_response) + + combine = stream.merge(_chat_response_generator(), _event_generator()) + is_stream_started = False + async with combine.stream() as streamer: + async for output in streamer: + if not is_stream_started: + is_stream_started = True + # Stream a blank message to start the stream + yield VercelStreamResponse.convert_text("") + + yield output + + if await request.is_disconnected(): + break diff --git a/backend/app/api/services/file.py b/backend/app/api/services/file.py new file mode 100644 index 0000000..a478570 --- /dev/null +++ b/backend/app/api/services/file.py @@ -0,0 +1,113 @@ +import base64 +import mimetypes +import os +from pathlib import Path +from typing import Dict, List +from uuid import uuid4 + +from app.engine.index import get_index +from llama_index.core import VectorStoreIndex +from llama_index.core.ingestion import IngestionPipeline +from llama_index.core.readers.file.base import ( + _try_loading_included_file_formats as get_file_loaders_map, +) +from llama_index.core.readers.file.base import ( + default_file_metadata_func, +) +from llama_index.core.schema import Document +from llama_index.indices.managed.llama_cloud.base import LlamaCloudIndex +from llama_index.readers.file import FlatReader + + +def get_llamaparse_parser(): + from app.engine.loaders import load_configs + from app.engine.loaders.file import FileLoaderConfig, llama_parse_parser + + config = load_configs() + file_loader_config = FileLoaderConfig(**config["file"]) + if file_loader_config.use_llama_parse: + return llama_parse_parser() + else: + return None + + +def default_file_loaders_map(): + default_loaders = get_file_loaders_map() + default_loaders[".txt"] = FlatReader + return default_loaders + + +class PrivateFileService: + PRIVATE_STORE_PATH = "output/uploaded" + + @staticmethod + def preprocess_base64_file(base64_content: str) -> tuple: + header, data = base64_content.split(",", 1) + mime_type = header.split(";")[0].split(":", 1)[1] + extension = mimetypes.guess_extension(mime_type) + # File data as bytes + return base64.b64decode(data), extension + + @staticmethod + def store_and_parse_file(file_data, extension) -> List[Document]: + # Store file to the private directory + os.makedirs(PrivateFileService.PRIVATE_STORE_PATH, exist_ok=True) + + # random file name + file_name = f"{uuid4().hex}{extension}" + file_path = Path(os.path.join(PrivateFileService.PRIVATE_STORE_PATH, file_name)) + + # write file + with open(file_path, "wb") as f: + f.write(file_data) + + # Load file to documents + # If LlamaParse is enabled, use it to parse the file + # Otherwise, use the default file loaders + reader = get_llamaparse_parser() + if reader is None: + reader_cls = default_file_loaders_map().get(extension) + if reader_cls is None: + raise ValueError(f"File extension {extension} is not supported") + reader = reader_cls() + documents = reader.load_data(file_path) + # Add custom metadata + for doc in documents: + doc.metadata["file_name"] = file_name + doc.metadata["private"] = "true" + return documents + + @staticmethod + def process_file(base64_content: str) -> List[str]: + file_data, extension = PrivateFileService.preprocess_base64_file(base64_content) + documents = PrivateFileService.store_and_parse_file(file_data, extension) + + # Only process nodes, no store the index + pipeline = IngestionPipeline() + nodes = pipeline.run(documents=documents) + + # Add the nodes to the index and persist it + current_index = get_index() + + # Insert the documents into the index + if isinstance(current_index, LlamaCloudIndex): + # LlamaCloudIndex is a managed index so we don't need to process the nodes + # just insert the documents + for doc in documents: + current_index.insert(doc) + else: + # Only process nodes, no store the index + pipeline = IngestionPipeline() + nodes = pipeline.run(documents=documents) + + # Add the nodes to the index and persist it + if current_index is None: + current_index = VectorStoreIndex(nodes=nodes) + else: + current_index.insert_nodes(nodes=nodes) + current_index.storage_context.persist( + persist_dir=os.environ.get("STORAGE_DIR", "storage") + ) + + # Return the document ids + return [doc.doc_id for doc in documents] diff --git a/backend/app/api/services/llama_cloud.py b/backend/app/api/services/llama_cloud.py new file mode 100644 index 0000000..852ae7c --- /dev/null +++ b/backend/app/api/services/llama_cloud.py @@ -0,0 +1,114 @@ +import logging +import os +from typing import Any, Dict, List, Optional + +import requests +from app.api.routers.models import LlamaCloudFile + +logger = logging.getLogger("uvicorn") + + +class LLamaCloudFileService: + LLAMA_CLOUD_URL = "https://cloud.llamaindex.ai/api/v1" + LOCAL_STORE_PATH = "output/llamacloud" + + DOWNLOAD_FILE_NAME_TPL = "{pipeline_id}${filename}" + + @classmethod + def get_all_projects(cls) -> List[Dict[str, Any]]: + url = f"{cls.LLAMA_CLOUD_URL}/projects" + return cls._make_request(url) + + @classmethod + def get_all_pipelines(cls) -> List[Dict[str, Any]]: + url = f"{cls.LLAMA_CLOUD_URL}/pipelines" + return cls._make_request(url) + + @classmethod + def get_all_projects_with_pipelines(cls) -> List[Dict[str, Any]]: + try: + projects = cls.get_all_projects() + pipelines = cls.get_all_pipelines() + return [ + { + **project, + "pipelines": [p for p in pipelines if p["project_id"] == project["id"]], + } + for project in projects + ] + except Exception as error: + logger.error(f"Error listing projects and pipelines: {error}") + return [] + + @classmethod + def _get_files(cls, pipeline_id: str) -> List[Dict[str, Any]]: + url = f"{cls.LLAMA_CLOUD_URL}/pipelines/{pipeline_id}/files" + return cls._make_request(url) + + @classmethod + def _get_file_detail(cls, project_id: str, file_id: str) -> Dict[str, Any]: + url = f"{cls.LLAMA_CLOUD_URL}/files/{file_id}/content?project_id={project_id}" + return cls._make_request(url) + + @classmethod + def _download_file(cls, url: str, local_file_path: str): + logger.info(f"Downloading file to {local_file_path}") + # Create directory if it doesn't exist + os.makedirs(cls.LOCAL_STORE_PATH, exist_ok=True) + # Download the file + with requests.get(url, stream=True) as r: + r.raise_for_status() + with open(local_file_path, "wb") as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + logger.info("File downloaded successfully") + + @classmethod + def download_llamacloud_pipeline_file( + cls, + file: LlamaCloudFile, + force_download: bool = False, + ): + file_name = file.file_name + pipeline_id = file.pipeline_id + + # Check is the file already exists + downloaded_file_path = cls.get_file_path(file_name, pipeline_id) + if os.path.exists(downloaded_file_path) and not force_download: + logger.debug(f"File {file_name} already exists in local storage") + return + try: + logger.info(f"Downloading file {file_name} for pipeline {pipeline_id}") + files = cls._get_files(pipeline_id) + if not files or not isinstance(files, list): + raise Exception("No files found in LlamaCloud") + for file_entry in files: + if file_entry["name"] == file_name: + file_id = file_entry["file_id"] + project_id = file_entry["project_id"] + file_detail = cls._get_file_detail(project_id, file_id) + cls._download_file(file_detail["url"], downloaded_file_path) + break + except Exception as error: + logger.info(f"Error fetching file from LlamaCloud: {error}") + + @classmethod + def get_file_name(cls, name: str, pipeline_id: str) -> str: + return cls.DOWNLOAD_FILE_NAME_TPL.format(pipeline_id=pipeline_id, filename=name) + + @classmethod + def get_file_path(cls, name: str, pipeline_id: str) -> str: + return os.path.join(cls.LOCAL_STORE_PATH, cls.get_file_name(name, pipeline_id)) + + @staticmethod + def _make_request( + url: str, data=None, headers: Optional[Dict] = None, method: str = "get" + ): + if headers is None: + headers = { + "Accept": "application/json", + "Authorization": f'Bearer {os.getenv("LLAMA_CLOUD_API_KEY")}', + } + response = requests.request(method, url, headers=headers, data=data) + response.raise_for_status() + return response.json() diff --git a/backend/app/api/services/suggestion.py b/backend/app/api/services/suggestion.py new file mode 100644 index 0000000..406b0ae --- /dev/null +++ b/backend/app/api/services/suggestion.py @@ -0,0 +1,48 @@ +from typing import List + +from app.api.routers.models import Message +from llama_index.core.prompts import PromptTemplate +from llama_index.core.settings import Settings +from pydantic import BaseModel + +NEXT_QUESTIONS_SUGGESTION_PROMPT = PromptTemplate( + "You're a helpful assistant! Your task is to suggest the next question that user might ask. " + "\nHere is the conversation history" + "\n---------------------\n{conversation}\n---------------------" + "Given the conversation history, please give me $number_of_questions questions that you might ask next!" +) +N_QUESTION_TO_GENERATE = 3 + + +class NextQuestions(BaseModel): + """A list of questions that user might ask next""" + + questions: List[str] + + +class NextQuestionSuggestion: + @staticmethod + async def suggest_next_questions( + messages: List[Message], + number_of_questions: int = N_QUESTION_TO_GENERATE, + ) -> List[str]: + # Reduce the cost by only using the last two messages + last_user_message = None + last_assistant_message = None + for message in reversed(messages): + if message.role == "user": + last_user_message = f"User: {message.content}" + elif message.role == "assistant": + last_assistant_message = f"Assistant: {message.content}" + if last_user_message and last_assistant_message: + break + conversation: str = f"{last_user_message}\n{last_assistant_message}" + + output: NextQuestions = await Settings.llm.astructured_predict( + NextQuestions, + prompt=NEXT_QUESTIONS_SUGGESTION_PROMPT, + conversation=conversation, + nun_questions=number_of_questions, + ) + + return output.questions diff --git a/backend/app/engine/__init__.py b/backend/app/engine/__init__.py new file mode 100644 index 0000000..fb8d410 --- /dev/null +++ b/backend/app/engine/__init__.py @@ -0,0 +1,31 @@ +import os +from llama_index.core.settings import Settings +from llama_index.core.agent import AgentRunner +from llama_index.core.tools.query_engine import QueryEngineTool +from app.engine.tools import ToolFactory +from app.engine.index import get_index + + +def get_chat_engine(filters=None, params=None): + system_prompt = os.getenv("SYSTEM_PROMPT") + top_k = os.getenv("TOP_K", "3") + tools = [] + + # Add query tool if index exists + index = get_index() + if index is not None: + query_engine = index.as_query_engine( + similarity_top_k=int(top_k), filters=filters + ) + query_engine_tool = QueryEngineTool.from_defaults(query_engine=query_engine) + tools.append(query_engine_tool) + + # Add additional tools + tools += ToolFactory.from_env() + + return AgentRunner.from_llm( + llm=Settings.llm, + tools=tools, + system_prompt=system_prompt, + verbose=True, + ) diff --git a/backend/app/engine/generate.py b/backend/app/engine/generate.py new file mode 100644 index 0000000..8bcf606 --- /dev/null +++ b/backend/app/engine/generate.py @@ -0,0 +1,51 @@ +from dotenv import load_dotenv + +load_dotenv() + +import os +import logging +from app.settings import init_settings +from app.engine.loaders import get_documents +from llama_index.indices.managed.llama_cloud import LlamaCloudIndex + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger() + + +def generate_datasource(): + init_settings() + logger.info("Generate index for the provided data") + + name = os.getenv("LLAMA_CLOUD_INDEX_NAME") + project_name = os.getenv("LLAMA_CLOUD_PROJECT_NAME") + api_key = os.getenv("LLAMA_CLOUD_API_KEY") + base_url = os.getenv("LLAMA_CLOUD_BASE_URL") + organization_id = os.getenv("LLAMA_CLOUD_ORGANIZATION_ID") + + if name is None or project_name is None or api_key is None: + raise ValueError( + "Please set LLAMA_CLOUD_INDEX_NAME, LLAMA_CLOUD_PROJECT_NAME and LLAMA_CLOUD_API_KEY" + " to your environment variables or config them in .env file" + ) + + documents = get_documents() + + # Set private=false to mark the document as public (required for filtering) + for doc in documents: + doc.metadata["private"] = "false" + + LlamaCloudIndex.from_documents( + documents=documents, + name=name, + project_name=project_name, + api_key=api_key, + base_url=base_url, + organization_id=organization_id + ) + + logger.info("Finished generating the index") + + +if __name__ == "__main__": + generate_datasource() diff --git a/backend/app/engine/index.py b/backend/app/engine/index.py new file mode 100644 index 0000000..e54e8ca --- /dev/null +++ b/backend/app/engine/index.py @@ -0,0 +1,31 @@ +import logging +import os +from llama_index.indices.managed.llama_cloud import LlamaCloudIndex + + +logger = logging.getLogger("uvicorn") + +def get_index(params=None): + configParams = params or {} + pipelineConfig = configParams.get("llamaCloudPipeline", {}) + name = pipelineConfig.get("pipeline", os.getenv("LLAMA_CLOUD_INDEX_NAME")) + project_name = pipelineConfig.get("project", os.getenv("LLAMA_CLOUD_PROJECT_NAME")) + api_key = os.getenv("LLAMA_CLOUD_API_KEY") + base_url = os.getenv("LLAMA_CLOUD_BASE_URL") + organization_id = os.getenv("LLAMA_CLOUD_ORGANIZATION_ID") + + if name is None or project_name is None or api_key is None: + raise ValueError( + "Please set LLAMA_CLOUD_INDEX_NAME, LLAMA_CLOUD_PROJECT_NAME and LLAMA_CLOUD_API_KEY" + " to your environment variables or config them in .env file" + ) + + index = LlamaCloudIndex( + name=name, + project_name=project_name, + api_key=api_key, + base_url=base_url, + organization_id=organization_id + ) + + return index diff --git a/backend/app/engine/loaders/__init__.py b/backend/app/engine/loaders/__init__.py new file mode 100644 index 0000000..4a278a4 --- /dev/null +++ b/backend/app/engine/loaders/__init__.py @@ -0,0 +1,37 @@ +import logging + +import yaml +from app.engine.loaders.db import DBLoaderConfig, get_db_documents +from app.engine.loaders.file import FileLoaderConfig, get_file_documents +from app.engine.loaders.web import WebLoaderConfig, get_web_documents + +logger = logging.getLogger(__name__) + + +def load_configs(): + with open("config/loaders.yaml") as f: + configs = yaml.safe_load(f) + return configs + + +def get_documents(): + documents = [] + config = load_configs() + for loader_type, loader_config in config.items(): + logger.info( + f"Loading documents from loader: {loader_type}, config: {loader_config}" + ) + match loader_type: + case "file": + document = get_file_documents(FileLoaderConfig(**loader_config)) + case "web": + document = get_web_documents(WebLoaderConfig(**loader_config)) + case "db": + document = get_db_documents( + configs=[DBLoaderConfig(**cfg) for cfg in loader_config] + ) + case _: + raise ValueError(f"Invalid loader type: {loader_type}") + documents.extend(document) + + return documents diff --git a/backend/app/engine/loaders/db.py b/backend/app/engine/loaders/db.py new file mode 100644 index 0000000..d5c9ffd --- /dev/null +++ b/backend/app/engine/loaders/db.py @@ -0,0 +1,26 @@ +import os +import logging +from typing import List +from pydantic import BaseModel, validator +from llama_index.core.indices.vector_store import VectorStoreIndex + +logger = logging.getLogger(__name__) + + +class DBLoaderConfig(BaseModel): + uri: str + queries: List[str] + + +def get_db_documents(configs: list[DBLoaderConfig]): + from llama_index.readers.database import DatabaseReader + + docs = [] + for entry in configs: + loader = DatabaseReader(uri=entry.uri) + for query in entry.queries: + logger.info(f"Loading data from database with query: {query}") + documents = loader.load_data(query=query) + docs.extend(documents) + + return documents diff --git a/backend/app/engine/loaders/file.py b/backend/app/engine/loaders/file.py new file mode 100644 index 0000000..4dea4f8 --- /dev/null +++ b/backend/app/engine/loaders/file.py @@ -0,0 +1,79 @@ +import os +import logging +from typing import Dict +from llama_parse import LlamaParse +from pydantic import BaseModel, validator + +logger = logging.getLogger(__name__) + + +class FileLoaderConfig(BaseModel): + data_dir: str = "data" + use_llama_parse: bool = False + + @validator("data_dir") + def data_dir_must_exist(cls, v): + if not os.path.isdir(v): + raise ValueError(f"Directory '{v}' does not exist") + return v + + +def llama_parse_parser(): + if os.getenv("LLAMA_CLOUD_API_KEY") is None: + raise ValueError( + "LLAMA_CLOUD_API_KEY environment variable is not set. " + "Please set it in .env file or in your shell environment then run again!" + ) + parser = LlamaParse( + result_type="markdown", + verbose=True, + language="en", + ignore_errors=False, + ) + return parser + + +def llama_parse_extractor() -> Dict[str, LlamaParse]: + from llama_parse.utils import SUPPORTED_FILE_TYPES + + parser = llama_parse_parser() + return {file_type: parser for file_type in SUPPORTED_FILE_TYPES} + + +def get_file_documents(config: FileLoaderConfig): + from llama_index.core.readers import SimpleDirectoryReader + + try: + file_extractor = None + if config.use_llama_parse: + # LlamaParse is async first, + # so we need to use nest_asyncio to run it in sync mode + import nest_asyncio + + nest_asyncio.apply() + + file_extractor = llama_parse_extractor() + reader = SimpleDirectoryReader( + config.data_dir, + recursive=True, + filename_as_id=True, + raise_on_error=True, + file_extractor=file_extractor, + ) + return reader.load_data() + except Exception as e: + import sys + import traceback + + # Catch the error if the data dir is empty + # and return as empty document list + _, _, exc_traceback = sys.exc_info() + function_name = traceback.extract_tb(exc_traceback)[-1].name + if function_name == "_add_files": + logger.warning( + f"Failed to load file documents, error message: {e} . Return as empty document list." + ) + return [] + else: + # Raise the error if it is not the case of empty data dir + raise e diff --git a/backend/app/engine/loaders/web.py b/backend/app/engine/loaders/web.py new file mode 100644 index 0000000..563e51b --- /dev/null +++ b/backend/app/engine/loaders/web.py @@ -0,0 +1,36 @@ +import os +import json +from pydantic import BaseModel, Field + + +class CrawlUrl(BaseModel): + base_url: str + prefix: str + max_depth: int = Field(default=1, ge=0) + + +class WebLoaderConfig(BaseModel): + driver_arguments: list[str] = Field(default=None) + urls: list[CrawlUrl] + + +def get_web_documents(config: WebLoaderConfig): + from llama_index.readers.web import WholeSiteReader + from selenium import webdriver + from selenium.webdriver.chrome.options import Options + + options = Options() + driver_arguments = config.driver_arguments or [] + for arg in driver_arguments: + options.add_argument(arg) + + docs = [] + for url in config.urls: + scraper = WholeSiteReader( + prefix=url.prefix, + max_depth=url.max_depth, + driver=webdriver.Chrome(options=options), + ) + docs.extend(scraper.load_data(url.base_url)) + + return docs diff --git a/backend/app/engine/tools/__init__.py b/backend/app/engine/tools/__init__.py new file mode 100644 index 0000000..111bee5 --- /dev/null +++ b/backend/app/engine/tools/__init__.py @@ -0,0 +1,56 @@ +import os +import yaml +import json +import importlib +from cachetools import cached, LRUCache +from llama_index.core.tools.tool_spec.base import BaseToolSpec +from llama_index.core.tools.function_tool import FunctionTool + + +class ToolType: + LLAMAHUB = "llamahub" + LOCAL = "local" + + +class ToolFactory: + + TOOL_SOURCE_PACKAGE_MAP = { + ToolType.LLAMAHUB: "llama_index.tools", + ToolType.LOCAL: "app.engine.tools", + } + + def load_tools(tool_type: str, tool_name: str, config: dict) -> list[FunctionTool]: + source_package = ToolFactory.TOOL_SOURCE_PACKAGE_MAP[tool_type] + try: + if "ToolSpec" in tool_name: + tool_package, tool_cls_name = tool_name.split(".") + module_name = f"{source_package}.{tool_package}" + module = importlib.import_module(module_name) + tool_class = getattr(module, tool_cls_name) + tool_spec: BaseToolSpec = tool_class(**config) + return tool_spec.to_tool_list() + else: + module = importlib.import_module(f"{source_package}.{tool_name}") + tools = module.get_tools(**config) + if not all(isinstance(tool, FunctionTool) for tool in tools): + raise ValueError( + f"The module {module} does not contain valid tools" + ) + return tools + except ImportError as e: + raise ValueError(f"Failed to import tool {tool_name}: {e}") + except AttributeError as e: + raise ValueError(f"Failed to load tool {tool_name}: {e}") + + @staticmethod + def from_env() -> list[FunctionTool]: + tools = [] + if os.path.exists("config/tools.yaml"): + with open("config/tools.yaml", "r") as f: + tool_configs = yaml.safe_load(f) + for tool_type, config_entries in tool_configs.items(): + for tool_name, config in config_entries.items(): + tools.extend( + ToolFactory.load_tools(tool_type, tool_name, config) + ) + return tools diff --git a/backend/app/engine/tools/duckduckgo.py b/backend/app/engine/tools/duckduckgo.py new file mode 100644 index 0000000..b63612a --- /dev/null +++ b/backend/app/engine/tools/duckduckgo.py @@ -0,0 +1,36 @@ +from llama_index.core.tools.function_tool import FunctionTool + + +def duckduckgo_search( + query: str, + region: str = "wt-wt", + max_results: int = 10, +): + """ + Use this function to search for any query in DuckDuckGo. + Args: + query (str): The query to search in DuckDuckGo. + region Optional(str): The region to be used for the search in [country-language] convention, ex us-en, uk-en, ru-ru, etc... + max_results Optional(int): The maximum number of results to be returned. Default is 10. + """ + try: + from duckduckgo_search import DDGS + except ImportError: + raise ImportError( + "duckduckgo_search package is required to use this function." + "Please install it by running: `poetry add duckduckgo_search` or `pip install duckduckgo_search`" + ) + + params = { + "keywords": query, + "region": region, + "max_results": max_results, + } + results = [] + with DDGS() as ddg: + results = list(ddg.text(**params)) + return results + + +def get_tools(**kwargs): + return [FunctionTool.from_defaults(duckduckgo_search)] diff --git a/backend/app/engine/tools/img_gen.py b/backend/app/engine/tools/img_gen.py new file mode 100644 index 0000000..966e95d --- /dev/null +++ b/backend/app/engine/tools/img_gen.py @@ -0,0 +1,108 @@ +import os +import uuid +import logging +import requests +from typing import Optional +from pydantic import BaseModel, Field +from llama_index.core.tools import FunctionTool + +logger = logging.getLogger(__name__) + + +class ImageGeneratorToolOutput(BaseModel): + is_success: bool = Field( + ..., + description="Whether the image generation was successful.", + ) + image_url: Optional[str] = Field( + None, + description="The URL of the generated image.", + ) + error_message: Optional[str] = Field( + None, + description="The error message if the image generation failed.", + ) + + +class ImageGeneratorTool: + _IMG_OUTPUT_FORMAT = "webp" + _IMG_OUTPUT_DIR = "output/tool" + _IMG_GEN_API = "https://api.stability.ai/v2beta/stable-image/generate/core" + + def __init__(self, api_key: str = None): + if not api_key: + api_key = os.getenv("STABILITY_API_KEY") + self._api_key = api_key + self.fileserver_url_prefix = os.getenv("FILESERVER_URL_PREFIX") + if self._api_key is None: + raise ValueError( + "STABILITY_API_KEY key is required to run image generator. Get it here: https://platform.stability.ai/account/keys" + ) + if self.fileserver_url_prefix is None: + raise ValueError("FILESERVER_URL_PREFIX is required.") + + def _prepare_output_dir(self): + """ + Create the output directory if it doesn't exist + """ + if not os.path.exists(self._IMG_OUTPUT_DIR): + os.makedirs(self._IMG_OUTPUT_DIR, exist_ok=True) + + def _save_image(self, image_data: bytes): + self._prepare_output_dir() + filename = f"{uuid.uuid4()}.{self._IMG_OUTPUT_FORMAT}" + output_path = os.path.join(self._IMG_OUTPUT_DIR, filename) + with open(output_path, "wb") as f: + f.write(image_data) + url = f"{os.getenv('FILESERVER_URL_PREFIX')}/{self._IMG_OUTPUT_DIR}/{filename}" + logger.info(f"Saved image to {output_path}.\nURL: {url}") + return url + + def _call_stability_api(self, prompt: str): + headers = { + "authorization": f"Bearer {self._api_key}", + "accept": "image/*", + } + data = { + "prompt": prompt, + "output_format": self._IMG_OUTPUT_FORMAT, + } + + response = requests.post( + self._IMG_GEN_API, + headers=headers, + files={"none": ""}, + data=data, + ) + response.raise_for_status() + + return response + + def generate_image(self, prompt: str) -> ImageGeneratorToolOutput: + """ + Use this tool to generate an image based on the prompt. + Args: + prompt (str): The prompt to generate the image from. + """ + + try: + # Call the Stability API + response = self._call_stability_api(prompt) + + # Save the image and get the URL + image_url = self._save_image(response.content) + + return ImageGeneratorToolOutput( + is_success=True, + image_url=image_url, + ) + except Exception as e: + logger.exception(e, exc_info=True) + return ImageGeneratorToolOutput( + is_success=False, + error_message=str(e), + ) + + +def get_tools(**kwargs): + return [FunctionTool.from_defaults(ImageGeneratorTool(**kwargs).generate_image)] diff --git a/backend/app/engine/tools/interpreter.py b/backend/app/engine/tools/interpreter.py new file mode 100644 index 0000000..1d2c02c --- /dev/null +++ b/backend/app/engine/tools/interpreter.py @@ -0,0 +1,143 @@ +import os +import logging +import base64 +import uuid +from pydantic import BaseModel +from typing import List, Tuple, Dict, Optional +from llama_index.core.tools import FunctionTool +from e2b_code_interpreter import CodeInterpreter +from e2b_code_interpreter.models import Logs + + +logger = logging.getLogger(__name__) + + +class InterpreterExtraResult(BaseModel): + type: str + content: Optional[str] = None + filename: Optional[str] = None + url: Optional[str] = None + + +class E2BToolOutput(BaseModel): + is_error: bool + logs: Logs + results: List[InterpreterExtraResult] = [] + + +class E2BCodeInterpreter: + + output_dir = "output/tool" + + def __init__(self, api_key: str = None): + if api_key is None: + api_key = os.getenv("E2B_API_KEY") + filesever_url_prefix = os.getenv("FILESERVER_URL_PREFIX") + if not api_key: + raise ValueError( + "E2B_API_KEY key is required to run code interpreter. Get it here: https://e2b.dev/docs/getting-started/api-key" + ) + if not filesever_url_prefix: + raise ValueError( + "FILESERVER_URL_PREFIX is required to display file output from sandbox" + ) + + self.filesever_url_prefix = filesever_url_prefix + self.interpreter = CodeInterpreter(api_key=api_key) + + def __del__(self): + self.interpreter.close() + + def get_output_path(self, filename: str) -> str: + # if output directory doesn't exist, create it + if not os.path.exists(self.output_dir): + os.makedirs(self.output_dir, exist_ok=True) + return os.path.join(self.output_dir, filename) + + def save_to_disk(self, base64_data: str, ext: str) -> Dict: + filename = f"{uuid.uuid4()}.{ext}" # generate a unique filename + buffer = base64.b64decode(base64_data) + output_path = self.get_output_path(filename) + + try: + with open(output_path, "wb") as file: + file.write(buffer) + except IOError as e: + logger.error(f"Failed to write to file {output_path}: {str(e)}") + raise e + + logger.info(f"Saved file to {output_path}") + + return { + "outputPath": output_path, + "filename": filename, + } + + def get_file_url(self, filename: str) -> str: + return f"{self.filesever_url_prefix}/{self.output_dir}/{filename}" + + def parse_result(self, result) -> List[InterpreterExtraResult]: + """ + The result could include multiple formats (e.g. png, svg, etc.) but encoded in base64 + We save each result to disk and return saved file metadata (extension, filename, url) + """ + if not result: + return [] + + output = [] + + try: + formats = result.formats() + results = [result[format] for format in formats] + + for ext, data in zip(formats, results): + match ext: + case "png" | "svg" | "jpeg" | "pdf": + result = self.save_to_disk(data, ext) + filename = result["filename"] + output.append( + InterpreterExtraResult( + type=ext, + filename=filename, + url=self.get_file_url(filename), + ) + ) + case _: + output.append( + InterpreterExtraResult( + type=ext, + content=data, + ) + ) + except Exception as error: + logger.exception(error, exc_info=True) + logger.error("Error when parsing output from E2b interpreter tool", error) + + return output + + def interpret(self, code: str) -> E2BToolOutput: + """ + Execute python code in a Jupyter notebook cell, the toll will return result, stdout, stderr, display_data, and error. + + Parameters: + code (str): The python code to be executed in a single cell. + """ + logger.info( + f"\n{'='*50}\n> Running following AI-generated code:\n{code}\n{'='*50}" + ) + exec = self.interpreter.notebook.exec_cell(code) + + if exec.error: + logger.error("Error when executing code", exec.error) + output = E2BToolOutput(is_error=True, logs=exec.logs, results=[]) + else: + if len(exec.results) == 0: + output = E2BToolOutput(is_error=False, logs=exec.logs, results=[]) + else: + results = self.parse_result(exec.results[0]) + output = E2BToolOutput(is_error=False, logs=exec.logs, results=results) + return output + + +def get_tools(**kwargs): + return [FunctionTool.from_defaults(E2BCodeInterpreter(**kwargs).interpret)] diff --git a/backend/app/engine/tools/openapi_action.py b/backend/app/engine/tools/openapi_action.py new file mode 100644 index 0000000..c19187d --- /dev/null +++ b/backend/app/engine/tools/openapi_action.py @@ -0,0 +1,78 @@ +from typing import Dict, List, Tuple +from llama_index.tools.openapi import OpenAPIToolSpec +from llama_index.tools.requests import RequestsToolSpec + + +class OpenAPIActionToolSpec(OpenAPIToolSpec, RequestsToolSpec): + """ + A combination of OpenAPI and Requests tool specs that can parse OpenAPI specs and make requests. + + openapi_uri: str: The file path or URL to the OpenAPI spec. + domain_headers: dict: Whitelist domains and the headers to use. + """ + + spec_functions = OpenAPIToolSpec.spec_functions + RequestsToolSpec.spec_functions + # Cached parsed specs by URI + _specs: Dict[str, Tuple[Dict, List[str]]] = {} + + def __init__(self, openapi_uri: str, domain_headers: dict = None, **kwargs): + if domain_headers is None: + domain_headers = {} + if openapi_uri not in self._specs: + openapi_spec, servers = self._load_openapi_spec(openapi_uri) + self._specs[openapi_uri] = (openapi_spec, servers) + else: + openapi_spec, servers = self._specs[openapi_uri] + + # Add the servers to the domain headers if they are not already present + for server in servers: + if server not in domain_headers: + domain_headers[server] = {} + + OpenAPIToolSpec.__init__(self, spec=openapi_spec) + RequestsToolSpec.__init__(self, domain_headers) + + @staticmethod + def _load_openapi_spec(uri: str) -> Tuple[Dict, List[str]]: + """ + Load an OpenAPI spec from a URI. + + Args: + uri (str): A file path or URL to the OpenAPI spec. + + Returns: + List[Document]: A list of Document objects. + """ + import yaml + from urllib.parse import urlparse + + if uri.startswith("http"): + import requests + + response = requests.get(uri) + if response.status_code != 200: + raise ValueError( + "Could not initialize OpenAPIActionToolSpec: " + f"Failed to load OpenAPI spec from {uri}, status code: {response.status_code}" + ) + spec = yaml.safe_load(response.text) + elif uri.startswith("file"): + filepath = urlparse(uri).path + with open(filepath, "r") as file: + spec = yaml.safe_load(file) + else: + raise ValueError( + "Could not initialize OpenAPIActionToolSpec: Invalid OpenAPI URI provided. " + "Only HTTP and file path are supported." + ) + # Add the servers to the whitelist + try: + servers = [ + urlparse(server["url"]).netloc for server in spec.get("servers", []) + ] + except KeyError as e: + raise ValueError( + "Could not initialize OpenAPIActionToolSpec: Invalid OpenAPI spec provided. " + "Could not get `servers` from the spec." + ) from e + return spec, servers diff --git a/backend/app/engine/tools/weather.py b/backend/app/engine/tools/weather.py new file mode 100644 index 0000000..c8b6f1b --- /dev/null +++ b/backend/app/engine/tools/weather.py @@ -0,0 +1,73 @@ +"""Open Meteo weather map tool spec.""" + +import logging +import requests +import pytz +from llama_index.core.tools import FunctionTool + +logger = logging.getLogger(__name__) + + +class OpenMeteoWeather: + geo_api = "https://geocoding-api.open-meteo.com/v1" + weather_api = "https://api.open-meteo.com/v1" + + @classmethod + def _get_geo_location(cls, location: str) -> dict: + """Get geo location from location name.""" + params = {"name": location, "count": 10, "language": "en", "format": "json"} + response = requests.get(f"{cls.geo_api}/search", params=params) + if response.status_code != 200: + raise Exception(f"Failed to fetch geo location: {response.status_code}") + else: + data = response.json() + result = data["results"][0] + geo_location = { + "id": result["id"], + "name": result["name"], + "latitude": result["latitude"], + "longitude": result["longitude"], + } + return geo_location + + @classmethod + def get_weather_information(cls, location: str) -> dict: + """Use this function to get the weather of any given location. + Note that the weather code should follow WMO Weather interpretation codes (WW): + 0: Clear sky + 1, 2, 3: Mainly clear, partly cloudy, and overcast + 45, 48: Fog and depositing rime fog + 51, 53, 55: Drizzle: Light, moderate, and dense intensity + 56, 57: Freezing Drizzle: Light and dense intensity + 61, 63, 65: Rain: Slight, moderate and heavy intensity + 66, 67: Freezing Rain: Light and heavy intensity + 71, 73, 75: Snow fall: Slight, moderate, and heavy intensity + 77: Snow grains + 80, 81, 82: Rain showers: Slight, moderate, and violent + 85, 86: Snow showers slight and heavy + 95: Thunderstorm: Slight or moderate + 96, 99: Thunderstorm with slight and heavy hail + """ + logger.info( + f"Calling open-meteo api to get weather information of location: {location}" + ) + geo_location = cls._get_geo_location(location) + timezone = pytz.timezone("UTC").zone + params = { + "latitude": geo_location["latitude"], + "longitude": geo_location["longitude"], + "current": "temperature_2m,weather_code", + "hourly": "temperature_2m,weather_code", + "daily": "weather_code", + "timezone": timezone, + } + response = requests.get(f"{cls.weather_api}/forecast", params=params) + if response.status_code != 200: + raise Exception( + f"Failed to fetch weather information: {response.status_code}" + ) + return response.json() + + +def get_tools(**kwargs): + return [FunctionTool.from_defaults(OpenMeteoWeather.get_weather_information)] diff --git a/backend/app/llmhub.py b/backend/app/llmhub.py new file mode 100644 index 0000000..69e0e32 --- /dev/null +++ b/backend/app/llmhub.py @@ -0,0 +1,61 @@ +from llama_index.embeddings.openai import OpenAIEmbedding +from llama_index.core.settings import Settings +from typing import Dict +import os + +DEFAULT_MODEL = "gpt-3.5-turbo" +DEFAULT_EMBEDDING_MODEL = "text-embedding-3-large" + +class TSIEmbedding(OpenAIEmbedding): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._query_engine = self._text_engine = self.model_name + +def llm_config_from_env() -> Dict: + from llama_index.core.constants import DEFAULT_TEMPERATURE + + model = os.getenv("MODEL", DEFAULT_MODEL) + temperature = os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE) + max_tokens = os.getenv("LLM_MAX_TOKENS") + api_key = os.getenv("T_SYSTEMS_LLMHUB_API_KEY") + api_base = os.getenv("T_SYSTEMS_LLMHUB_BASE_URL") + + config = { + "model": model, + "api_key": api_key, + "api_base": api_base, + "temperature": float(temperature), + "max_tokens": int(max_tokens) if max_tokens is not None else None, + } + return config + + +def embedding_config_from_env() -> Dict: + from llama_index.core.constants import DEFAULT_EMBEDDING_DIM + + model = os.getenv("EMBEDDING_MODEL", DEFAULT_EMBEDDING_MODEL) + dimension = os.getenv("EMBEDDING_DIM", DEFAULT_EMBEDDING_DIM) + api_key = os.getenv("T_SYSTEMS_LLMHUB_API_KEY") + api_base = os.getenv("T_SYSTEMS_LLMHUB_BASE_URL") + + config = { + "model_name": model, + "dimension": int(dimension) if dimension is not None else None, + "api_key": api_key, + "api_base": api_base, + } + return config + +def init_llmhub(): + from llama_index.llms.openai_like import OpenAILike + + llm_configs = llm_config_from_env() + embedding_configs = embedding_config_from_env() + + Settings.embed_model = TSIEmbedding(**embedding_configs) + Settings.llm = OpenAILike( + **llm_configs, + is_chat_model=True, + is_function_calling_model=False, + context_window=4096, + ) \ No newline at end of file diff --git a/backend/app/observability.py b/backend/app/observability.py new file mode 100644 index 0000000..28019c3 --- /dev/null +++ b/backend/app/observability.py @@ -0,0 +1,2 @@ +def init_observability(): + pass diff --git a/backend/app/settings.py b/backend/app/settings.py new file mode 100644 index 0000000..b723bf3 --- /dev/null +++ b/backend/app/settings.py @@ -0,0 +1,172 @@ +import os +from typing import Dict + +from llama_index.core.settings import Settings + + +def init_settings(): + model_provider = os.getenv("MODEL_PROVIDER") + match model_provider: + case "openai": + init_openai() + case "groq": + init_groq() + case "ollama": + init_ollama() + case "anthropic": + init_anthropic() + case "gemini": + init_gemini() + case "mistral": + init_mistral() + case "azure-openai": + init_azure_openai() + case "t-systems": + from .llmhub import init_llmhub + + init_llmhub() + case _: + raise ValueError(f"Invalid model provider: {model_provider}") + + Settings.chunk_size = int(os.getenv("CHUNK_SIZE", "1024")) + Settings.chunk_overlap = int(os.getenv("CHUNK_OVERLAP", "20")) + + +def init_ollama(): + from llama_index.embeddings.ollama import OllamaEmbedding + from llama_index.llms.ollama.base import DEFAULT_REQUEST_TIMEOUT, Ollama + + base_url = os.getenv("OLLAMA_BASE_URL") or "http://127.0.0.1:11434" + request_timeout = float( + os.getenv("OLLAMA_REQUEST_TIMEOUT", DEFAULT_REQUEST_TIMEOUT) + ) + Settings.embed_model = OllamaEmbedding( + base_url=base_url, + model_name=os.getenv("EMBEDDING_MODEL"), + ) + Settings.llm = Ollama( + base_url=base_url, model=os.getenv("MODEL"), request_timeout=request_timeout + ) + + +def init_openai(): + from llama_index.core.constants import DEFAULT_TEMPERATURE + from llama_index.embeddings.openai import OpenAIEmbedding + from llama_index.llms.openai import OpenAI + + max_tokens = os.getenv("LLM_MAX_TOKENS") + config = { + "model": os.getenv("MODEL"), + "temperature": float(os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE)), + "max_tokens": int(max_tokens) if max_tokens is not None else None, + } + Settings.llm = OpenAI(**config) + + dimensions = os.getenv("EMBEDDING_DIM") + config = { + "model": os.getenv("EMBEDDING_MODEL"), + "dimensions": int(dimensions) if dimensions is not None else None, + } + Settings.embed_model = OpenAIEmbedding(**config) + + +def init_azure_openai(): + from llama_index.core.constants import DEFAULT_TEMPERATURE + from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding + from llama_index.llms.azure_openai import AzureOpenAI + + llm_deployment = os.environ["AZURE_OPENAI_LLM_DEPLOYMENT"] + embedding_deployment = os.environ["AZURE_OPENAI_EMBEDDING_DEPLOYMENT"] + max_tokens = os.getenv("LLM_MAX_TOKENS") + temperature = os.getenv("LLM_TEMPERATURE", DEFAULT_TEMPERATURE) + dimensions = os.getenv("EMBEDDING_DIM") + + azure_config = { + "api_key": os.environ["AZURE_OPENAI_KEY"], + "azure_endpoint": os.environ["AZURE_OPENAI_ENDPOINT"], + "api_version": os.getenv("AZURE_OPENAI_API_VERSION") + or os.getenv("OPENAI_API_VERSION"), + } + + Settings.llm = AzureOpenAI( + model=os.getenv("MODEL"), + max_tokens=int(max_tokens) if max_tokens is not None else None, + temperature=float(temperature), + deployment_name=llm_deployment, + **azure_config, + ) + + Settings.embed_model = AzureOpenAIEmbedding( + model=os.getenv("EMBEDDING_MODEL"), + dimensions=int(dimensions) if dimensions is not None else None, + deployment_name=embedding_deployment, + **azure_config, + ) + + +def init_fastembed(): + """ + Use Qdrant Fastembed as the local embedding provider. + """ + from llama_index.embeddings.fastembed import FastEmbedEmbedding + + embed_model_map: Dict[str, str] = { + # Small and multilingual + "all-MiniLM-L6-v2": "sentence-transformers/all-MiniLM-L6-v2", + # Large and multilingual + "paraphrase-multilingual-mpnet-base-v2": "sentence-transformers/paraphrase-multilingual-mpnet-base-v2", # noqa: E501 + } + + # This will download the model automatically if it is not already downloaded + Settings.embed_model = FastEmbedEmbedding( + model_name=embed_model_map[os.getenv("EMBEDDING_MODEL")] + ) + + +def init_groq(): + from llama_index.llms.groq import Groq + + model_map: Dict[str, str] = { + "llama3-8b": "llama3-8b-8192", + "llama3-70b": "llama3-70b-8192", + "mixtral-8x7b": "mixtral-8x7b-32768", + } + + Settings.llm = Groq(model=model_map[os.getenv("MODEL")]) + # Groq does not provide embeddings, so we use FastEmbed instead + init_fastembed() + + +def init_anthropic(): + from llama_index.llms.anthropic import Anthropic + + model_map: Dict[str, str] = { + "claude-3-opus": "claude-3-opus-20240229", + "claude-3-sonnet": "claude-3-sonnet-20240229", + "claude-3-haiku": "claude-3-haiku-20240307", + "claude-2.1": "claude-2.1", + "claude-instant-1.2": "claude-instant-1.2", + } + + Settings.llm = Anthropic(model=model_map[os.getenv("MODEL")]) + # Anthropic does not provide embeddings, so we use FastEmbed instead + init_fastembed() + + +def init_gemini(): + from llama_index.embeddings.gemini import GeminiEmbedding + from llama_index.llms.gemini import Gemini + + model_name = f"models/{os.getenv('MODEL')}" + embed_model_name = f"models/{os.getenv('EMBEDDING_MODEL')}" + + Settings.llm = Gemini(model=model_name) + Settings.embed_model = GeminiEmbedding(model_name=embed_model_name) + + +def init_mistral(): + from llama_index.embeddings.mistralai import MistralAIEmbedding + from llama_index.llms.mistralai import MistralAI + + Settings.llm = MistralAI(model=os.getenv("MODEL")) + Settings.embed_model = MistralAIEmbedding(model_name=os.getenv("EMBEDDING_MODEL")) diff --git a/backend/config/loaders.yaml b/backend/config/loaders.yaml new file mode 100644 index 0000000..d746c61 --- /dev/null +++ b/backend/config/loaders.yaml @@ -0,0 +1,10 @@ +file: + # use_llama_parse: Use LlamaParse if `true`. Needs a `LLAMA_CLOUD_API_KEY` from https://cloud.llamaindex.ai set as environment variable + use_llama_parse: true +db: + # The configuration for the database loader, only supports MySQL and PostgreSQL databases for now. + # uri: The URI for the database. E.g.: mysql+pymysql://user:password@localhost:3306/db or postgresql+psycopg2://user:password@localhost:5432/db + # query: The query to fetch data from the database. E.g.: SELECT * FROM table + - uri: mysql+pymysql://zjinfo1:Dy2Bcr53Hm5xRkba@110.42.234.166:3306/zjinfo1 + queries: + - SELECT * FROM mytable diff --git a/backend/config/tools.yaml b/backend/config/tools.yaml new file mode 100644 index 0000000..df5690c --- /dev/null +++ b/backend/config/tools.yaml @@ -0,0 +1,4 @@ +local: + weather: {} + interpreter: {} +llamahub: {} diff --git a/backend/data/101.pdf b/backend/data/101.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ae5acffd5398b7c59e2df9e6dead2d99128b719c GIT binary patch literal 47931 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" ] +readme = "README.md" + +[tool.poetry.scripts] +generate = "app.engine.generate:generate_datasource" + +[tool.poetry.dependencies] +python = "^3.11,<3.12" +fastapi = "^0.109.1" +python-dotenv = "^1.0.0" +aiostream = "^0.5.2" +llama-index = "0.10.58" +cachetools = "^5.3.3" + +[tool.poetry.dependencies.uvicorn] +extras = [ "standard" ] +version = "^0.23.2" + +[tool.poetry.dependencies.llama-index-readers-database] +version = "^0.1.3" + +[tool.poetry.dependencies.pymysql] +version = "^1.1.0" +extras = [ "rsa" ] + +[tool.poetry.dependencies.psycopg2] +version = "^2.9.9" + +[tool.poetry.dependencies.llama-index-indices-managed-llama-cloud] +version = "^0.2.7" + +[tool.poetry.dependencies.docx2txt] +version = "^0.8" + +[tool.poetry.dependencies.e2b_code_interpreter] +version = "0.0.7" + +[tool.poetry.dependencies.llama-index-agent-openai] +version = "0.2.6" + +[build-system] +requires = [ "poetry-core" ] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..faf27f1 --- /dev/null +++ b/frontend/.env @@ -0,0 +1,6 @@ +# The backend API for chat endpoint. +NEXT_PUBLIC_CHAT_API=http://localhost:8000/api/chat + +# Let's the user change indexes in LlamaCloud projects +NEXT_PUBLIC_USE_LLAMACLOUD=true + diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..e96fdb8 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["next/core-web-vitals", "prettier"], + "rules": { + "max-params": ["error", 4], + "prefer-const": "error" + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b7ee22a --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +output/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..5c738ab --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine as build + +WORKDIR /app + +# Install dependencies +COPY package.json package-lock.* ./ +RUN npm install + +# Build the application +COPY . . +RUN npm run build + +# ==================================== +FROM build as release + +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2eb1eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,71 @@ +This is a [LlamaIndex](https://www.llamaindex.ai/) project using [Next.js](https://nextjs.org/) bootstrapped with [`create-llama`](https://github.com/run-llama/LlamaIndexTS/tree/main/packages/create-llama). + +## Getting Started + +First, install the dependencies: + +``` +npm install +``` + +Second, generate the embeddings of the documents in the `./data` directory (if this folder exists - otherwise, skip this step): + +``` +npm run generate +``` + +Third, run the development server: + +``` +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Using Docker + +1. Build an image for the Next.js app: + +``` +docker build -t . +``` + +2. Generate embeddings: + +Parse the data and generate the vector embeddings if the `./data` folder exists - otherwise, skip this step: + +``` +docker run \ + --rm \ + -v $(pwd)/.env:/app/.env \ # Use ENV variables and configuration from your file-system + -v $(pwd)/config:/app/config \ + -v $(pwd)/data:/app/data \ + -v $(pwd)/cache:/app/cache \ # Use your file system to store the vector database + \ + npm run generate +``` + +3. Start the app: + +``` +docker run \ + --rm \ + -v $(pwd)/.env:/app/.env \ # Use ENV variables and configuration from your file-system + -v $(pwd)/config:/app/config \ + -v $(pwd)/cache:/app/cache \ # Use your file system to store gea vector database + -p 3000:3000 \ + +``` + +## Learn More + +To learn more about LlamaIndex, take a look at the following resources: + +- [LlamaIndex Documentation](https://docs.llamaindex.ai) - learn about LlamaIndex (Python features). +- [LlamaIndexTS Documentation](https://ts.llamaindex.ai) - learn about LlamaIndex (Typescript features). + +You can check out [the LlamaIndexTS GitHub repository](https://github.com/run-llama/LlamaIndexTS) - your feedback and contributions are welcome! diff --git a/frontend/app/components/chat-section.tsx b/frontend/app/components/chat-section.tsx new file mode 100644 index 0000000..b33a6cf --- /dev/null +++ b/frontend/app/components/chat-section.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useChat } from "ai/react"; +import { ChatInput, ChatMessages } from "./ui/chat"; +import { useClientConfig } from "./ui/chat/hooks/use-config"; + +export default function ChatSection() { + const { backend } = useClientConfig(); + const { + messages, + input, + isLoading, + handleSubmit, + handleInputChange, + reload, + stop, + append, + setInput, + } = useChat({ + api: `${backend}/api/chat`, + headers: { + "Content-Type": "application/json", // using JSON because of vercel/ai 2.2.26 + }, + onError: (error: unknown) => { + if (!(error instanceof Error)) throw error; + const message = JSON.parse(error.message); + alert(message.detail); + }, + }); + + return ( +

+ + +
+ ); +} diff --git a/frontend/app/components/header.tsx b/frontend/app/components/header.tsx new file mode 100644 index 0000000..f02ce73 --- /dev/null +++ b/frontend/app/components/header.tsx @@ -0,0 +1,28 @@ +import Image from "next/image"; + +export default function Header() { + return ( +
+

+ Get started by editing  + app/page.tsx +

+ +
+ ); +} diff --git a/frontend/app/components/ui/README.md b/frontend/app/components/ui/README.md new file mode 100644 index 0000000..ebfcf48 --- /dev/null +++ b/frontend/app/components/ui/README.md @@ -0,0 +1 @@ +Using the chat component from https://github.com/marcusschiesser/ui (based on https://ui.shadcn.com/) diff --git a/frontend/app/components/ui/button.tsx b/frontend/app/components/ui/button.tsx new file mode 100644 index 0000000..662b040 --- /dev/null +++ b/frontend/app/components/ui/button.tsx @@ -0,0 +1,56 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "./lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend/app/components/ui/chat/chat-actions.tsx b/frontend/app/components/ui/chat/chat-actions.tsx new file mode 100644 index 0000000..151ef61 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-actions.tsx @@ -0,0 +1,28 @@ +import { PauseCircle, RefreshCw } from "lucide-react"; + +import { Button } from "../button"; +import { ChatHandler } from "./chat.interface"; + +export default function ChatActions( + props: Pick & { + showReload?: boolean; + showStop?: boolean; + }, +) { + return ( +
+ {props.showStop && ( + + )} + {props.showReload && ( + + )} +
+ ); +} diff --git a/frontend/app/components/ui/chat/chat-input.tsx b/frontend/app/components/ui/chat/chat-input.tsx new file mode 100644 index 0000000..4c58296 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-input.tsx @@ -0,0 +1,127 @@ +import { JSONValue } from "ai"; +import { useState } from "react"; +import { Button } from "../button"; +import { DocumentPreview } from "../document-preview"; +import FileUploader from "../file-uploader"; +import { Input } from "../input"; +import UploadImagePreview from "../upload-image-preview"; +import { ChatHandler } from "./chat.interface"; +import { useFile } from "./hooks/use-file"; +import { LlamaCloudSelector } from "./widgets/LlamaCloudSelector"; + +const ALLOWED_EXTENSIONS = ["png", "jpg", "jpeg", "csv", "pdf", "txt", "docx"]; + +export default function ChatInput( + props: Pick< + ChatHandler, + | "isLoading" + | "input" + | "onFileUpload" + | "onFileError" + | "handleSubmit" + | "handleInputChange" + | "messages" + | "setInput" + | "append" + > & { + requestParams?: any; + }, +) { + const { + imageUrl, + setImageUrl, + uploadFile, + files, + removeDoc, + reset, + getAnnotations, + } = useFile(); + const [requestData, setRequestData] = useState(); + + // default submit function does not handle including annotations in the message + // so we need to use append function to submit new message with annotations + const handleSubmitWithAnnotations = ( + e: React.FormEvent, + annotations: JSONValue[] | undefined, + ) => { + e.preventDefault(); + props.append!( + { + content: props.input, + role: "user", + createdAt: new Date(), + annotations, + }, + { data: requestData }, + ); + props.setInput!(""); + }; + + const onSubmit = (e: React.FormEvent) => { + const annotations = getAnnotations(); + if (annotations.length) { + handleSubmitWithAnnotations(e, annotations); + return reset(); + } + props.handleSubmit(e, { data: requestData }); + }; + + const handleUploadFile = async (file: File) => { + if (imageUrl || files.length > 0) { + alert("You can only upload one file at a time."); + return; + } + try { + await uploadFile(file, props.requestParams); + props.onFileUpload?.(file); + } catch (error: any) { + props.onFileError?.(error.message); + } + }; + + return ( +
+ {imageUrl && ( + setImageUrl(null)} /> + )} + {files.length > 0 && ( +
+ {files.map((file) => ( + removeDoc(file)} + /> + ))} +
+ )} +
+ + + {process.env.NEXT_PUBLIC_USE_LLAMACLOUD === "true" && ( + + )} + +
+ + ); +} diff --git a/frontend/app/components/ui/chat/chat-message/chat-avatar.tsx b/frontend/app/components/ui/chat/chat-message/chat-avatar.tsx new file mode 100644 index 0000000..ce04e30 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/chat-avatar.tsx @@ -0,0 +1,25 @@ +import { User2 } from "lucide-react"; +import Image from "next/image"; + +export default function ChatAvatar({ role }: { role: string }) { + if (role === "user") { + return ( +
+ +
+ ); + } + + return ( +
+ Llama Logo +
+ ); +} diff --git a/frontend/app/components/ui/chat/chat-message/chat-events.tsx b/frontend/app/components/ui/chat/chat-message/chat-events.tsx new file mode 100644 index 0000000..3dfad75 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/chat-events.tsx @@ -0,0 +1,50 @@ +import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { Button } from "../../button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../../collapsible"; +import { EventData } from "../index"; + +export function ChatEvents({ + data, + isLoading, +}: { + data: EventData[]; + isLoading: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + + const buttonLabel = isOpen ? "Hide events" : "Show events"; + + const EventIcon = isOpen ? ( + + ) : ( + + ); + + return ( +
+ + + + + +
+ {data.map((eventItem, index) => ( +
+ {eventItem.title} +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/app/components/ui/chat/chat-message/chat-files.tsx b/frontend/app/components/ui/chat/chat-message/chat-files.tsx new file mode 100644 index 0000000..5139c54 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/chat-files.tsx @@ -0,0 +1,13 @@ +import { DocumentPreview } from "../../document-preview"; +import { DocumentFileData } from "../index"; + +export function ChatFiles({ data }: { data: DocumentFileData }) { + if (!data.files.length) return null; + return ( +
+ {data.files.map((file) => ( + + ))} +
+ ); +} diff --git a/frontend/app/components/ui/chat/chat-message/chat-image.tsx b/frontend/app/components/ui/chat/chat-message/chat-image.tsx new file mode 100644 index 0000000..2de28c3 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/chat-image.tsx @@ -0,0 +1,17 @@ +import Image from "next/image"; +import { type ImageData } from "../index"; + +export function ChatImage({ data }: { data: ImageData }) { + return ( +
+ +
+ ); +} diff --git a/frontend/app/components/ui/chat/chat-message/chat-sources.tsx b/frontend/app/components/ui/chat/chat-message/chat-sources.tsx new file mode 100644 index 0000000..1d4ccb6 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/chat-sources.tsx @@ -0,0 +1,123 @@ +import { Check, Copy } from "lucide-react"; +import { useMemo } from "react"; +import { Button } from "../../button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "../../hover-card"; +import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; +import { SourceData } from "../index"; +import PdfDialog from "../widgets/PdfDialog"; + +const SCORE_THRESHOLD = 0.3; + +function SourceNumberButton({ index }: { index: number }) { + return ( +
+ {index + 1} +
+ ); +} + +type NodeInfo = { + id: string; + url?: string; +}; + +export function ChatSources({ data }: { data: SourceData }) { + const sources: NodeInfo[] = useMemo(() => { + // aggregate nodes by url or file_path (get the highest one by score) + const nodesByPath: { [path: string]: NodeInfo } = {}; + + data.nodes + .filter((node) => (node.score ?? 1) > SCORE_THRESHOLD) + .sort((a, b) => (b.score ?? 1) - (a.score ?? 1)) + .forEach((node) => { + const nodeInfo = { + id: node.id, + url: node.url, + }; + const key = nodeInfo.url ?? nodeInfo.id; // use id as key for UNKNOWN type + if (!nodesByPath[key]) { + nodesByPath[key] = nodeInfo; + } + }); + + return Object.values(nodesByPath); + }, [data.nodes]); + + if (sources.length === 0) return null; + + return ( +
+ Sources: +
+ {sources.map((nodeInfo: NodeInfo, index: number) => { + if (nodeInfo.url?.endsWith(".pdf")) { + return ( + } + /> + ); + } + return ( +
+ + + + + + + + +
+ ); + })} +
+
+ ); +} + +function NodeInfo({ nodeInfo }: { nodeInfo: NodeInfo }) { + const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 }); + + if (nodeInfo.url) { + // this is a node generated by the web loader or file loader, + // add a link to view its URL and a button to copy the URL to the clipboard + return ( +
+ + {nodeInfo.url} + + +
+ ); + } + + // node generated by unknown loader, implement renderer by analyzing logged out metadata + return ( +

+ Sorry, unknown node type. Please add a new renderer in the NodeInfo + component. +

+ ); +} diff --git a/frontend/app/components/ui/chat/chat-message/chat-suggestedQuestions.tsx b/frontend/app/components/ui/chat/chat-message/chat-suggestedQuestions.tsx new file mode 100644 index 0000000..ea662e4 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/chat-suggestedQuestions.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { ChatHandler, SuggestedQuestionsData } from ".."; + +export function SuggestedQuestions({ + questions, + append, +}: { + questions: SuggestedQuestionsData; + append: Pick["append"]; +}) { + const [showQuestions, setShowQuestions] = useState(questions.length > 0); + + return ( + showQuestions && + append !== undefined && ( + + ) + ); +} diff --git a/frontend/app/components/ui/chat/chat-message/chat-tools.tsx b/frontend/app/components/ui/chat/chat-message/chat-tools.tsx new file mode 100644 index 0000000..202f982 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/chat-tools.tsx @@ -0,0 +1,26 @@ +import { ToolData } from "../index"; +import { WeatherCard, WeatherData } from "../widgets/WeatherCard"; + +// TODO: If needed, add displaying more tool outputs here +export default function ChatTools({ data }: { data: ToolData }) { + if (!data) return null; + const { toolCall, toolOutput } = data; + + if (toolOutput.isError) { + return ( +
+ There was an error when calling the tool {toolCall.name} with input:{" "} +
+ {JSON.stringify(toolCall.input)} +
+ ); + } + + switch (toolCall.name) { + case "get_weather_information": + const weatherData = toolOutput.output as unknown as WeatherData; + return ; + default: + return null; + } +} diff --git a/frontend/app/components/ui/chat/chat-message/codeblock.tsx b/frontend/app/components/ui/chat/chat-message/codeblock.tsx new file mode 100644 index 0000000..e71a408 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/codeblock.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Check, Copy, Download } from "lucide-react"; +import { FC, memo } from "react"; +import { Prism, SyntaxHighlighterProps } from "react-syntax-highlighter"; +import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; + +import { Button } from "../../button"; +import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; + +// TODO: Remove this when @type/react-syntax-highlighter is updated +const SyntaxHighlighter = Prism as unknown as FC; + +interface Props { + language: string; + value: string; +} + +interface languageMap { + [key: string]: string | undefined; +} + +export const programmingLanguages: languageMap = { + javascript: ".js", + python: ".py", + java: ".java", + c: ".c", + cpp: ".cpp", + "c++": ".cpp", + "c#": ".cs", + ruby: ".rb", + php: ".php", + swift: ".swift", + "objective-c": ".m", + kotlin: ".kt", + typescript: ".ts", + go: ".go", + perl: ".pl", + rust: ".rs", + scala: ".scala", + haskell: ".hs", + lua: ".lua", + shell: ".sh", + sql: ".sql", + html: ".html", + css: ".css", + // add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component +}; + +export const generateRandomString = (length: number, lowercase = false) => { + const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0 + let result = ""; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return lowercase ? result.toLowerCase() : result; +}; + +const CodeBlock: FC = memo(({ language, value }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); + + const downloadAsFile = () => { + if (typeof window === "undefined") { + return; + } + const fileExtension = programmingLanguages[language] || ".file"; + const suggestedFileName = `file-${generateRandomString( + 3, + true, + )}${fileExtension}`; + const fileName = window.prompt("Enter file name" || "", suggestedFileName); + + if (!fileName) { + // User pressed cancel on prompt. + return; + } + + const blob = new Blob([value], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.download = fileName; + link.href = url; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const onCopy = () => { + if (isCopied) return; + copyToClipboard(value); + }; + + return ( +
+
+ {language} +
+ + +
+
+ + {value} + +
+ ); +}); +CodeBlock.displayName = "CodeBlock"; + +export { CodeBlock }; diff --git a/frontend/app/components/ui/chat/chat-message/index.tsx b/frontend/app/components/ui/chat/chat-message/index.tsx new file mode 100644 index 0000000..e71903e --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/index.tsx @@ -0,0 +1,156 @@ +import { Check, Copy } from "lucide-react"; + +import { Message } from "ai"; +import { Fragment } from "react"; +import { Button } from "../../button"; +import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard"; +import { + ChatHandler, + DocumentFileData, + EventData, + ImageData, + MessageAnnotation, + MessageAnnotationType, + SourceData, + SuggestedQuestionsData, + ToolData, + getAnnotationData, +} from "../index"; +import ChatAvatar from "./chat-avatar"; +import { ChatEvents } from "./chat-events"; +import { ChatFiles } from "./chat-files"; +import { ChatImage } from "./chat-image"; +import { ChatSources } from "./chat-sources"; +import { SuggestedQuestions } from "./chat-suggestedQuestions"; +import ChatTools from "./chat-tools"; +import Markdown from "./markdown"; + +type ContentDisplayConfig = { + order: number; + component: JSX.Element | null; +}; + +function ChatMessageContent({ + message, + isLoading, + append, +}: { + message: Message; + isLoading: boolean; + append: Pick["append"]; +}) { + const annotations = message.annotations as MessageAnnotation[] | undefined; + if (!annotations?.length) return ; + + const imageData = getAnnotationData( + annotations, + MessageAnnotationType.IMAGE, + ); + const contentFileData = getAnnotationData( + annotations, + MessageAnnotationType.DOCUMENT_FILE, + ); + const eventData = getAnnotationData( + annotations, + MessageAnnotationType.EVENTS, + ); + const sourceData = getAnnotationData( + annotations, + MessageAnnotationType.SOURCES, + ); + const toolData = getAnnotationData( + annotations, + MessageAnnotationType.TOOLS, + ); + const suggestedQuestionsData = getAnnotationData( + annotations, + MessageAnnotationType.SUGGESTED_QUESTIONS, + ); + + const contents: ContentDisplayConfig[] = [ + { + order: 1, + component: imageData[0] ? : null, + }, + { + order: -3, + component: + eventData.length > 0 ? ( + + ) : null, + }, + { + order: 2, + component: contentFileData[0] ? ( + + ) : null, + }, + { + order: -1, + component: toolData[0] ? : null, + }, + { + order: 0, + component: , + }, + { + order: 3, + component: sourceData[0] ? : null, + }, + { + order: 4, + component: suggestedQuestionsData[0] ? ( + + ) : null, + }, + ]; + + return ( +
+ {contents + .sort((a, b) => a.order - b.order) + .map((content, index) => ( + {content.component} + ))} +
+ ); +} + +export default function ChatMessage({ + chatMessage, + isLoading, + append, +}: { + chatMessage: Message; + isLoading: boolean; + append: Pick["append"]; +}) { + const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); + return ( +
+ +
+ + +
+
+ ); +} diff --git a/frontend/app/components/ui/chat/chat-message/markdown.tsx b/frontend/app/components/ui/chat/chat-message/markdown.tsx new file mode 100644 index 0000000..79791b4 --- /dev/null +++ b/frontend/app/components/ui/chat/chat-message/markdown.tsx @@ -0,0 +1,88 @@ +import "katex/dist/katex.min.css"; +import { FC, memo } from "react"; +import ReactMarkdown, { Options } from "react-markdown"; +import rehypeKatex from "rehype-katex"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; + +import { CodeBlock } from "./codeblock"; + +const MemoizedReactMarkdown: FC = memo( + ReactMarkdown, + (prevProps, nextProps) => + prevProps.children === nextProps.children && + prevProps.className === nextProps.className, +); + +const preprocessLaTeX = (content: string) => { + // Replace block-level LaTeX delimiters \[ \] with $$ $$ + const blockProcessedContent = content.replace( + /\\\[([\s\S]*?)\\\]/g, + (_, equation) => `$$${equation}$$`, + ); + // Replace inline LaTeX delimiters \( \) with $ $ + const inlineProcessedContent = blockProcessedContent.replace( + /\\\[([\s\S]*?)\\\]/g, + (_, equation) => `$${equation}$`, + ); + return inlineProcessedContent; +}; + +const preprocessMedia = (content: string) => { + // Remove `sandbox:` from the beginning of the URL + // to fix OpenAI's models issue appending `sandbox:` to the relative URL + return content.replace(/(sandbox|attachment|snt):/g, ""); +}; + +const preprocessContent = (content: string) => { + return preprocessMedia(preprocessLaTeX(content)); +}; + +export default function Markdown({ content }: { content: string }) { + const processedContent = preprocessContent(content); + + return ( + {children}

; + }, + code({ node, inline, className, children, ...props }) { + if (children.length) { + if (children[0] == "▍") { + return ( + + ); + } + + children[0] = (children[0] as string).replace("`▍`", "▍"); + } + + const match = /language-(\w+)/.exec(className || ""); + + if (inline) { + return ( + + {children} + + ); + } + + return ( + + ); + }, + }} + > + {processedContent} +
+ ); +} diff --git a/frontend/app/components/ui/chat/chat-messages.tsx b/frontend/app/components/ui/chat/chat-messages.tsx new file mode 100644 index 0000000..e0afd8b --- /dev/null +++ b/frontend/app/components/ui/chat/chat-messages.tsx @@ -0,0 +1,95 @@ +import { Loader2 } from "lucide-react"; +import { useEffect, useRef } from "react"; + +import { Button } from "../button"; +import ChatActions from "./chat-actions"; +import ChatMessage from "./chat-message"; +import { ChatHandler } from "./chat.interface"; +import { useClientConfig } from "./hooks/use-config"; + +export default function ChatMessages( + props: Pick< + ChatHandler, + "messages" | "isLoading" | "reload" | "stop" | "append" + >, +) { + const { starterQuestions } = useClientConfig(); + const scrollableChatContainerRef = useRef(null); + const messageLength = props.messages.length; + const lastMessage = props.messages[messageLength - 1]; + + const scrollToBottom = () => { + if (scrollableChatContainerRef.current) { + scrollableChatContainerRef.current.scrollTop = + scrollableChatContainerRef.current.scrollHeight; + } + }; + + const isLastMessageFromAssistant = + messageLength > 0 && lastMessage?.role !== "user"; + const showReload = + props.reload && !props.isLoading && isLastMessageFromAssistant; + const showStop = props.stop && props.isLoading; + + // `isPending` indicate + // that stream response is not yet received from the server, + // so we show a loading indicator to give a better UX. + const isPending = props.isLoading && !isLastMessageFromAssistant; + + useEffect(() => { + scrollToBottom(); + }, [messageLength, lastMessage]); + + return ( +
+
+ {props.messages.map((m, i) => { + const isLoadingMessage = i === messageLength - 1 && props.isLoading; + return ( + + ); + })} + {isPending && ( +
+ +
+ )} +
+ {(showReload || showStop) && ( +
+ +
+ )} + {!messageLength && starterQuestions?.length && props.append && ( +
+
+ {starterQuestions.map((question, i) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/app/components/ui/chat/chat.interface.ts b/frontend/app/components/ui/chat/chat.interface.ts new file mode 100644 index 0000000..6b74d4f --- /dev/null +++ b/frontend/app/components/ui/chat/chat.interface.ts @@ -0,0 +1,25 @@ +import { Message } from "ai"; + +export interface ChatHandler { + messages: Message[]; + input: string; + isLoading: boolean; + handleSubmit: ( + e: React.FormEvent, + ops?: { + data?: any; + }, + ) => void; + handleInputChange: (e: React.ChangeEvent) => void; + reload?: () => void; + stop?: () => void; + onFileUpload?: (file: File) => Promise; + onFileError?: (errMsg: string) => void; + setInput?: (input: string) => void; + append?: ( + message: Message | Omit, + ops?: { + data: any; + }, + ) => Promise; +} diff --git a/frontend/app/components/ui/chat/hooks/use-config.ts b/frontend/app/components/ui/chat/hooks/use-config.ts new file mode 100644 index 0000000..05de32a --- /dev/null +++ b/frontend/app/components/ui/chat/hooks/use-config.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +export interface ChatConfig { + backend?: string; + starterQuestions?: string[]; +} + +export function useClientConfig(): ChatConfig { + const chatAPI = process.env.NEXT_PUBLIC_CHAT_API; + const [config, setConfig] = useState(); + + const backendOrigin = useMemo(() => { + return chatAPI ? new URL(chatAPI).origin : ""; + }, [chatAPI]); + + const configAPI = `${backendOrigin}/api/chat/config`; + + useEffect(() => { + fetch(configAPI) + .then((response) => response.json()) + .then((data) => setConfig({ ...data, chatAPI })) + .catch((error) => console.error("Error fetching config", error)); + }, [chatAPI, configAPI]); + + return { + backend: backendOrigin, + starterQuestions: config?.starterQuestions, + }; +} diff --git a/frontend/app/components/ui/chat/hooks/use-copy-to-clipboard.tsx b/frontend/app/components/ui/chat/hooks/use-copy-to-clipboard.tsx new file mode 100644 index 0000000..e011d69 --- /dev/null +++ b/frontend/app/components/ui/chat/hooks/use-copy-to-clipboard.tsx @@ -0,0 +1,33 @@ +"use client"; + +import * as React from "react"; + +export interface useCopyToClipboardProps { + timeout?: number; +} + +export function useCopyToClipboard({ + timeout = 2000, +}: useCopyToClipboardProps) { + const [isCopied, setIsCopied] = React.useState(false); + + const copyToClipboard = (value: string) => { + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + return; + } + + if (!value) { + return; + } + + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, timeout); + }); + }; + + return { isCopied, copyToClipboard }; +} diff --git a/frontend/app/components/ui/chat/hooks/use-file.ts b/frontend/app/components/ui/chat/hooks/use-file.ts new file mode 100644 index 0000000..2c2c34b --- /dev/null +++ b/frontend/app/components/ui/chat/hooks/use-file.ts @@ -0,0 +1,153 @@ +"use client"; + +import { JSONValue } from "llamaindex"; +import { useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { + DocumentFile, + DocumentFileType, + MessageAnnotation, + MessageAnnotationType, +} from ".."; +import { useClientConfig } from "./use-config"; + +const docMineTypeMap: Record = { + "text/csv": "csv", + "application/pdf": "pdf", + "text/plain": "txt", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + "docx", +}; + +export function useFile() { + const { backend } = useClientConfig(); + const [imageUrl, setImageUrl] = useState(null); + const [files, setFiles] = useState([]); + + const docEqual = (a: DocumentFile, b: DocumentFile) => { + if (a.id === b.id) return true; + if (a.filename === b.filename && a.filesize === b.filesize) return true; + return false; + }; + + const addDoc = (file: DocumentFile) => { + const existedFile = files.find((f) => docEqual(f, file)); + if (!existedFile) { + setFiles((prev) => [...prev, file]); + return true; + } + return false; + }; + + const removeDoc = (file: DocumentFile) => { + setFiles((prev) => prev.filter((f) => f.id !== file.id)); + }; + + const reset = () => { + imageUrl && setImageUrl(null); + files.length && setFiles([]); + }; + + const uploadContent = async ( + base64: string, + requestParams: any = {}, + ): Promise => { + const uploadAPI = `${backend}/api/chat/upload`; + const response = await fetch(uploadAPI, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + base64, + ...requestParams, + }), + }); + if (!response.ok) throw new Error("Failed to upload document."); + return await response.json(); + }; + + const getAnnotations = () => { + const annotations: MessageAnnotation[] = []; + if (imageUrl) { + annotations.push({ + type: MessageAnnotationType.IMAGE, + data: { url: imageUrl }, + }); + } + if (files.length > 0) { + annotations.push({ + type: MessageAnnotationType.DOCUMENT_FILE, + data: { files }, + }); + } + return annotations as JSONValue[]; + }; + + const readContent = async (input: { + file: File; + asUrl?: boolean; + }): Promise => { + const { file, asUrl } = input; + const content = await new Promise((resolve, reject) => { + const reader = new FileReader(); + if (asUrl) { + reader.readAsDataURL(file); + } else { + reader.readAsText(file); + } + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + }); + return content; + }; + + const uploadFile = async (file: File, requestParams: any = {}) => { + if (file.type.startsWith("image/")) { + const base64 = await readContent({ file, asUrl: true }); + return setImageUrl(base64); + } + + const filetype = docMineTypeMap[file.type]; + if (!filetype) throw new Error("Unsupported document type."); + const newDoc: Omit = { + id: uuidv4(), + filetype, + filename: file.name, + filesize: file.size, + }; + switch (file.type) { + case "text/csv": { + const content = await readContent({ file }); + return addDoc({ + ...newDoc, + content: { + type: "text", + value: content, + }, + }); + } + default: { + const base64 = await readContent({ file, asUrl: true }); + const ids = await uploadContent(base64, requestParams); + return addDoc({ + ...newDoc, + content: { + type: "ref", + value: ids, + }, + }); + } + } + }; + + return { + imageUrl, + setImageUrl, + files, + removeDoc, + reset, + getAnnotations, + uploadFile, + }; +} diff --git a/frontend/app/components/ui/chat/index.ts b/frontend/app/components/ui/chat/index.ts new file mode 100644 index 0000000..dcfc9cd --- /dev/null +++ b/frontend/app/components/ui/chat/index.ts @@ -0,0 +1,91 @@ +import { JSONValue } from "ai"; +import ChatInput from "./chat-input"; +import ChatMessages from "./chat-messages"; + +export { type ChatHandler } from "./chat.interface"; +export { ChatInput, ChatMessages }; + +export enum MessageAnnotationType { + IMAGE = "image", + DOCUMENT_FILE = "document_file", + SOURCES = "sources", + EVENTS = "events", + TOOLS = "tools", + SUGGESTED_QUESTIONS = "suggested_questions", +} + +export type ImageData = { + url: string; +}; + +export type DocumentFileType = "csv" | "pdf" | "txt" | "docx"; + +export type DocumentFileContent = { + type: "ref" | "text"; + value: string[] | string; +}; + +export type DocumentFile = { + id: string; + filename: string; + filesize: number; + filetype: DocumentFileType; + content: DocumentFileContent; +}; + +export type DocumentFileData = { + files: DocumentFile[]; +}; + +export type SourceNode = { + id: string; + metadata: Record; + score?: number; + text: string; + url?: string; +}; + +export type SourceData = { + nodes: SourceNode[]; +}; + +export type EventData = { + title: string; + isCollapsed: boolean; +}; + +export type ToolData = { + toolCall: { + id: string; + name: string; + input: { + [key: string]: JSONValue; + }; + }; + toolOutput: { + output: JSONValue; + isError: boolean; + }; +}; + +export type SuggestedQuestionsData = string[]; + +export type AnnotationData = + | ImageData + | DocumentFileData + | SourceData + | EventData + | ToolData + | SuggestedQuestionsData; + +export type MessageAnnotation = { + type: MessageAnnotationType; + data: AnnotationData; +}; + +export function getAnnotationData( + annotations: MessageAnnotation[], + type: MessageAnnotationType, +): T[] { + return annotations.filter((a) => a.type === type).map((a) => a.data as T); +} diff --git a/frontend/app/components/ui/chat/widgets/LlamaCloudSelector.tsx b/frontend/app/components/ui/chat/widgets/LlamaCloudSelector.tsx new file mode 100644 index 0000000..aa995c9 --- /dev/null +++ b/frontend/app/components/ui/chat/widgets/LlamaCloudSelector.tsx @@ -0,0 +1,151 @@ +import { Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "../../select"; +import { useClientConfig } from "../hooks/use-config"; + +type LLamaCloudPipeline = { + id: string; + name: string; +}; + +type LLamaCloudProject = { + id: string; + organization_id: string; + name: string; + is_default: boolean; + pipelines: Array; +}; + +type PipelineConfig = { + project: string; // project name + pipeline: string; // pipeline name +}; + +type LlamaCloudConfig = { + projects?: LLamaCloudProject[]; + pipeline?: PipelineConfig; +}; + +export interface LlamaCloudSelectorProps { + setRequestData: React.Dispatch; +} + +export function LlamaCloudSelector({ + setRequestData, +}: LlamaCloudSelectorProps) { + const { backend } = useClientConfig(); + const [config, setConfig] = useState(); + + useEffect(() => { + if (process.env.NEXT_PUBLIC_USE_LLAMACLOUD === "true" && !config) { + fetch(`${backend}/api/chat/config/llamacloud`) + .then((response) => response.json()) + .then((data) => { + setConfig(data); + setRequestData({ + llamaCloudPipeline: data.pipeline, + }); + }) + .catch((error) => console.error("Error fetching config", error)); + } + }, [backend, config, setRequestData]); + + const setPipeline = (pipelineConfig?: PipelineConfig) => { + setConfig((prevConfig: any) => ({ + ...prevConfig, + pipeline: pipelineConfig, + })); + setRequestData((prevData: any) => { + if (!prevData) return { llamaCloudPipeline: pipelineConfig }; + return { + ...prevData, + llamaCloudPipeline: pipelineConfig, + }; + }); + }; + + const handlePipelineSelect = async (value: string) => { + setPipeline(JSON.parse(value) as PipelineConfig); + }; + + if (!config) { + return ( +
+ +
+ ); + } + if (!isValid(config)) { + return ( +

+ Invalid LlamaCloud configuration. Check console logs. +

+ ); + } + const { projects, pipeline } = config; + + return ( + + ); +} + +function isValid(config: LlamaCloudConfig): boolean { + const { projects, pipeline } = config; + if (!projects?.length) return false; + if (!pipeline) return false; + const matchedProject = projects.find( + (project: LLamaCloudProject) => project.name === pipeline.project, + ); + if (!matchedProject) { + console.error( + `LlamaCloud project ${pipeline.project} not found. Check LLAMA_CLOUD_PROJECT_NAME variable`, + ); + return false; + } + const pipelineExists = matchedProject.pipelines.some( + (p) => p.name === pipeline.pipeline, + ); + if (!pipelineExists) { + console.error( + `LlamaCloud pipeline ${pipeline.pipeline} not found. Check LLAMA_CLOUD_INDEX_NAME variable`, + ); + return false; + } + return true; +} diff --git a/frontend/app/components/ui/chat/widgets/PdfDialog.tsx b/frontend/app/components/ui/chat/widgets/PdfDialog.tsx new file mode 100644 index 0000000..8dafffc --- /dev/null +++ b/frontend/app/components/ui/chat/widgets/PdfDialog.tsx @@ -0,0 +1,67 @@ +import dynamic from "next/dynamic"; +import { Button } from "../../button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "../../drawer"; + +export interface PdfDialogProps { + documentId: string; + url: string; + trigger: React.ReactNode; +} + +// Dynamic imports for client-side rendering only +const PDFViewer = dynamic( + () => import("@llamaindex/pdf-viewer").then((module) => module.PDFViewer), + { ssr: false }, +); + +const PdfFocusProvider = dynamic( + () => + import("@llamaindex/pdf-viewer").then((module) => module.PdfFocusProvider), + { ssr: false }, +); + +export default function PdfDialog(props: PdfDialogProps) { + return ( + + {props.trigger} + + +
+ PDF Content + + File URL:{" "} + + {props.url} + + +
+ + + +
+
+ + + +
+
+
+ ); +} diff --git a/frontend/app/components/ui/chat/widgets/WeatherCard.tsx b/frontend/app/components/ui/chat/widgets/WeatherCard.tsx new file mode 100644 index 0000000..f2115ae --- /dev/null +++ b/frontend/app/components/ui/chat/widgets/WeatherCard.tsx @@ -0,0 +1,213 @@ +export interface WeatherData { + latitude: number; + longitude: number; + generationtime_ms: number; + utc_offset_seconds: number; + timezone: string; + timezone_abbreviation: string; + elevation: number; + current_units: { + time: string; + interval: string; + temperature_2m: string; + weather_code: string; + }; + current: { + time: string; + interval: number; + temperature_2m: number; + weather_code: number; + }; + hourly_units: { + time: string; + temperature_2m: string; + weather_code: string; + }; + hourly: { + time: string[]; + temperature_2m: number[]; + weather_code: number[]; + }; + daily_units: { + time: string; + weather_code: string; + }; + daily: { + time: string[]; + weather_code: number[]; + }; +} + +// Follow WMO Weather interpretation codes (WW) +const weatherCodeDisplayMap: Record< + string, + { + icon: JSX.Element; + status: string; + } +> = { + "0": { + icon: ☀️, + status: "Clear sky", + }, + "1": { + icon: 🌤️, + status: "Mainly clear", + }, + "2": { + icon: ☁️, + status: "Partly cloudy", + }, + "3": { + icon: ☁️, + status: "Overcast", + }, + "45": { + icon: 🌫️, + status: "Fog", + }, + "48": { + icon: 🌫️, + status: "Depositing rime fog", + }, + "51": { + icon: 🌧️, + status: "Drizzle", + }, + "53": { + icon: 🌧️, + status: "Drizzle", + }, + "55": { + icon: 🌧️, + status: "Drizzle", + }, + "56": { + icon: 🌧️, + status: "Freezing Drizzle", + }, + "57": { + icon: 🌧️, + status: "Freezing Drizzle", + }, + "61": { + icon: 🌧️, + status: "Rain", + }, + "63": { + icon: 🌧️, + status: "Rain", + }, + "65": { + icon: 🌧️, + status: "Rain", + }, + "66": { + icon: 🌧️, + status: "Freezing Rain", + }, + "67": { + icon: 🌧️, + status: "Freezing Rain", + }, + "71": { + icon: ❄️, + status: "Snow fall", + }, + "73": { + icon: ❄️, + status: "Snow fall", + }, + "75": { + icon: ❄️, + status: "Snow fall", + }, + "77": { + icon: ❄️, + status: "Snow grains", + }, + "80": { + icon: 🌧️, + status: "Rain showers", + }, + "81": { + icon: 🌧️, + status: "Rain showers", + }, + "82": { + icon: 🌧️, + status: "Rain showers", + }, + "85": { + icon: ❄️, + status: "Snow showers", + }, + "86": { + icon: ❄️, + status: "Snow showers", + }, + "95": { + icon: ⛈️, + status: "Thunderstorm", + }, + "96": { + icon: ⛈️, + status: "Thunderstorm", + }, + "99": { + icon: ⛈️, + status: "Thunderstorm", + }, +}; + +const displayDay = (time: string) => { + return new Date(time).toLocaleDateString("en-US", { + weekday: "long", + }); +}; + +export function WeatherCard({ data }: { data: WeatherData }) { + const currentDayString = new Date(data.current.time).toLocaleDateString( + "en-US", + { + weekday: "long", + month: "long", + day: "numeric", + }, + ); + + return ( +
+
+
+
{currentDayString}
+
+ + {data.current.temperature_2m} {data.current_units.temperature_2m} + + {weatherCodeDisplayMap[data.current.weather_code].icon} +
+
+ + {weatherCodeDisplayMap[data.current.weather_code].status} + +
+
+ {data.daily.time.map((time, index) => { + if (index === 0) return null; // skip the current day + return ( +
+ {displayDay(time)} +
+ {weatherCodeDisplayMap[data.daily.weather_code[index]].icon} +
+ + {weatherCodeDisplayMap[data.daily.weather_code[index]].status} + +
+ ); + })} +
+
+ ); +} diff --git a/frontend/app/components/ui/collapsible.tsx b/frontend/app/components/ui/collapsible.tsx new file mode 100644 index 0000000..1fe76f5 --- /dev/null +++ b/frontend/app/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client"; + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; + +export { Collapsible, CollapsibleContent, CollapsibleTrigger }; diff --git a/frontend/app/components/ui/document-preview.tsx b/frontend/app/components/ui/document-preview.tsx new file mode 100644 index 0000000..eb9d6d9 --- /dev/null +++ b/frontend/app/components/ui/document-preview.tsx @@ -0,0 +1,119 @@ +import { XCircleIcon } from "lucide-react"; +import Image from "next/image"; +import DocxIcon from "../ui/icons/docx.svg"; +import PdfIcon from "../ui/icons/pdf.svg"; +import SheetIcon from "../ui/icons/sheet.svg"; +import TxtIcon from "../ui/icons/txt.svg"; +import { Button } from "./button"; +import { DocumentFile, DocumentFileType } from "./chat"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "./drawer"; +import { cn } from "./lib/utils"; + +export interface DocumentPreviewProps { + file: DocumentFile; + onRemove?: () => void; +} + +export function DocumentPreview(props: DocumentPreviewProps) { + const { filename, filesize, content, filetype } = props.file; + + if (content.type === "ref") { + return ( +
+ +
+ ); + } + + return ( + + +
+ +
+
+ + +
+ {filetype.toUpperCase()} Raw Content + + {filename} ({inKB(filesize)} KB) + +
+ + + +
+
+ {content.type === "text" && ( +
+              {content.value as string}
+            
+ )} +
+
+
+ ); +} + +const FileIcon: Record = { + csv: SheetIcon, + pdf: PdfIcon, + docx: DocxIcon, + txt: TxtIcon, +}; + +function PreviewCard(props: DocumentPreviewProps) { + const { onRemove, file } = props; + return ( +
+
+
+ Icon +
+
+
+ {file.filename} ({inKB(file.filesize)} KB) +
+
+ {file.filetype.toUpperCase()} File +
+
+
+ {onRemove && ( +
+ +
+ )} +
+ ); +} + +function inKB(size: number) { + return Math.round((size / 1024) * 10) / 10; +} diff --git a/frontend/app/components/ui/drawer.tsx b/frontend/app/components/ui/drawer.tsx new file mode 100644 index 0000000..bf733c8 --- /dev/null +++ b/frontend/app/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "./lib/utils"; + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +); +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = "DrawerContent"; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = "DrawerHeader"; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = "DrawerFooter"; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + DrawerPortal, + DrawerTitle, + DrawerTrigger, +}; diff --git a/frontend/app/components/ui/file-uploader.tsx b/frontend/app/components/ui/file-uploader.tsx new file mode 100644 index 0000000..e42a267 --- /dev/null +++ b/frontend/app/components/ui/file-uploader.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { Loader2, Paperclip } from "lucide-react"; +import { ChangeEvent, useState } from "react"; +import { buttonVariants } from "./button"; +import { cn } from "./lib/utils"; + +export interface FileUploaderProps { + config?: { + inputId?: string; + fileSizeLimit?: number; + allowedExtensions?: string[]; + checkExtension?: (extension: string) => string | null; + disabled: boolean; + }; + onFileUpload: (file: File) => Promise; + onFileError?: (errMsg: string) => void; +} + +const DEFAULT_INPUT_ID = "fileInput"; +const DEFAULT_FILE_SIZE_LIMIT = 1024 * 1024 * 50; // 50 MB + +export default function FileUploader({ + config, + onFileUpload, + onFileError, +}: FileUploaderProps) { + const [uploading, setUploading] = useState(false); + + const inputId = config?.inputId || DEFAULT_INPUT_ID; + const fileSizeLimit = config?.fileSizeLimit || DEFAULT_FILE_SIZE_LIMIT; + const allowedExtensions = config?.allowedExtensions; + const defaultCheckExtension = (extension: string) => { + if (allowedExtensions && !allowedExtensions.includes(extension)) { + return `Invalid file type. Please select a file with one of these formats: ${allowedExtensions!.join( + ",", + )}`; + } + return null; + }; + const checkExtension = config?.checkExtension ?? defaultCheckExtension; + + const isFileSizeExceeded = (file: File) => { + return file.size > fileSizeLimit; + }; + + const resetInput = () => { + const fileInput = document.getElementById(inputId) as HTMLInputElement; + fileInput.value = ""; + }; + + const onFileChange = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + await handleUpload(file); + resetInput(); + setUploading(false); + }; + + const handleUpload = async (file: File) => { + const onFileUploadError = onFileError || window.alert; + const fileExtension = file.name.split(".").pop() || ""; + const extensionFileError = checkExtension(fileExtension); + if (extensionFileError) { + return onFileUploadError(extensionFileError); + } + + if (isFileSizeExceeded(file)) { + return onFileUploadError( + `File size exceeded. Limit is ${fileSizeLimit / 1024 / 1024} MB`, + ); + } + + await onFileUpload(file); + }; + + return ( +
+ + +
+ ); +} diff --git a/frontend/app/components/ui/hover-card.tsx b/frontend/app/components/ui/hover-card.tsx new file mode 100644 index 0000000..e886235 --- /dev/null +++ b/frontend/app/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; +import * as React from "react"; + +import { cn } from "./lib/utils"; + +const HoverCard = HoverCardPrimitive.Root; + +const HoverCardTrigger = HoverCardPrimitive.Trigger; + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)); +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; + +export { HoverCard, HoverCardContent, HoverCardTrigger }; diff --git a/frontend/app/components/ui/icons/docx.svg b/frontend/app/components/ui/icons/docx.svg new file mode 100644 index 0000000..4278239 --- /dev/null +++ b/frontend/app/components/ui/icons/docx.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/components/ui/icons/pdf.svg b/frontend/app/components/ui/icons/pdf.svg new file mode 100644 index 0000000..f32146c --- /dev/null +++ b/frontend/app/components/ui/icons/pdf.svg @@ -0,0 +1,19 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/components/ui/icons/sheet.svg b/frontend/app/components/ui/icons/sheet.svg new file mode 100644 index 0000000..65f1b0f --- /dev/null +++ b/frontend/app/components/ui/icons/sheet.svg @@ -0,0 +1,90 @@ + + + Sheets-icon + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/components/ui/icons/txt.svg b/frontend/app/components/ui/icons/txt.svg new file mode 100644 index 0000000..0afb11b --- /dev/null +++ b/frontend/app/components/ui/icons/txt.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/components/ui/input.tsx b/frontend/app/components/ui/input.tsx new file mode 100644 index 0000000..edfa129 --- /dev/null +++ b/frontend/app/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { cn } from "./lib/utils"; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/frontend/app/components/ui/lib/utils.ts b/frontend/app/components/ui/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/frontend/app/components/ui/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/frontend/app/components/ui/select.tsx b/frontend/app/components/ui/select.tsx new file mode 100644 index 0000000..c01b068 --- /dev/null +++ b/frontend/app/components/ui/select.tsx @@ -0,0 +1,159 @@ +"use client"; + +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import * as React from "react"; +import { cn } from "./lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/frontend/app/components/ui/upload-image-preview.tsx b/frontend/app/components/ui/upload-image-preview.tsx new file mode 100644 index 0000000..55ef6e9 --- /dev/null +++ b/frontend/app/components/ui/upload-image-preview.tsx @@ -0,0 +1,32 @@ +import { XCircleIcon } from "lucide-react"; +import Image from "next/image"; +import { cn } from "./lib/utils"; + +export default function UploadImagePreview({ + url, + onRemove, +}: { + url: string; + onRemove: () => void; +}) { + return ( +
+ Uploaded image + +
+ ); +} diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a1eaef62f2dfa895f1bbffc6595bb53d9604963e GIT binary patch literal 15406 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 KfA0a-1OEqKLE94m literal 0 HcmV?d00001 diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..0c2b9bd --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,97 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 47.4% 11.2%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + + --card: 0 0% 100%; + --card-foreground: 222.2 47.4% 11.2%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 100% 50%; + --destructive-foreground: 210 40% 98%; + + --ring: 215 20.2% 65.1%; + + --radius: 0.5rem; + } + + .dark { + --background: 224 71% 4%; + --foreground: 213 31% 91%; + + --muted: 223 47% 11%; + --muted-foreground: 215.4 16.3% 56.9%; + + --accent: 216 34% 17%; + --accent-foreground: 210 40% 98%; + + --popover: 224 71% 4%; + --popover-foreground: 215 20.2% 65.1%; + + --border: 216 34% 17%; + --input: 216 34% 17%; + + --card: 224 71% 4%; + --card-foreground: 213 31% 91%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 1.2%; + + --secondary: 222.2 47.4% 11.2%; + --secondary-foreground: 210 40% 98%; + + --destructive: 0 63% 31%; + --destructive-foreground: 210 40% 98%; + + --ring: 216 34% 17%; + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + html { + @apply h-full; + } + body { + @apply bg-background text-foreground h-full; + font-feature-settings: + "rlig" 1, + "calt" 1; + } + .background-gradient { + background-color: #fff; + background-image: radial-gradient( + at 21% 11%, + rgba(186, 186, 233, 0.53) 0, + transparent 50% + ), + radial-gradient(at 85% 0, hsla(46, 57%, 78%, 0.52) 0, transparent 50%), + radial-gradient(at 91% 36%, rgba(194, 213, 255, 0.68) 0, transparent 50%), + radial-gradient(at 8% 40%, rgba(251, 218, 239, 0.46) 0, transparent 50%); + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..8f7cab9 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import "./markdown.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Create Llama App", + description: "Generated by create-llama", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/frontend/app/markdown.css b/frontend/app/markdown.css new file mode 100644 index 0000000..a843eeb --- /dev/null +++ b/frontend/app/markdown.css @@ -0,0 +1,23 @@ +/* Custom CSS for chat message markdown */ +.custom-markdown ul { + list-style-type: disc; + margin-left: 20px; +} + +.custom-markdown ol { + list-style-type: decimal; + margin-left: 20px; +} + +.custom-markdown li { + margin-bottom: 5px; +} + +.custom-markdown ol ol { + list-style: lower-alpha; +} + +.custom-markdown ul ul, +.custom-markdown ol ol { + margin-left: 20px; +} diff --git a/frontend/app/observability/index.ts b/frontend/app/observability/index.ts new file mode 100644 index 0000000..2e4ce2b --- /dev/null +++ b/frontend/app/observability/index.ts @@ -0,0 +1 @@ +export const initObservability = () => {}; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..04d4302 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,15 @@ +import Header from "@/app/components/header"; +import ChatSection from "./components/chat-section"; + +export default function Home() { + return ( +
+
+
+
+ +
+
+
+ ); +} diff --git a/frontend/config/tools.json b/frontend/config/tools.json new file mode 100644 index 0000000..298e248 --- /dev/null +++ b/frontend/config/tools.json @@ -0,0 +1,7 @@ +{ + "local": { + "weather": {}, + "interpreter": {} + }, + "llamahub": {} +} \ No newline at end of file diff --git a/frontend/next.config.json b/frontend/next.config.json new file mode 100644 index 0000000..018bd38 --- /dev/null +++ b/frontend/next.config.json @@ -0,0 +1,16 @@ +{ + "experimental": { + "outputFileTracingIncludes": { + "/*": [ + "./cache/**/*" + ], + "/api/**/*": [ + "./node_modules/**/*.wasm" + ] + } + }, + "output": "export", + "images": { + "unoptimized": true + } +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs new file mode 100644 index 0000000..64bdff2 --- /dev/null +++ b/frontend/next.config.mjs @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +import fs from "fs"; +import withLlamaIndex from "llamaindex/next"; +import webpack from "./webpack.config.mjs"; + +const nextConfig = JSON.parse(fs.readFileSync("./next.config.json", "utf-8")); +nextConfig.webpack = webpack; + +// use withLlamaIndex to add necessary modifications for llamaindex library +export default withLlamaIndex(nextConfig); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e3a26e4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,65 @@ +{ + "name": "testapp", + "version": "0.1.0", + "scripts": { + "format": "prettier --ignore-unknown --cache --check .", + "format:write": "prettier --ignore-unknown --write .", + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "generate": "tsx app\\api\\chat\\engine\\generate.ts" + }, + "dependencies": { + "@apidevtools/swagger-parser": "^10.1.0", + "@e2b/code-interpreter": "^0.0.5", + "@llamaindex/pdf-viewer": "^1.1.3", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-slot": "^1.0.2", + "ai": "^3.0.21", + "ajv": "^8.12.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "dotenv": "^16.3.1", + "duck-duck-scrape": "^2.2.5", + "formdata-node": "^6.0.3", + "got": "^14.4.1", + "llamaindex": "0.5.12", + "lucide-react": "^0.294.0", + "next": "^14.2.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^8.0.7", + "react-syntax-highlighter": "^15.5.0", + "rehype-katex": "^7.0.0", + "remark": "^14.0.3", + "remark-code-import": "^1.2.0", + "remark-gfm": "^3.0.1", + "remark-math": "^5.1.1", + "supports-color": "^8.1.1", + "tailwind-merge": "^2.1.0", + "tiktoken": "^1.0.15", + "uuid": "^9.0.1", + "vaul": "^0.9.1" + }, + "devDependencies": { + "@types/node": "^20.10.3", + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", + "@types/react-syntax-highlighter": "^15.5.11", + "@types/uuid": "^9.0.8", + "autoprefixer": "^10.4.16", + "cross-env": "^7.0.3", + "eslint": "^8.55.0", + "eslint-config-next": "^14.2.4", + "eslint-config-prettier": "^8.10.0", + "postcss": "^8.4.32", + "prettier": "^3.2.5", + "prettier-plugin-organize-imports": "^3.2.4", + "tailwindcss": "^3.3.6", + "tsx": "^4.7.2", + "typescript": "^5.3.2" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js new file mode 100644 index 0000000..1fe03c6 --- /dev/null +++ b/frontend/prettier.config.js @@ -0,0 +1,3 @@ +module.exports = { + plugins: ["prettier-plugin-organize-imports"], +}; diff --git a/frontend/public/llama.png b/frontend/public/llama.png new file mode 100644 index 0000000000000000000000000000000000000000..d4efba3b816bf765439c6d01b322b02684e946c3 GIT binary patch literal 36985 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 vJhQ^1K}l_EDGGCB?_Esg99ufZln>@+s literal 0 HcmV?d00001 diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..aa5580a --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,78 @@ +import type { Config } from "tailwindcss"; +import { fontFamily } from "tailwindcss/defaultTheme"; + +const config: Config = { + darkMode: ["class"], + content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive) / )", + foreground: "hsl(var(--destructive-foreground) / )", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + xl: `calc(var(--radius) + 4px)`, + lg: `var(--radius)`, + md: `calc(var(--radius) - 2px)`, + sm: "calc(var(--radius) - 4px)", + }, + fontFamily: { + sans: ["var(--font-sans)", ...fontFamily.sans], + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [], +}; +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..e7ff90f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/frontend/webpack.config.mjs b/frontend/webpack.config.mjs new file mode 100644 index 0000000..29decaf --- /dev/null +++ b/frontend/webpack.config.mjs @@ -0,0 +1,8 @@ +// webpack config must be a function in NextJS that is used to patch the default webpack config provided by NextJS, see https://nextjs.org/docs/pages/api-reference/next-config-js/webpack +export default function webpack(config) { + config.resolve.fallback = { + aws4: false, + }; + + return config; +}