├── .coveragerc
├── .editorconfig
├── .env.example
├── .env.production.example
├── .flake8
├── .github
└── workflows
│ ├── pr-ui.yml
│ └── pr.yml
├── .gitignore
├── .python-version
├── BUILD
├── LICENSE
├── Makefile
├── README.md
├── alembic.ini
├── assets
├── PersonaFlowIcon-512.png
└── screenshot.png
├── constraints.txt
├── contrib
├── postgresql
│ ├── BUILD
│ ├── Dockerfile
│ └── setup-internal.sql
└── qdrant
│ ├── config.yaml
│ ├── development.yaml
│ └── production.yaml
├── docker-compose.dev.yaml
├── docker-compose.yaml
├── docs
├── assistants.md
├── auth_guide.md
├── rag.md
└── troubleshooting.md
├── migrations
├── BUILD
├── README
├── env.py
├── script.py.mako
└── versions
│ ├── 1091347b4ab9_alter_checkpoints_table.py
│ ├── 42cb7889f092_init.py
│ ├── BUILD
│ ├── af0a5f3e2816_auth.py
│ ├── dc27d3e10363_file_tracking.py
│ ├── e1338e81c83d_add_relationships.py
│ ├── fa2c17415189_add_checkpoints_table.py
│ └── fd264e716c5d_remove_checkpoints_table.py
├── mypy.ini
├── pants.toml
├── poetry.lock
├── pyproject.toml
├── pytest.ini
├── scripts
├── db_management.sh
└── setup_default_user.py
├── stack
├── .dockerignore
├── .flake8
├── BUILD
├── Dockerfile
├── __init__.py
├── app
│ ├── BUILD
│ ├── __init__.py
│ ├── agents
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ ├── chatbot_executor.py
│ │ ├── configurable_agent.py
│ │ ├── configurable_chatbot.py
│ │ ├── configurable_crag.py
│ │ ├── configurable_retrieval.py
│ │ ├── llm.py
│ │ ├── prompts.py
│ │ ├── retrieval_executor.py
│ │ ├── tools.py
│ │ ├── tools_agent_executor.py
│ │ └── xml_agent.py
│ ├── api
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ └── v1
│ │ │ ├── BUILD
│ │ │ ├── __init__.py
│ │ │ ├── admin_users.py
│ │ │ ├── assistants.py
│ │ │ ├── auth.py
│ │ │ ├── files.py
│ │ │ ├── rag.py
│ │ │ ├── runs.py
│ │ │ ├── threads.py
│ │ │ └── users.py
│ ├── app_factory.py
│ ├── cache
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ └── cache.py
│ ├── chains
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ └── universal_retrieval_chain.py
│ ├── core
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ ├── auth
│ │ │ ├── BUILD
│ │ │ ├── __init__.py
│ │ │ ├── auth_config.py
│ │ │ ├── jwt.py
│ │ │ ├── request_validators.py
│ │ │ ├── strategies
│ │ │ │ ├── BUILD
│ │ │ │ ├── __init__.py
│ │ │ │ ├── base.py
│ │ │ │ ├── basic.py
│ │ │ │ ├── google_oauth.py
│ │ │ │ ├── oidc.py
│ │ │ │ └── settings.py
│ │ │ └── utils.py
│ │ ├── configuration.py
│ │ ├── datastore.py
│ │ ├── exception.py
│ │ ├── logger.py
│ │ ├── redis.py
│ │ ├── retriever.py
│ │ └── struct_logger.py
│ ├── main.py
│ ├── middlewares
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ ├── request_logger.py
│ │ └── system_logger.py
│ ├── model
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ ├── assistant.py
│ │ ├── base.py
│ │ ├── blacklist.py
│ │ ├── file.py
│ │ ├── message.py
│ │ ├── request_log.py
│ │ ├── thread.py
│ │ ├── user.py
│ │ └── util.py
│ ├── rag
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ ├── custom_retriever.py
│ │ ├── embedding_service.py
│ │ ├── encoders
│ │ │ ├── BUILD
│ │ │ ├── __init__.py
│ │ │ └── ollama_encoder.py
│ │ ├── ingest.py
│ │ ├── query.py
│ │ ├── splitter.py
│ │ ├── summarizer.py
│ │ ├── table_parser.py
│ │ ├── tests
│ │ │ ├── __init__.py
│ │ │ └── unit
│ │ │ │ ├── BUILD
│ │ │ │ └── test_util.py
│ │ └── util.py
│ ├── repositories
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ ├── assistant.py
│ │ ├── base.py
│ │ ├── blacklist.py
│ │ ├── file.py
│ │ ├── message.py
│ │ ├── thread.py
│ │ └── user.py
│ ├── schema
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ ├── assistant.py
│ │ ├── auth.py
│ │ ├── feedback.py
│ │ ├── file.py
│ │ ├── message.py
│ │ ├── message_types.py
│ │ ├── rag.py
│ │ ├── thread.py
│ │ ├── title.py
│ │ └── user.py
│ ├── start.py
│ ├── utils
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ ├── exceptions.py
│ │ ├── file_helpers.py
│ │ ├── format_docs.py
│ │ ├── group_threads.py
│ │ ├── helpers.py
│ │ ├── stream.py
│ │ └── tests
│ │ │ ├── __init__.py
│ │ │ └── unit
│ │ │ ├── BUILD
│ │ │ └── test_format_docs.py
│ └── vectordbs
│ │ ├── BUILD
│ │ ├── __init__.py
│ │ ├── base.py
│ │ └── qdrant.py
├── dev.sh
└── tests
│ ├── __init__.py
│ ├── integration
│ ├── PersonaFlow.postman_collection.json
│ └── openapi.json
│ └── unit
│ ├── BUILD
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_app.py
│ ├── test_app_admin_users.py
│ ├── test_app_assistants.py
│ ├── test_app_auth.py
│ ├── test_app_files.py
│ ├── test_app_runs.py
│ ├── test_app_threads.py
│ ├── test_app_users.py
│ ├── test_authorization_header.py
│ ├── test_basic_auth.py
│ └── test_format_docs.py
└── ui
├── .env.local.example
├── .eslintrc.json
├── .gitignore
├── .husky
└── pre-commit
├── .prettierignore
├── .prettierrc
├── assets
└── PersonaFlowIcon-512.png
├── components.json
├── jest.config.ts
├── jest.setup.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── src
├── app
│ ├── [[...slug]]
│ │ └── page.tsx
│ ├── icon.ico
│ ├── layout.tsx
│ └── not-found.tsx
├── components
│ ├── features
│ │ ├── build-panel
│ │ │ └── components
│ │ │ │ ├── __tests__
│ │ │ │ ├── assistant-builder.spec.tsx
│ │ │ │ └── assistant-form.spec.tsx
│ │ │ │ ├── assistant-builder.tsx
│ │ │ │ ├── assistant-form.tsx
│ │ │ │ ├── assistant-selector.tsx
│ │ │ │ ├── build-panel.tsx
│ │ │ │ ├── create-assistant.tsx
│ │ │ │ ├── edit-assistant.tsx
│ │ │ │ ├── file-builder.tsx
│ │ │ │ ├── files-dialog.tsx
│ │ │ │ ├── form-select.tsx
│ │ │ │ ├── public-switch.tsx
│ │ │ │ ├── retrieval-description.tsx
│ │ │ │ ├── select-actions.tsx
│ │ │ │ ├── select-capabilities.tsx
│ │ │ │ ├── select-files.tsx
│ │ │ │ ├── select-options.tsx
│ │ │ │ ├── select-tools.tsx
│ │ │ │ ├── system-prompt.tsx
│ │ │ │ └── tool-dialog.tsx
│ │ ├── chat-panel
│ │ │ ├── chat-panel.tsx
│ │ │ └── components
│ │ │ │ ├── composer.tsx
│ │ │ │ ├── message-item.tsx
│ │ │ │ └── messages-container.tsx
│ │ ├── header
│ │ │ └── components
│ │ │ │ └── header.tsx
│ │ ├── markdown
│ │ │ └── markdown.tsx
│ │ ├── navbar
│ │ │ └── components
│ │ │ │ ├── navbar.tsx
│ │ │ │ ├── new-thread-btn.tsx
│ │ │ │ └── thread-item.tsx
│ │ └── tools
│ │ │ ├── tool-container.tsx
│ │ │ ├── tool-query.tsx
│ │ │ └── tool-result.tsx
│ └── ui
│ │ ├── accordion.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── checkbox.tsx
│ │ ├── collapsible.tsx
│ │ ├── command.tsx
│ │ ├── dialog.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── multiselect.tsx
│ │ ├── select.tsx
│ │ ├── spinner.tsx
│ │ ├── switch.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ ├── toast.tsx
│ │ ├── toaster.tsx
│ │ ├── tooltip.tsx
│ │ └── use-toast.ts
├── data-provider
│ ├── data-service.ts
│ ├── endpoints.ts
│ ├── query-provider.ts
│ ├── query-service.ts
│ ├── requests.ts
│ └── types.ts
├── hooks
│ ├── useAvailableTools.ts
│ ├── useChat.ts
│ ├── useConfig.ts
│ ├── useFileStream.ts
│ ├── useSlugParams.ts
│ └── useStream.ts
├── mockFiles.ts
├── providers
│ └── Providers.tsx
├── store.ts
├── styles
│ └── main.css
└── utils
│ ├── routeUtils.ts
│ └── utils.ts
├── tailwind.config.ts
└── tsconfig.json
/.coveragerc:
--------------------------------------------------------------------------------
1 | [report]
2 | omit = */tests/*
3 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | indent_style = space
8 | indent_size = 2
9 | trim_trailing_whitespace = true
10 |
11 | [*.{py,pyi}]
12 | indent_size = 4
13 |
14 | [Makefile]
15 | indent_style = tab
16 |
17 | [*.md]
18 | trim_trailing_whitespace = false
19 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | extend-ignore:
3 | # whitespace before ':' (conflicts with Black)
4 | E203,
5 | # Bad trailing comma (conflicts with Black)
6 | E231,
7 | # line too long (conflicts with Black)
8 | E501,
9 |
10 | max-complexity = 10
11 | statistics = True
12 |
--------------------------------------------------------------------------------
/.github/workflows/pr-ui.yml:
--------------------------------------------------------------------------------
1 | name: UI PR Check
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | paths:
7 | - "ui/**"
8 | - ".github/workflows/pr-ui.yml"
9 | pull_request:
10 | branches: ["main"]
11 | paths:
12 | - "ui/**"
13 | - ".github/workflows/pr-ui.yml"
14 | jobs:
15 | build:
16 | name: Build and Test UI
17 | runs-on: ubuntu-latest
18 | strategy:
19 | matrix:
20 | node-version: [20.x]
21 |
22 | steps:
23 | - uses: actions/checkout@v4
24 | with:
25 | sparse-checkout: |
26 | .github
27 | ui
28 | - name: Use Node.js ${{ matrix.node-version }}
29 | uses: actions/setup-node@v3
30 | with:
31 | node-version: ${{ matrix.node-version }}
32 | cache: "npm"
33 | cache-dependency-path: ui/package-lock.json
34 |
35 | - name: Cache build
36 | uses: actions/cache@v4
37 | with:
38 | path: |
39 | ~/.npm
40 | ${{ github.workspace }}/.next/cache
41 | # Generate a new cache whenever packages or source files change.
42 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
43 | # If source files changed but packages didn't, rebuild from a prior cache.
44 | restore-keys: |
45 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
46 |
47 | - name: Build and Test UI
48 | working-directory: ui
49 | run: |
50 | pwd
51 | npm ci
52 | npm run format:check
53 | npm run build
54 | npm run test:ci
55 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: PR Check
2 |
3 | #on: workflow_dispatch
4 | on:
5 | pull_request:
6 | push:
7 | branches:
8 | - main # or whatever your default branch is named
9 | paths-ignore:
10 | - 'ui/**'
11 | - '.github/workflows/pr-ui.yml'
12 |
13 | jobs:
14 | pr-check:
15 | runs-on: ubuntu-latest
16 | strategy:
17 | matrix:
18 | python-version: ["3.11"]
19 | steps:
20 | - uses: actions/checkout@v4
21 | - name: Set up Python ${{ matrix.python-version }}
22 | uses: actions/setup-python@v5
23 | with:
24 | python-version: ${{ matrix.python-version }}
25 | - name: Pants init
26 | uses: pantsbuild/actions/init-pants@main
27 | with:
28 | # cache0 makes it easy to bust the cache if needed
29 | gha-cache-key: cache0-py-${{ github.head_ref || github.ref_name }}-${{ matrix.python_version }}
30 | named-caches-hash: ${{ hashFiles('constraints.txt', 'poetry.lock') }}
31 | - name: Pants lint
32 | if: '!cancelled()'
33 | run: |
34 | pants lint ::
35 | # - name: Pants check
36 | # if: '!cancelled()'
37 | # run: |
38 | # pants check ::
39 | - name: Pants test
40 | if: '!cancelled()'
41 | run: |
42 | pants test ::
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.11.9
2 |
--------------------------------------------------------------------------------
/BUILD:
--------------------------------------------------------------------------------
1 | poetry_requirements(name="reqs")
2 |
3 | python_tests(
4 | name="tests",
5 | dependencies=["//:test-reqs"]
6 | )
7 |
8 | target(
9 | name="test-reqs",
10 | dependencies=[
11 | "//:reqs#asyncpg",
12 | "//:reqs#python-multipart",
13 | "//:reqs#pytest-asyncio",
14 | "//:reqs#itsdangerous",
15 | "//:reqs#langgraph-checkpoint-postgres"
16 | ]
17 | )
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 PersonaFlow
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile
2 |
3 | # Define variables
4 | PYTHON := python
5 | ALEMBIC := alembic
6 | SERVER_DIR := stack
7 |
8 | # Target: migrate
9 | migrate:
10 | @echo "Initializing the project..."
11 | $(ALEMBIC) upgrade head
12 | PYTHONPATH=$(SERVER_DIR) $(PYTHON) scripts/setup_default_user.py
13 | @echo "Initialization complete."
14 |
15 | # Target: stack-dev
16 | stack-dev:
17 | @echo "Starting the development server..."
18 | uvicorn stack.app.main:app --reload --port 9000
19 |
20 | # Target create_db_snapshot
21 | create_db_snapshot:
22 | @echo "Creating a clone of the database..."
23 | bash ./scripts/db_management.sh create_db_snapshot internal personaflow
24 |
25 | restore_db_snapshot:
26 | @echo "Restoring the database from the snapshot..."
27 | bash ./scripts/db_management.sh restore_db_snapshot internal backup.sql
28 |
29 | # Target: help
30 | help:
31 | @echo "Available commands:"
32 | @echo " make migrate - Initialize the project (run migrations and insert default user)"
33 | @echo " make help - Show this help message"
34 | @echo " make stack-dev - Start the server with auto-reload"
35 | @echo " make create_db_snapshot - Create a snapshot of the database schema"
36 | @echo " make restore_db_snapshot - Restore the database from the snapshot"
37 |
38 | .PHONY: migrate help stack-dev
39 |
--------------------------------------------------------------------------------
/alembic.ini:
--------------------------------------------------------------------------------
1 | # alembic.ini
2 | # ------------------
3 | #
4 | # Alembic configuration file to manage database schema migrations.
5 | # This file contains settings related to the location of migration scripts,
6 | # database URL, and logging configuration.
7 | #
8 | # -----------------------------------------------------------------------------------
9 |
10 | # Core alembic configurations.
11 | [alembic]
12 | # Directory where migration scripts are located.
13 | script_location = migrations
14 | # A list of Python paths to prepend to sys.path.
15 | prepend_sys_path = .
16 | # Database URL for Alembic to connect to.
17 | # WARNING: The provided URL lacks authentication details, consider updating it.
18 | sqlalchemy.url = postgresql://:@:/
19 |
20 | # Logging configurations.
21 | [loggers]
22 | # Define the names of loggers, handlers, and formatters.
23 | keys = root,sqlalchemy,alembic
24 |
25 | [handlers]
26 | keys = console
27 |
28 | [formatters]
29 | keys = generic
30 |
31 | # Logger configurations.
32 | # 'root' logger configuration.
33 | [logger_root]
34 | level = WARN
35 | handlers = console
36 | qualname =
37 |
38 | # 'sqlalchemy' logger configuration for logging SQL queries.
39 | [logger_sqlalchemy]
40 | level = WARN
41 | handlers =
42 | qualname = sqlalchemy.engine
43 |
44 | # 'alembic' logger configuration specific to Alembic operations.
45 | [logger_alembic]
46 | level = INFO
47 | handlers =
48 | qualname = alembic
49 |
50 | # Console handler: Defines how logs are handled on the console.
51 | [handler_console]
52 | class = StreamHandler
53 | args = (sys.stderr,)
54 | level = NOTSET
55 | formatter = generic
56 |
57 | # Formatter: Specifies the format for displaying logs.
58 | [formatter_generic]
59 | format = %(levelname)-5.5s [%(name)s] %(message)s
60 | datefmt = %H:%M:%S
61 |
--------------------------------------------------------------------------------
/assets/PersonaFlowIcon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/assets/PersonaFlowIcon-512.png
--------------------------------------------------------------------------------
/assets/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/assets/screenshot.png
--------------------------------------------------------------------------------
/contrib/postgresql/BUILD:
--------------------------------------------------------------------------------
1 | docker_image(
2 | name="docker",
3 | )
4 |
--------------------------------------------------------------------------------
/contrib/postgresql/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM postgres:15.5
2 |
3 | COPY setup-internal.sql /docker-entrypoint-initdb.d/001-setup-internal.sql
4 |
--------------------------------------------------------------------------------
/contrib/postgresql/setup-internal.sql:
--------------------------------------------------------------------------------
1 | /*
2 | contrib/setup-internal.sql
3 | --------------------------
4 |
5 | This SQL script is responsible for the initial setup of the `internal` user and the related `internal` database in a PostgreSQL environment.
6 | */
7 |
8 | \c postgres
9 | CREATE USER internal WITH PASSWORD 'internal';
10 | CREATE DATABASE internal;
11 | -- GRANT ALL PRIVILEGES ON DATABASE internal TO internal;
12 | ALTER USER internal WITH SUPERUSER;
13 |
14 | \c internal;
15 | CREATE SCHEMA personaflow;
16 | ALTER SCHEMA personaflow OWNER TO internal;
17 |
18 | CREATE EXTENSION IF NOT EXISTS pgcrypto;
19 |
--------------------------------------------------------------------------------
/contrib/qdrant/development.yaml:
--------------------------------------------------------------------------------
1 | log_level: DEBUG
2 |
3 | service:
4 | host: 127.0.0.1
5 | http_port: 6333
6 | # Uncomment to enable gRPC:
7 | #grpc_port: 6334
8 | #api_key: your_secret_api_key_here
9 |
10 |
11 | storage:
12 | performance:
13 | # Number of parallel threads used for search operations. If 0 - auto selection.
14 | max_search_threads: 4
15 |
16 | optimizers:
17 | # Minimum interval between forced flushes.
18 | flush_interval_sec: 5
19 |
20 | # Do not create too much segments in dev
21 | default_segment_number: 2
22 |
23 | handle_collection_load_errors: true
24 |
--------------------------------------------------------------------------------
/contrib/qdrant/production.yaml:
--------------------------------------------------------------------------------
1 | log_level: INFO
2 |
3 | service:
4 | host: 0.0.0.0
5 | http_port: 6333
6 | # Uncomment to enable gRPC:
7 | #grpc_port: 6334
8 |
--------------------------------------------------------------------------------
/docs/troubleshooting.md:
--------------------------------------------------------------------------------
1 | # Troubleshooting (WIP)
2 |
3 | **Problem**
4 | When I try to run `docker-compose up` I get the error: "configs.qdrant_config Additional property content is not allowed"
5 |
6 | **Solution**
7 | Proving the inline content in the configs top-level element requires Docker Compose v2.23.1 or above. This functionality is supported starting Docker Engine v25.0.0 and Docker Desktop v4.26.0 onwards.
8 |
9 |
10 |
11 | **Problem**
12 | `black` fails the CI pipeline on the `pants lint` step of my PR.
13 |
14 | **Solution**
15 | Run `pants fmt ::` and commit the formatting changes and it should pass.
16 |
17 |
18 |
--------------------------------------------------------------------------------
/migrations/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """
2 | migrations/script.py.mako
3 | ----------------------------
4 |
5 |
6 | Alembic Migration Script Template
7 |
8 | This script serves as a template for Alembic migrations. When Alembic auto-generates migration scripts, it uses
9 | this template to structure the content. The generated script then gets saved in the 'versions' directory under 'migrations'.
10 | Each script defines actions to be taken for both 'upgrade' (applying the migration) and 'downgrade' (reverting the migration).
11 |
12 | """
13 |
14 | """${message}
15 |
16 | Revision ID: ${up_revision}
17 | Revises: ${down_revision | comma,n}
18 | Create Date: ${create_date}
19 |
20 | """
21 | from alembic import op
22 | import sqlalchemy as sa
23 | ${imports if imports else ""} # Import additional modules dynamically based on the migration's needs.
24 |
25 | # Revision identifiers, used by Alembic to identify the migration script.
26 | revision = ${repr(up_revision)} # The ID of this revision (migration).
27 | down_revision = ${repr(down_revision)} # The ID of the previous revision. Specifies ordering of migrations.
28 | branch_labels = ${repr(branch_labels)} # Labels that can group revisions together.
29 | depends_on = ${repr(depends_on)} # If this revision depends on another one, it is referenced here.
30 |
31 | # Function to handle the 'upgrade' operation.
32 | # This contains the operations to be performed when moving to this version from the previous version.
33 | def upgrade() -> None:
34 | ${upgrades if upgrades else "pass"} # Operations to upgrade the database schema.
35 |
36 | # Function to handle the 'downgrade' operation.
37 | # This contains the operations to revert the changes introduced in the 'upgrade' function.
38 | def downgrade() -> None:
39 | ${downgrades if downgrades else "pass"} # Operations to downgrade the database schema.
40 |
--------------------------------------------------------------------------------
/migrations/versions/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/migrations/versions/fa2c17415189_add_checkpoints_table.py:
--------------------------------------------------------------------------------
1 | """
2 | migrations/script.py.mako
3 | ----------------------------
4 |
5 |
6 | Alembic Migration Script Template
7 |
8 | This script serves as a template for Alembic migrations. When Alembic auto-generates migration scripts, it uses
9 | this template to structure the content. The generated script then gets saved in the 'versions' directory under 'migrations'.
10 | Each script defines actions to be taken for both 'upgrade' (applying the migration) and 'downgrade' (reverting the migration).
11 |
12 | """
13 |
14 | """add checkpoints table
15 |
16 | Revision ID: fa2c17415189
17 | Revises: 42cb7889f092
18 | Create Date: 2024-03-09 16:14:13.358092
19 |
20 | """
21 | from alembic import op
22 | import sqlalchemy as sa
23 | from sqlalchemy.dialects import (
24 | postgresql,
25 | ) # Import additional modules dynamically based on the migration's needs.
26 |
27 | # Revision identifiers, used by Alembic to identify the migration script.
28 | revision = "fa2c17415189" # The ID of this revision (migration).
29 | down_revision = (
30 | "42cb7889f092" # The ID of the previous revision. Specifies ordering of migrations.
31 | )
32 | branch_labels = None # Labels that can group revisions together.
33 | depends_on = None # If this revision depends on another one, it is referenced here.
34 |
35 |
36 | # Function to handle the 'upgrade' operation.
37 | # This contains the operations to be performed when moving to this version from the previous version.
38 | def upgrade() -> None:
39 | # ### commands auto generated by Alembic - please adjust! ###
40 | op.create_table(
41 | "checkpoints",
42 | sa.Column(
43 | "id",
44 | sa.UUID(),
45 | server_default=sa.text("gen_random_uuid()"),
46 | nullable=False,
47 | comment="A unique identifier for the checkpoint. It's a UUID type and is automatically generated by the database.",
48 | ),
49 | sa.Column(
50 | "user_id",
51 | sa.String(),
52 | nullable=False,
53 | comment="The ID of the user to whom the checkpoint belongs.",
54 | ),
55 | sa.Column(
56 | "thread_id",
57 | sa.String(),
58 | nullable=False,
59 | comment="The ID of the thread to which the checkpoint belongs.",
60 | ),
61 | sa.Column(
62 | "checkpoint",
63 | sa.LargeBinary(),
64 | nullable=False,
65 | comment="The serialized checkpoint data.",
66 | ),
67 | sa.Column(
68 | "created_at",
69 | sa.DateTime(timezone=True),
70 | nullable=False,
71 | comment="The timestamp when the checkpoint was created.",
72 | ),
73 | sa.Column(
74 | "updated_at",
75 | sa.DateTime(timezone=True),
76 | nullable=False,
77 | comment="The timestamp when the checkpoint was last updated.",
78 | ),
79 | sa.PrimaryKeyConstraint("id"),
80 | schema="personaflow",
81 | )
82 | # ### end Alembic commands ### # Operations to upgrade the database schema.
83 |
84 |
85 | # Function to handle the 'downgrade' operation.
86 | # This contains the operations to revert the changes introduced in the 'upgrade' function.
87 | def downgrade() -> None:
88 | # ### commands auto generated by Alembic - please adjust! ###
89 | op.drop_table("checkpoints", schema="personaflow")
90 | # ### end Alembic commands ### # Operations to downgrade the database schema.
91 |
--------------------------------------------------------------------------------
/migrations/versions/fd264e716c5d_remove_checkpoints_table.py:
--------------------------------------------------------------------------------
1 | """
2 | migrations/script.py.mako
3 | ----------------------------
4 |
5 |
6 | Alembic Migration Script Template
7 |
8 | This script serves as a template for Alembic migrations. When Alembic auto-generates migration scripts, it uses
9 | this template to structure the content. The generated script then gets saved in the 'versions' directory under 'migrations'.
10 | Each script defines actions to be taken for both 'upgrade' (applying the migration) and 'downgrade' (reverting the migration).
11 |
12 | """
13 |
14 | """remove_checkpoints_table
15 |
16 | Revision ID: fd264e716c5d
17 | Revises: af0a5f3e2816
18 | Create Date: 2024-10-28 16:11:58.088012
19 |
20 | """
21 | from alembic import op
22 | import sqlalchemy as sa
23 | from sqlalchemy.dialects import (
24 | postgresql,
25 | ) # Import additional modules dynamically based on the migration's needs.
26 |
27 | # Revision identifiers, used by Alembic to identify the migration script.
28 | revision = "fd264e716c5d" # The ID of this revision (migration).
29 | down_revision = (
30 | "af0a5f3e2816" # The ID of the previous revision. Specifies ordering of migrations.
31 | )
32 | branch_labels = None # Labels that can group revisions together.
33 | depends_on = None # If this revision depends on another one, it is referenced here.
34 |
35 |
36 | # Function to handle the 'upgrade' operation.
37 | # This contains the operations to be performed when moving to this version from the previous version.
38 | def upgrade() -> None:
39 | op.execute('DROP TABLE IF EXISTS "personaflow".checkpoints')
40 |
41 |
42 | # Function to handle the 'downgrade' operation.
43 | # This contains the operations to revert the changes introduced in the 'upgrade' function.
44 | def downgrade() -> None:
45 | pass
46 |
--------------------------------------------------------------------------------
/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | warn_return_any = True
3 | warn_unused_configs = True
4 |
--------------------------------------------------------------------------------
/pants.toml:
--------------------------------------------------------------------------------
1 | [GLOBAL]
2 | pants_version = "2.20.0"
3 | backend_packages = [
4 | "pants.backend.shell",
5 | "pants.backend.shell.lint.shfmt",
6 | "pants.backend.shell.lint.shellcheck",
7 |
8 | "pants.backend.docker",
9 | # "pants.backend.docker.lint.hadolint",
10 |
11 | "pants.backend.python",
12 | "pants.backend.python.lint.docformatter",
13 | "pants.backend.python.lint.black",
14 | # "pants.backend.python.lint.flake8",
15 | # "pants.backend.python.lint.autoflake",
16 | "pants.backend.python.typecheck.mypy",
17 | ]
18 |
19 | [source]
20 | root_patterns = ["/"]
21 |
22 | [test]
23 | extra_env_vars = [
24 | 'OPENAI_API_KEY=asdf'
25 | ]
26 |
27 | [pytest]
28 | args = ["--disable-warnings", "-vv"]
29 |
30 | [python]
31 | # The default interpreter constraints for code in this repo. Individual targets can override
32 | # this with the `interpreter_constraints` field. See
33 | # https://www.pantsbuild.org/docs/python-interpreter-compatibility.
34 |
35 | # Modify this if you don't have Python 3.9 on your machine.
36 | # This can be a range, such as [">=3.8,<3.11"], but it's usually recommended to restrict
37 | # to a single minor version.
38 | interpreter_constraints = ["CPython>=3.11,<3.12"]
39 |
40 | # Enable the "resolves" mechanism, which turns on lockfiles for user code. See
41 | # https://www.pantsbuild.org/docs/python-third-party-dependencies. This also adds the
42 | # `generate-lockfiles` goal for Pants to generate the lockfile for you.
43 | enable_resolves = true
44 |
45 | resolves = { python-default = "constraints.txt"}
46 |
47 | [python-bootstrap]
48 | # We search for interpreters both on the $PATH and in the `$(pyenv root)/versions` folder.
49 | # If you're using macOS, you may want to leave off the entry to avoid using the
50 | # problematic system Pythons. See
51 | # https://www.pantsbuild.org/docs/python-interpreter-compatibility#changing-the-interpreter-search-path.
52 | search_path = ["", ""]
53 |
54 | [docker]
55 | build_verbose = true
56 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "personaflow"
3 | version = "0.1.0"
4 | description = ""
5 | authors = ["PersonaFlow"]
6 | readme = "README.md"
7 |
8 | [tool.poetry.dependencies]
9 | python = ">=3.11.0,<3.12"
10 | openai = "^1.7.2"
11 | fastapi = "^0.103.2"
12 | pydantic = "^2.0.1"
13 | uvicorn = "^0.23.2"
14 | asyncio = "^3.4.3"
15 | python-dotenv = "^1.0.0"
16 | jq = "^1.6.0"
17 | loguru = "^0.7.2"
18 | pyjwt = "^2.8.0"
19 | pypdf = "^4.0.1"
20 | redis = "^5.0.1"
21 | langsmith = "^0.1.31"
22 | langchain-core = "0.2.38"
23 | langchain-community = "0.2.16"
24 | langchain = "0.2.16"
25 | orjson = "^3.9.13"
26 | langgraph = "0.2.19"
27 | sse-starlette = "^2.0.0"
28 | langchain-anthropic = "0.1.23"
29 | langchain-google-vertexai = "1.0.10"
30 | fireworks-ai = "^0.11.2"
31 | cohere = ">=4.32,<5.0"
32 | urllib3 = "1.26.18"
33 | boto3 = "^1.34.105"
34 |
35 | semantic-router = { extras = ["processing"], version = ">=0.0.34" }
36 | tiktoken = "0.7"
37 |
38 | alembic = "^1.11.1"
39 | sqlalchemy = { extras = ["asyncio"], version = "^2.0.27" }
40 | asyncpg = "^0.28.0"
41 | structlog = "^23.1.0"
42 | rich = "^13.5.2"
43 | colorama = "^0.4.6"
44 | pydantic-settings = "^2.0.2"
45 | asgi-correlation-id = "^4.2.0"
46 | python-multipart = "^0.0.9"
47 |
48 | duckduckgo-search = "^4.4"
49 | wikipedia = "^1.4.0"
50 | arxiv = "^2.1.0"
51 | xmltodict = "^0.13.0"
52 | python-magic = "^0.4.27"
53 | beautifulsoup4 = "^4.12.3"
54 | pdfminer-six = "^20221105"
55 | psycopg2-binary = "^2.9.9"
56 | qdrant-client = "^1.8.0"
57 | azure-monitor-opentelemetry = "^1.3.0"
58 | dataclasses-json = "^0.6.4"
59 | unstructured-client = "^0.22.0"
60 | langchain-robocorp = "0.0.10"
61 | langchain-openai = "0.1.23"
62 | pytest = "^8.2.2"
63 | factory-boy = "^3.3.0"
64 | httpx = "0.27.0"
65 | pytest-asyncio = "^0.23.7"
66 | pytest-mock = "^3.14.0"
67 | pytest-socket = "^0.6.0"
68 | pytest-cov = "^5.0.0"
69 | langfuse = "^2.36.1"
70 | arize-phoenix = {extras = ["evals"], version = "^4.4.3"}
71 | bcrypt = "^4.1.3"
72 | itsdangerous = "^2.2.0"
73 | freezegun = "^1.5.1"
74 | Authlib = "^1.3.1"
75 | langgraph-checkpoint-postgres = "1.0.7"
76 |
77 | [build-system]
78 | requires = ["poetry-core"]
79 | build-backend = "poetry.core.masonry.api"
80 |
81 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | asyncio_mode = auto
3 |
--------------------------------------------------------------------------------
/scripts/db_management.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Create a development database clone
4 | create_db_clone() {
5 | local SOURCE_DB="internal"
6 | local DEV_DB="pf_internal_dev"
7 |
8 | # Stop connections to source database
9 | psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$SOURCE_DB';"
10 |
11 | # Create new database as a clone
12 | createdb -T "$SOURCE_DB" "$DEV_DB"
13 |
14 | echo "Created development database: $DEV_DB"
15 | }
16 |
17 | # Create a database schema snapshot
18 | create_db_snapshot() {
19 | local DB_NAME=$1
20 | local SCHEMA_NAME=$2
21 | local TIMESTAMP=$(date +%Y%m%d_%H%M%S)
22 | local BACKUP_FILE="personaflow_db_snapshot_${TIMESTAMP}.sql"
23 |
24 | # Dump only the specified schema
25 | pg_dump -d "$DB_NAME" -n "$SCHEMA_NAME" -f "$BACKUP_FILE"
26 |
27 | echo "Created database snapshot: $BACKUP_FILE"
28 | }
29 |
30 | # Restore database from snapshot
31 | restore_db_snapshot() {
32 | local DB_NAME=$1
33 | local BACKUP_FILE=$2
34 |
35 | # Restore the database from snapshot
36 | psql -d "$DB_NAME" -f "$BACKUP_FILE"
37 |
38 | echo "Restored database from snapshot: $BACKUP_FILE"
39 | }
40 |
41 | # Usage examples:
42 | # ./db_management.sh create_clone
43 | # ./db_management.sh create_snapshot internal personaflow
44 | # ./db_management.sh restore_snapshot internal backup_file.sql
--------------------------------------------------------------------------------
/scripts/setup_default_user.py:
--------------------------------------------------------------------------------
1 | # scripts/setup_default_user.py
2 |
3 | import asyncio
4 | import os
5 | import sys
6 |
7 | # Add the 'stack' directory to the Python path
8 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
9 |
10 | # Import all models to ensure they're registered with SQLAlchemy
11 | from stack.app.model.user import User
12 | from stack.app.model.thread import Thread
13 | from stack.app.model.assistant import Assistant
14 | from stack.app.model.file import File
15 | from stack.app.model.message import Message
16 |
17 | from stack.app.core.configuration import settings
18 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
19 | from sqlalchemy.orm import sessionmaker
20 | from sqlalchemy import select
21 |
22 | async def insert_default_user():
23 | engine = create_async_engine(settings.INTERNAL_DATABASE_URI)
24 | async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
25 |
26 | default_user_id = settings.DEFAULT_USER_ID
27 |
28 | async with async_session() as session:
29 | # Check if the default user already exists
30 | query = select(User).where(User.user_id == default_user_id)
31 | result = await session.execute(query)
32 | default_user = result.scalar_one_or_none()
33 |
34 | if default_user is None:
35 | # Create the default user
36 | default_user = User(user_id=default_user_id, username=f"Default User ({default_user_id})")
37 | session.add(default_user)
38 | await session.commit()
39 | print(f"Default user with ID '{default_user_id}' inserted successfully.")
40 | else:
41 | print(f"Default user with ID '{default_user_id}' already exists.")
42 |
43 |
44 | if __name__ == "__main__":
45 | asyncio.run(insert_default_user())
46 |
--------------------------------------------------------------------------------
/stack/.dockerignore:
--------------------------------------------------------------------------------
1 | .flake8
2 | __pycache__
--------------------------------------------------------------------------------
/stack/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ignore = W291
--------------------------------------------------------------------------------
/stack/BUILD:
--------------------------------------------------------------------------------
1 | python_sources(name="lib")
2 |
3 | pex_binary(
4 | name="local",
5 | entry_point="app/main.py",
6 | dependencies=["//:reqs#asyncpg", "//:reqs#python-multipart", "//:reqs#itsdangerous", "//:reqs#langgraph-checkpoint-postgres"],
7 | execution_mode="venv",
8 | )
9 |
10 | docker_image(
11 | name="docker",
12 | dependencies=[":local"],
13 | repository="personaflow/stack"
14 | )
15 |
16 | shell_sources()
17 |
--------------------------------------------------------------------------------
/stack/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11-slim as base
2 | ENV ENVIRONMENT='' \
3 | OPENAI_API_KEY='' \
4 | LANGCHAIN_API_KEY='' \
5 | LANGCHAIN_ENDPOINT='' \
6 | LANGCHAIN_TRACING_V2='' \
7 | APPLICATIONINSIGHTS_CONNECTION_STRING='' \
8 | ENABLE_LANGSMITH_TRACING='' \
9 | LANGSMITH_PROJECT_NAME='' \
10 | LANGGRAPH_RECURSION_LIMIT=5 \
11 | INTERNAL_DATABASE_USER='' \
12 | INTERNAL_DATABASE_PASSWORD='' \
13 | INTERNAL_DATABASE_HOST='' \
14 | INTERNAL_DATABASE_PORT=5432 \
15 | INTERNAL_DATABASE_DATABASE='' \
16 | INTERNAL_DATABASE_SCHEMA='' \
17 | VECTOR_DB_HOST='' \
18 | VECTOR_DB_PORT=6333 \
19 | VECTOR_DB_NAME='' \
20 | VECTOR_DB_API_KEY='' \
21 | VECTOR_DB_ENCODER_DIMENSIONS=1536 \
22 | VECTOR_DB_ENCODER_MODEL='' \
23 | VECTOR_DB_DEFAULT_NAMESPACE='' \
24 | DEFAULT_PARTITION_STRATEGY='' \
25 | DEFAULT_HI_RES_MODEL_NAME='' \
26 | PROCESS_UNSTRUCTURED_TABLES=true \
27 | CREATE_SUMMARY_COLLECTION=true \
28 | DEFAULT_CHUNKING_STRATEGY='' \
29 | DEFAULT_SEMANTIC_CHUNK_MIN_TOKENS=100 \
30 | DEFAULT_SEMANTIC_CHUNK_MAX_TOKENS=1000 \
31 | SEMANTIC_ROLLING_WINDOW_SIZE=1 \
32 | PREFIX_TITLES=true \
33 | PREFIX_SUMMARY=true \
34 | ENABLE_RERANK_BY_DEFAULT=false \
35 | MAX_QUERY_TOP_K=5 \
36 | PERSONAFLOW_API_KEY='' \
37 | COHERE_API_KEY='' \
38 | UNSTRUCTURED_API_KEY='' \
39 | UNSTRUCTURED_BASE_URL='' \
40 | MAX_FILE_UPLOAD_SIZE=25000000 \
41 | ENVIRONMENT='' \
42 | TAVILY_API_KEY='' \
43 | AZURE_OPENAI_DEPLOYMENT_NAME='' \
44 | AZURE_OPENAI_API_BASE='' \
45 | AZURE_OPENAI_API_KEY='' \
46 | AZURE_OPENAI_API_VERSION=''
47 |
48 | COPY stack/local.pex /bin/app
49 |
50 | ENTRYPOINT ["/usr/local/bin/python3.11", "/bin/app"]
51 |
--------------------------------------------------------------------------------
/stack/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/__init__.py
--------------------------------------------------------------------------------
/stack/app/BUILD:
--------------------------------------------------------------------------------
1 | python_sources(
2 |
3 | )
4 |
--------------------------------------------------------------------------------
/stack/app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/__init__.py
--------------------------------------------------------------------------------
/stack/app/agents/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/agents/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/agents/__init__.py
--------------------------------------------------------------------------------
/stack/app/agents/chatbot_executor.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated, List
2 | from langchain_core.language_models.base import LanguageModelLike
3 | from langchain_core.messages import BaseMessage, SystemMessage
4 | from langgraph.graph import END
5 | from langgraph.graph.state import StateGraph
6 | from stack.app.schema.message_types import add_messages_liberal
7 | from stack.app.core.datastore import get_checkpointer
8 |
9 |
10 | def get_chatbot_executor(
11 | llm: LanguageModelLike,
12 | system_message: str,
13 | ):
14 | checkpointer = get_checkpointer()
15 |
16 | def _get_messages(messages):
17 | return [SystemMessage(content=system_message)] + messages
18 |
19 | chatbot = _get_messages | llm
20 |
21 | workflow = StateGraph(Annotated[List[BaseMessage], add_messages_liberal])
22 | workflow.add_node("chatbot", chatbot)
23 | workflow.set_entry_point("chatbot")
24 | workflow.add_edge("chatbot", END)
25 | app = workflow.compile(checkpointer=checkpointer)
26 | return app
27 |
--------------------------------------------------------------------------------
/stack/app/agents/configurable_chatbot.py:
--------------------------------------------------------------------------------
1 | # from enum import Enum
2 | # from typing import Any, Dict, Mapping, Optional, Sequence, Union
3 |
4 | # from langchain_core.messages import AnyMessage
5 | # from langchain_core.runnables import (
6 | # ConfigurableField,
7 | # RunnableBinding,
8 | # RunnableSerializable,
9 | # )
10 | # from langgraph.graph.message import Messages
11 |
12 | # from stack.app.agents.xml_agent import get_xml_agent_executor
13 | # from stack.app.agents.chatbot import get_chatbot_executor
14 | # from stack.app.core.configuration import get_settings
15 | # from stack.app.schema.assistant import AgentType, LLMType
16 |
17 | # from stack.app.agents.retrieval import get_retrieval_executor
18 | # from stack.app.core.datastore import get_checkpointer
19 | # from stack.app.agents.tools import (
20 | # RETRIEVAL_DESCRIPTION,
21 | # TOOLS,
22 | # ActionServer,
23 | # Arxiv,
24 | # AvailableTools,
25 | # Connery,
26 | # DallE,
27 | # DDGSearch,
28 | # PubMed,
29 | # Retrieval,
30 | # Tavily,
31 | # TavilyAnswer,
32 | # Wikipedia,
33 | # YouSearch,
34 | # SecFilings,
35 | # PressReleases,
36 | # get_retrieval_tool,
37 | # get_retriever,
38 | # )
39 |
40 | # Tool = Union[
41 | # ActionServer,
42 | # Connery,
43 | # DDGSearch,
44 | # Arxiv,
45 | # YouSearch,
46 | # PubMed,
47 | # Wikipedia,
48 | # Tavily,
49 | # TavilyAnswer,
50 | # Retrieval,
51 | # DallE,
52 | # SecFilings,
53 | # PressReleases,
54 | # ]
55 |
56 | # DEFAULT_SYSTEM_MESSAGE = "You are a helpful assistant."
57 |
58 |
59 | # def get_chatbot(
60 | # llm_type: LLMType,
61 | # system_message: str,
62 | # ):
63 | # llm = get_llm(llm_type)
64 | # return get_chatbot_executor(llm, system_message)
65 |
66 |
67 | # class ConfigurableChatBot(RunnableBinding):
68 | # llm: LLMType
69 | # system_message: str = DEFAULT_SYSTEM_MESSAGE
70 | # user_id: Optional[str] = None
71 |
72 | # def __init__(
73 | # self,
74 | # *,
75 | # llm: LLMType = LLMType.GPT_4O_MINI,
76 | # system_message: str = DEFAULT_SYSTEM_MESSAGE,
77 | # kwargs: Optional[Mapping[str, Any]] = None,
78 | # config: Optional[Mapping[str, Any]] = None,
79 | # **others: Any,
80 | # ) -> None:
81 | # others.pop("bound", None)
82 |
83 | # chatbot = get_chatbot(llm, system_message)
84 | # super().__init__(
85 | # llm=llm,
86 | # system_message=system_message,
87 | # bound=chatbot,
88 | # kwargs=kwargs or {},
89 | # config=config or {},
90 | # )
91 |
92 |
93 | # chatbot = (
94 | # ConfigurableChatBot(llm=LLMType.GPT_4O_MINI)
95 | # .configurable_fields(
96 | # llm=ConfigurableField(id="llm_type", name="LLM Type"),
97 | # system_message=ConfigurableField(id="system_message", name="Instructions"),
98 | # )
99 | # .with_types(
100 | # input_type=Messages,
101 | # output_type=Sequence[AnyMessage],
102 | # )
103 | # )
104 |
--------------------------------------------------------------------------------
/stack/app/agents/configurable_retrieval.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict, Mapping, Optional
2 |
3 | from langchain_core.runnables import (
4 | ConfigurableField,
5 | RunnableBinding,
6 | Runnable,
7 | )
8 |
9 | from stack.app.agents.llm import get_llm, LLMType
10 |
11 | from stack.app.agents.retrieval_executor import get_retrieval_executor
12 | from stack.app.agents.tools import get_retriever
13 |
14 |
15 | DEFAULT_SYSTEM_MESSAGE = "You are a helpful assistant."
16 |
17 |
18 | class ConfigurableRetrieval(RunnableBinding):
19 | llm_type: LLMType
20 | system_message: str = DEFAULT_SYSTEM_MESSAGE
21 | assistant_id: Optional[str] = None
22 | thread_id: str = ""
23 | user_id: Optional[str] = None
24 |
25 | def __init__(
26 | self,
27 | *,
28 | llm_type: LLMType = LLMType.GPT_4O_MINI,
29 | system_message: str = DEFAULT_SYSTEM_MESSAGE,
30 | assistant_id: Optional[str] = None,
31 | thread_id: str = "",
32 | kwargs: Optional[Mapping[str, Any]] = None,
33 | config: Optional[Mapping[str, Any]] = None,
34 | **others: Any,
35 | ) -> None:
36 | others.pop("bound", None)
37 | retriever = get_retriever(assistant_id, thread_id)
38 | llm = get_llm(llm_type)
39 | chatbot = get_retrieval_executor(llm, retriever, system_message)
40 | super().__init__(
41 | llm_type=llm_type,
42 | system_message=system_message,
43 | bound=chatbot,
44 | kwargs=kwargs or {},
45 | config=config or {},
46 | ) # type: ignore
47 |
48 |
49 | def get_configured_chat_retrieval() -> Runnable:
50 | initial = ConfigurableRetrieval(
51 | llm_type=LLMType.GPT_4O_MINI, system_message=DEFAULT_SYSTEM_MESSAGE
52 | )
53 | return initial.configurable_fields(
54 | llm_type=ConfigurableField(id="llm_type", name="LLM Type"),
55 | system_message=ConfigurableField(id="system_message", name="Instructions"),
56 | assistant_id=ConfigurableField(
57 | id="assistant_id", name="Assistant ID", is_shared=True
58 | ),
59 | thread_id=ConfigurableField(id="thread_id", name="Thread ID", is_shared=True),
60 | ).with_types(
61 | input_type=Dict[str, Any],
62 | output_type=Dict[str, Any],
63 | )
64 |
--------------------------------------------------------------------------------
/stack/app/agents/prompts.py:
--------------------------------------------------------------------------------
1 | xml_template = """{system_message}
2 |
3 | You have access to the following tools:
4 |
5 | {tools}
6 |
7 | In order to use a tool, you can use and tags. You will then get back a response in the form
8 | For example, if you have a tool called 'search' that could run a google search, in order to search for the weather in SF you would respond:
9 |
10 | searchweather in SF
11 | 64 degrees
12 |
13 | When you are done, you can respond as normal to the user.
14 |
15 | Example 1:
16 |
17 | Human: Hi!
18 |
19 | Assistant: Hi! How are you?
20 |
21 | Human: What is the weather in SF?
22 | Assistant: searchweather in SF
23 | 64 degrees
24 | It is 64 degrees in SF
25 |
26 |
27 | Begin!"""
28 |
--------------------------------------------------------------------------------
/stack/app/api/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/api/__init__.py
--------------------------------------------------------------------------------
/stack/app/api/v1/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/api/v1/__init__.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Response
2 |
3 | from stack.app.api.v1 import runs
4 | from stack.app.api.v1 import users
5 | from stack.app.api.v1 import threads
6 | from stack.app.api.v1 import assistants
7 | from stack.app.api.v1 import rag
8 | from stack.app.api.v1 import files
9 | from stack.app.api.v1 import auth
10 | from stack.app.api.v1 import admin_users
11 |
12 | api_router = APIRouter()
13 | api_router.include_router(runs.router, prefix="/runs")
14 | api_router.include_router(users.router, prefix="/users")
15 | api_router.include_router(threads.router, prefix="/threads")
16 | api_router.include_router(assistants.router, prefix="/assistants")
17 | api_router.include_router(rag.router, prefix="/rag")
18 | api_router.include_router(files.router, prefix="/files")
19 | api_router.include_router(auth.router, prefix="/auth")
20 |
21 | api_router.include_router(admin_users.router, prefix="/admin/users")
22 |
23 |
24 | @api_router.get("/health_check", tags=["Health Check"])
25 | def health_check():
26 | return Response(content="OK", status_code=200)
27 |
--------------------------------------------------------------------------------
/stack/app/cache/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/cache/__init__.py:
--------------------------------------------------------------------------------
1 | import redis
2 |
3 | from .cache import create_redis
4 |
5 | pool = create_redis()
6 |
7 |
8 | def get_redis():
9 | """Get instance of redis reusing the connection pool to avoid constantly
10 | opening/closing connections, yet get a fresh instance of redis each
11 | time."""
12 | return redis.Redis(connection_pool=pool)
13 |
--------------------------------------------------------------------------------
/stack/app/cache/cache.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import redis
4 |
5 | """
6 | GLOBALS
7 | """
8 | REDIS_SSL = os.environ.get("REDIS_SSL") == "1"
9 | REDIS_HOST = os.environ.get("REDIS_HOST")
10 | REDIS_PORT = os.environ.get("REDIS_PORT")
11 | REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD")
12 |
13 |
14 | def create_redis():
15 | """Create Redis connection pool to use through the lifetime of the
16 | service."""
17 | return redis.ConnectionPool(
18 | host=REDIS_HOST,
19 | port=REDIS_PORT,
20 | password=REDIS_PASSWORD,
21 | connection_class=redis.SSLConnection if REDIS_SSL else redis.Connection,
22 | decode_responses=True,
23 | )
24 |
--------------------------------------------------------------------------------
/stack/app/chains/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/chains/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/chains/__init__.py
--------------------------------------------------------------------------------
/stack/app/chains/universal_retrieval_chain.py:
--------------------------------------------------------------------------------
1 | from operator import itemgetter
2 | from langchain.schema.retriever import BaseRetriever
3 | from langchain.schema.language_model import BaseLanguageModel
4 | from langchain.schema.runnable import Runnable, RunnableMap
5 | from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder, PromptTemplate
6 | from langchain.schema.output_parser import StrOutputParser
7 | from stack.app.utils import format_docs
8 |
9 |
10 | def create_retriever_chain(
11 | llm: BaseLanguageModel,
12 | retriever: BaseRetriever,
13 | rephrase_template: str,
14 | use_chat_history: bool,
15 | ) -> Runnable:
16 | CONDENSE_QUESTION_PROMPT = PromptTemplate.from_template(rephrase_template)
17 | if not use_chat_history:
18 | initial_chain = (itemgetter("question")) | retriever
19 | return initial_chain
20 | else:
21 | condense_question_chain = (
22 | {
23 | "question": itemgetter("question"),
24 | "chat_history": itemgetter("chat_history"),
25 | }
26 | | CONDENSE_QUESTION_PROMPT
27 | | llm
28 | | StrOutputParser()
29 | ).with_config(
30 | run_name="CondenseQuestion",
31 | )
32 | conversation_chain = condense_question_chain | retriever
33 | return conversation_chain
34 |
35 |
36 | def create_universal_retrieval_chain(
37 | llm: BaseLanguageModel,
38 | retriever: BaseRetriever,
39 | response_template: str,
40 | rephrase_template: str,
41 | use_chat_history: bool = False,
42 | ) -> Runnable:
43 | retriever_chain = create_retriever_chain(
44 | llm, retriever, rephrase_template, use_chat_history
45 | ).with_config(run_name="ChatPipeline")
46 |
47 | _context = RunnableMap(
48 | {
49 | "context": retriever_chain | format_docs,
50 | "question": itemgetter("question"),
51 | "chat_history": itemgetter("chat_history"),
52 | }
53 | ).with_config(run_name="RetrieveDocs")
54 |
55 | prompt = ChatPromptTemplate.from_messages(
56 | [
57 | ("system", response_template),
58 | MessagesPlaceholder(variable_name="chat_history"),
59 | ("human", "{question}"),
60 | ]
61 | )
62 |
63 | response_synthesizer = (prompt | llm | StrOutputParser()).with_config(
64 | run_name="GenerateResponse",
65 | )
66 |
67 | return _context | response_synthesizer
68 |
--------------------------------------------------------------------------------
/stack/app/core/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/core/__init__.py
--------------------------------------------------------------------------------
/stack/app/core/auth/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/core/auth/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/core/auth/__init__.py
--------------------------------------------------------------------------------
/stack/app/core/auth/auth_config.py:
--------------------------------------------------------------------------------
1 | from stack.app.core.auth.strategies.base import (
2 | BaseAuthenticationStrategy,
3 | BaseOAuthStrategy,
4 | )
5 | from stack.app.core.auth.strategies.basic import BasicAuthentication
6 | from stack.app.core.auth.strategies.google_oauth import GoogleOAuth
7 | from stack.app.core.auth.strategies.oidc import OpenIDConnect
8 |
9 | # Add Auth strategy classes here to enable them
10 | # Ex: [BasicAuthentication]
11 | ENABLED_AUTH_STRATEGIES = [
12 | # BasicAuthentication,
13 | # GoogleOAuth,
14 | # OpenIDConnect,
15 | ]
16 |
17 | # Define the mapping from Auth strategy name to class obj - does not need to be manually modified.
18 | # During runtime, this will create an instance of each enabled strategy class.
19 | # Ex: {"Basic": BasicAuthentication()}
20 | ENABLED_AUTH_STRATEGY_MAPPING = {cls.NAME: cls() for cls in ENABLED_AUTH_STRATEGIES}
21 |
22 |
23 | def is_authentication_enabled() -> bool:
24 | """Check whether any form of authentication was enabled.
25 |
26 | Returns:
27 | bool: Whether authentication is enabled.
28 | """
29 | return bool(ENABLED_AUTH_STRATEGY_MAPPING)
30 |
31 |
32 | def get_auth_strategy(
33 | strategy_name: str,
34 | ) -> BaseAuthenticationStrategy | BaseOAuthStrategy | None:
35 | return ENABLED_AUTH_STRATEGY_MAPPING.get(strategy_name)
36 |
37 |
38 | async def get_auth_strategy_endpoints() -> None:
39 | """Fetches the endpoints for each enabled strategy."""
40 | for strategy in ENABLED_AUTH_STRATEGY_MAPPING.values():
41 | if hasattr(strategy, "get_endpoints"):
42 | await strategy.get_endpoints()
43 |
--------------------------------------------------------------------------------
/stack/app/core/auth/jwt.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta, timezone
2 | import structlog
3 | import uuid
4 | import jwt
5 | from jwt.exceptions import DecodeError, ExpiredSignatureError, InvalidTokenError
6 |
7 | from stack.app.core.configuration import Settings, get_settings
8 |
9 | logger = structlog.get_logger()
10 |
11 | settings = get_settings()
12 |
13 |
14 | class JWTService:
15 | def __init__(self, settings: Settings = None):
16 | self.settings = settings or get_settings()
17 | self.secret_key = self.settings.AUTH_SECRET_KEY
18 | self.algorithm = self.settings.JWT_ALGORITHM
19 | self.expiry = self.settings.TOKEN_EXPIRY_HOURS
20 | self.issuer = self.settings.JWT_ISSUER
21 |
22 | if not self.secret_key:
23 | raise ValueError("Secret auth key is not set.")
24 |
25 | def create_and_encode_jwt(self, user: dict, strategy_name: str) -> str:
26 | now = datetime.now(timezone.utc)
27 | payload = {
28 | "iss": self.issuer,
29 | "iat": now,
30 | "exp": now + timedelta(hours=self.expiry),
31 | "jti": str(uuid.uuid4()),
32 | "strategy": strategy_name,
33 | "context": user,
34 | }
35 |
36 | token = jwt.encode(payload, self.secret_key, self.algorithm)
37 |
38 | return token
39 |
40 | def decode_jwt(self, token: str) -> dict:
41 | try:
42 | decoded_payload = jwt.decode(
43 | token, self.secret_key, algorithms=[self.algorithm]
44 | )
45 | logger.info(f"Successfully decoded token: {decoded_payload}")
46 | return decoded_payload
47 | except ExpiredSignatureError:
48 | logger.warning("JWT Token has expired.")
49 | raise ValueError("JWT Token has expired")
50 | except DecodeError as e:
51 | logger.exception(f"JWT Token is malformed: {str(e)}")
52 | raise ValueError("JWT Token is malformed")
53 | except InvalidTokenError as e:
54 | logger.exception(f"JWT Token is invalid: {str(e)}")
55 | raise ValueError(f"JWT Token is invalid: {str(e)}")
56 |
--------------------------------------------------------------------------------
/stack/app/core/auth/strategies/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/core/auth/strategies/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/core/auth/strategies/__init__.py
--------------------------------------------------------------------------------
/stack/app/core/auth/strategies/basic.py:
--------------------------------------------------------------------------------
1 | import bcrypt
2 | from typing import List
3 |
4 | from stack.app.model.user import User
5 | from stack.app.core.auth.strategies.base import BaseAuthenticationStrategy
6 | from stack.app.repositories.user import UserRepository
7 |
8 |
9 | class BasicAuthentication(BaseAuthenticationStrategy):
10 | """Basic email/password strategy."""
11 |
12 | NAME = "Basic"
13 |
14 | @staticmethod
15 | def get_required_payload() -> List[str]:
16 | """Retrieves the required /login payload for the Auth strategy.
17 |
18 | Returns:
19 | List[str]: List of required variables.
20 | """
21 | return ["email", "password"]
22 |
23 | def check_password(self, plain_text_password: str, hashed_password: bytes) -> bool:
24 | """Checks that the input plain text password corresponds to a hashed
25 | password.
26 |
27 | Args:
28 | plain_text_password (str): Password to check.
29 | hashed_password (bytes): Password to check against.
30 |
31 | Returns:
32 | bool: Whether the plain-text password matches the given hashed password.
33 | """
34 | return bcrypt.checkpw(plain_text_password.encode("utf-8"), hashed_password)
35 |
36 | async def login(
37 | self, user_repository: UserRepository, payload: dict[str, str]
38 | ) -> dict | None:
39 | """Logs user in, checking if the hashed input password corresponds to
40 | the one stored in the DB.
41 |
42 | Args:
43 | user_repository (UserRepository): UserRepository
44 | payload (dict[str, str]): Login payload
45 |
46 | Returns:
47 | dict | None: Returns the user as dict to set the app session, or None.
48 | """
49 |
50 | payload_email = payload.get("email", "")
51 | payload_password = payload.get("password", "")
52 | user: User = await user_repository.retrieve_user_by_email(payload_email)
53 |
54 | if not user:
55 | return None
56 |
57 | if self.check_password(payload_password, user.hashed_password):
58 | return {
59 | "user_id": user.user_id,
60 | "email": user.email,
61 | }
62 |
63 | return None
64 |
--------------------------------------------------------------------------------
/stack/app/core/auth/strategies/google_oauth.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import requests
4 | from authlib.integrations.requests_client import OAuth2Session
5 | from starlette.requests import Request
6 |
7 | from stack.app.core.auth.strategies.base import BaseOAuthStrategy
8 | from stack.app.core.auth.strategies.settings import AuthStrategySettings
9 |
10 |
11 | class GoogleOAuthSettings(AuthStrategySettings):
12 | google_client_id: str
13 | google_client_secret: str
14 | frontend_hostname: str
15 |
16 |
17 | class GoogleOAuth(BaseOAuthStrategy):
18 | """Google OAuth2.0 strategy."""
19 |
20 | NAME = "Google"
21 | WELL_KNOWN_ENDPOINT = "https://accounts.google.com/.well-known/openid-configuration"
22 |
23 | def __init__(self):
24 | try:
25 | self.settings = GoogleOAuthSettings()
26 | self.REDIRECT_URI = (
27 | f"{self.settings.frontend_hostname}/auth/{self.NAME.lower()}"
28 | )
29 | self.client = OAuth2Session(
30 | client_id=self.settings.google_client_id,
31 | client_secret=self.settings.google_client_secret,
32 | )
33 | except Exception as e:
34 | logging.error(f"Error during initializing of GoogleOAuth class: {str(e)}")
35 | raise
36 |
37 | def get_client_id(self):
38 | return self.settings.google_client_id
39 |
40 | def get_pkce_enabled(self):
41 | return self.PKCE_ENABLED if hasattr(self, "PKCE_ENABLED") else False
42 |
43 | def get_authorization_endpoint(self):
44 | return (
45 | self.AUTHORIZATION_ENDPOINT
46 | if hasattr(self, "AUTHORIZATION_ENDPOINT")
47 | else None
48 | )
49 |
--------------------------------------------------------------------------------
/stack/app/core/auth/strategies/oidc.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from authlib.integrations.requests_client import OAuth2Session
3 |
4 | from stack.app.core.auth.strategies.base import BaseOAuthStrategy
5 | from stack.app.core.auth.strategies.settings import AuthStrategySettings
6 |
7 |
8 | class OIDCSettings(AuthStrategySettings):
9 | oidc_client_id: str
10 | oidc_client_secret: str
11 | oidc_well_known_endpoint: str
12 | frontend_hostname: str
13 |
14 |
15 | class OpenIDConnect(BaseOAuthStrategy):
16 | """OpenID Connect strategy."""
17 |
18 | NAME = "OIDC"
19 | PKCE_ENABLED = True
20 |
21 | def __init__(self):
22 | try:
23 | self.settings = OIDCSettings()
24 | self.REDIRECT_URI = (
25 | f"{self.settings.frontend_hostname}/auth/{self.NAME.lower()}"
26 | )
27 | self.WELL_KNOWN_ENDPOINT = self.settings.oidc_well_known_endpoint
28 | self.client = OAuth2Session(
29 | client_id=self.settings.oidc_client_id,
30 | client_secret=self.settings.oidc_client_secret,
31 | )
32 | except Exception as e:
33 | logging.error(f"Error during initializing of OpenIDConnect class: {str(e)}")
34 | raise
35 |
36 | def get_client_id(self):
37 | return self.settings.oidc_client_id
38 |
39 | def get_pkce_enabled(self):
40 | return self.PKCE_ENABLED if hasattr(self, "PKCE_ENABLED") else False
41 |
42 | def get_authorization_endpoint(self):
43 | return (
44 | self.AUTHORIZATION_ENDPOINT
45 | if hasattr(self, "AUTHORIZATION_ENDPOINT")
46 | else None
47 | )
48 |
--------------------------------------------------------------------------------
/stack/app/core/auth/strategies/settings.py:
--------------------------------------------------------------------------------
1 | from pydantic import root_validator
2 | from pydantic_settings import BaseSettings
3 |
4 |
5 | class AuthStrategySettings(BaseSettings):
6 | """Settings class used to grab environment variables directly from .env
7 | file.
8 |
9 | Uppercase env variables converted to class parameters.
10 | """
11 |
12 | class Config:
13 | env_file = ".env"
14 | extra = "ignore"
15 |
16 | @root_validator(pre=True)
17 | def check_required_fields(cls, values):
18 | # Retrieves list of class properties
19 | required_fields = list(cls.__annotations__.keys())
20 |
21 | missing_fields = [
22 | field.upper()
23 | for field in required_fields
24 | if field not in values or values[field] is None
25 | ]
26 |
27 | if missing_fields:
28 | errors = ", ".join(missing_fields)
29 | raise ValueError(
30 | f"Missing required environment variables: {errors}. Please set them in the .env file."
31 | )
32 |
33 | return values
34 |
--------------------------------------------------------------------------------
/stack/app/core/auth/utils.py:
--------------------------------------------------------------------------------
1 | from fastapi import Request
2 | from stack.app.core.auth.auth_config import (
3 | is_authentication_enabled,
4 | ENABLED_AUTH_STRATEGY_MAPPING,
5 | )
6 | from stack.app.core.auth.jwt import JWTService
7 | from stack.app.core.configuration import settings
8 |
9 |
10 | def is_enabled_authentication_strategy(strategy_name: str) -> bool:
11 | """Check whether a given authentication strategy is enabled in
12 | auth_config.py.
13 |
14 | Args:
15 | strategy_name (str): Name the of auth strategy.
16 |
17 | Returns:
18 | bool: Whether that strategy is currently enabled
19 | """
20 | # Check the strategy is valid and enabled
21 | return strategy_name in ENABLED_AUTH_STRATEGY_MAPPING.keys()
22 |
23 |
24 | def get_header_user_id(request: Request) -> str:
25 | """Retrieves the user_id from request headers, will work whether
26 | authentication is enabled or not.
27 |
28 | (Auth disabled): retrieves the User-Id header value
29 | (Auth enabled): retrieves the Authorization header, and decodes the value
30 |
31 | Args:
32 | request (Request): current Request
33 |
34 |
35 | Returns:
36 | str: User ID
37 | """
38 | default_user_id = settings.DEFAULT_USER_ID
39 | # Check if Auth enabled
40 | if is_authentication_enabled():
41 | # Validation already performed, so just retrieve value
42 | authorization = request.headers.get("Authorization")
43 | _, token = authorization.split(" ")
44 | decoded = JWTService().decode_jwt(token)
45 |
46 | return decoded["context"]["user_id"]
47 | # Auth disabled
48 | else:
49 | user_id = request.headers.get("User-Id") or default_user_id
50 | return user_id
51 |
--------------------------------------------------------------------------------
/stack/app/core/exception.py:
--------------------------------------------------------------------------------
1 | """
2 | exception.py
3 | ----------
4 | This module defines custom exception classes extending FastAPI's HTTPException.
5 |
6 | These exceptions are tailored to cater to common error scenarios in the application,
7 | enabling consistent error responses and ensuring that the response codes align with
8 | the HTTP standard.
9 |
10 | """
11 |
12 | from fastapi import HTTPException, status
13 |
14 |
15 | class NotFoundException(HTTPException):
16 | def __init__(self, message: str = None):
17 | super().__init__(
18 | status_code=status.HTTP_404_NOT_FOUND, detail=message or "Record Not Found"
19 | )
20 |
21 |
22 | class UnauthorizedException(HTTPException):
23 | def __init__(self, message: str = None):
24 | super().__init__(
25 | status_code=status.HTTP_401_UNAUTHORIZED,
26 | detail=message or "Can not validate credentials",
27 | )
28 |
--------------------------------------------------------------------------------
/stack/app/core/redis.py:
--------------------------------------------------------------------------------
1 | from redis.asyncio import Redis
2 | import structlog
3 | from typing import Optional
4 |
5 | logger = structlog.get_logger()
6 |
7 |
8 | class RedisService:
9 | def __init__(self, redis: Redis):
10 | self.redis = redis
11 |
12 | async def get_progress_messages(
13 | self, task_id: str, start_index: int = 0
14 | ) -> list[str]:
15 | messages = await self.redis.lrange(
16 | f"ingestion:{task_id}:progress", start_index, -1
17 | )
18 | return [message.decode("utf-8") for message in messages]
19 |
20 | async def get_ingestion_status(self, task_id: str) -> Optional[str]:
21 | status = await self.redis.get(f"ingestion:{task_id}:status")
22 | if status:
23 | return status.decode("utf-8")
24 | return None
25 |
26 | async def push_progress_message(self, task_id: str, message: str):
27 | await self.redis.rpush(f"ingestion:{task_id}:progress", message)
28 |
29 | async def set_ingestion_status(self, task_id: str, status: str):
30 | await self.redis.set(f"ingestion:{task_id}:status", status)
31 |
32 |
33 | async def get_redis_service() -> RedisService:
34 | from stack.app.core.datastore import get_redis_connection
35 |
36 | redis = await get_redis_connection()
37 | return RedisService(redis)
38 |
--------------------------------------------------------------------------------
/stack/app/core/retriever.py:
--------------------------------------------------------------------------------
1 | from qdrant_client import QdrantClient
2 | from stack.app.core.configuration import get_settings
3 | from langchain_openai import OpenAIEmbeddings
4 | from langchain_community.vectorstores.qdrant import Qdrant
5 |
6 | settings = get_settings()
7 |
8 | qdrant_client = QdrantClient(host=settings.VECTOR_DB_HOST, port=settings.VECTOR_DB_PORT)
9 |
10 | qdrant_vstore = Qdrant(
11 | client=qdrant_client,
12 | collection_name=settings.VECTOR_DB_COLLECTION_NAME,
13 | embeddings=OpenAIEmbeddings(),
14 | vector_name="page_content",
15 | )
16 |
--------------------------------------------------------------------------------
/stack/app/main.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from dotenv import load_dotenv
4 |
5 |
6 | # If we have the `ENVIRONMENT`variable already, we are running in Docker or Kubernetes
7 | # and do not need to load the .env file
8 | environment = os.getenv("ENVIRONMENT")
9 | if not environment:
10 | env_path = ".env"
11 | load_dotenv(env_path)
12 |
13 | from stack.app.app_factory import create_app
14 | from stack.app.core.configuration import settings
15 |
16 |
17 | if settings.ENABLE_LANGSMITH_TRACING:
18 | from langsmith import Client
19 |
20 | client = Client()
21 |
22 | app = create_app(settings)
23 |
24 | """
25 | Application Insights configuration
26 | (Disabled if APPLICATIONINSIGHTS_CONNECTION_STRING is not set)
27 | """
28 | logging.getLogger("azure").setLevel(logging.WARN)
29 | logging.getLogger("httpcore").setLevel(logging.WARN)
30 | logging.getLogger("urllib3").setLevel(logging.WARN)
31 | logging.getLogger("opentelemetry").setLevel(logging.WARN)
32 | AI_CONN_STR_ENV_NAME = "APPLICATIONINSIGHTS_CONNECTION_STRING"
33 | AI_CONN_STR = os.getenv(AI_CONN_STR_ENV_NAME)
34 | if AI_CONN_STR and AI_CONN_STR != "":
35 | from azure.monitor.opentelemetry import configure_azure_monitor
36 | from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
37 |
38 | configure_azure_monitor(
39 | connection_string=os.getenv(AI_CONN_STR_ENV_NAME),
40 | logger_name="personaflow",
41 | logging_level=os.getenv("INFO"),
42 | )
43 | FastAPIInstrumentor.instrument_app(app)
44 |
45 |
46 | if __name__ == "__main__":
47 | import uvicorn
48 |
49 | uvicorn.run(app, host="0.0.0.0", port=9000)
50 |
--------------------------------------------------------------------------------
/stack/app/middlewares/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/middlewares/__init__.py:
--------------------------------------------------------------------------------
1 | # from .request_logger import RequestLoggerMiddleware
2 |
--------------------------------------------------------------------------------
/stack/app/model/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/model/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/model/__init__.py
--------------------------------------------------------------------------------
/stack/app/model/assistant.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | import uuid
3 | from sqlalchemy import String, Boolean, text, DateTime, ForeignKey
4 | from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
5 | from sqlalchemy.orm import Mapped, mapped_column, relationship
6 | from sqlalchemy.sql import func
7 | from stack.app.core.configuration import settings
8 | from stack.app.model.base import Base
9 |
10 |
11 | class Assistant(Base):
12 | __tablename__ = "assistants"
13 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA}
14 |
15 | id: Mapped[uuid.UUID] = mapped_column(
16 | UUID(as_uuid=True),
17 | primary_key=True,
18 | server_default=text("gen_random_uuid()"),
19 | comment="A unique identifier for the assistant. It's a UUID type and is automatically generated by the database.",
20 | )
21 | user_id: Mapped[str] = mapped_column(
22 | String(),
23 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.users.user_id"),
24 | nullable=False,
25 | index=True,
26 | comment="The ID of the user who created the assistant.",
27 | )
28 | name: Mapped[str] = mapped_column(
29 | String(), nullable=False, comment="The name of the assistant."
30 | )
31 | config: Mapped[JSONB] = mapped_column(
32 | JSONB(),
33 | nullable=False,
34 | comment="The assistant config, containing specific configuration parameters.",
35 | )
36 | public: Mapped[bool] = mapped_column(
37 | Boolean(), default=False, comment="Whether or not the assistant is public."
38 | )
39 | file_ids: Mapped[ARRAY] = mapped_column(
40 | ARRAY(String()),
41 | nullable=True,
42 | comment="A list of files to be associated with this assistant for use with Retrieval.",
43 | )
44 | kwargs: Mapped[JSONB] = mapped_column(
45 | JSONB(),
46 | nullable=True,
47 | comment="The assistant metadata, containing any additional information about the assistant.",
48 | )
49 | created_at: Mapped[datetime] = mapped_column(
50 | DateTime(timezone=True),
51 | default=func.now(),
52 | nullable=False,
53 | comment="Created date",
54 | )
55 | updated_at: Mapped[datetime] = mapped_column(
56 | DateTime(timezone=True),
57 | default=func.now(),
58 | onupdate=func.now(),
59 | nullable=False,
60 | comment="Last updated date",
61 | )
62 |
63 | user = relationship("User", back_populates="assistant")
64 |
65 | thread = relationship("Thread", back_populates="assistant")
66 |
--------------------------------------------------------------------------------
/stack/app/model/base.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from sqlalchemy import MetaData
4 | from sqlalchemy.orm import declarative_base, declared_attr
5 |
6 | from stack.app.core.configuration import settings
7 |
8 |
9 | def camel_to_snake_case(name: str):
10 | name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
11 | return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
12 |
13 |
14 | class _Base:
15 | @declared_attr
16 | def __tablename__(cls):
17 | return camel_to_snake_case(cls.__name__)
18 |
19 | @declared_attr
20 | def alias(cls):
21 | return camel_to_snake_case(cls.__name__)
22 |
23 |
24 | Base = declarative_base(
25 | cls=_Base, metadata=MetaData(schema=settings.INTERNAL_DATABASE_SCHEMA)
26 | )
27 |
--------------------------------------------------------------------------------
/stack/app/model/blacklist.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from datetime import datetime
3 |
4 | from sqlalchemy.dialects.postgresql import UUID
5 | from sqlalchemy import String, func
6 | from sqlalchemy.orm import Mapped, mapped_column
7 | from stack.app.core.configuration import settings
8 | from stack.app.model.base import Base
9 | from sqlalchemy import DateTime, LargeBinary, String, text, ForeignKey
10 |
11 |
12 | class Blacklist(Base):
13 | """Table that contains the list of JWT access tokens that are blacklisted
14 | during logout."""
15 |
16 | __tablename__ = "blacklist"
17 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA}
18 |
19 | id: Mapped[uuid.UUID] = mapped_column(
20 | UUID(as_uuid=True),
21 | primary_key=True,
22 | server_default=text("gen_random_uuid()"),
23 | comment="A unique identifier for the blacklist entry. It's a UUID type and is automatically generated by the database.",
24 | )
25 | token_id: Mapped[str] = mapped_column(
26 | String(),
27 | index=True,
28 | comment="The ID of the token to be blacklisted.",
29 | )
30 | created_at: Mapped[datetime] = mapped_column(
31 | DateTime(timezone=True),
32 | default=func.now(),
33 | nullable=False,
34 | comment="The timestamp when the token was blacklisted.",
35 | )
36 | updated_at: Mapped[datetime] = mapped_column(
37 | DateTime(timezone=True),
38 | default=func.now(),
39 | onupdate=func.now(),
40 | nullable=False,
41 | comment="The timestamp when the token was last updated.",
42 | )
43 |
--------------------------------------------------------------------------------
/stack/app/model/file.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | import uuid
3 | from sqlalchemy import String, text, DateTime, Integer, ForeignKey
4 | from sqlalchemy.dialects.postgresql import UUID, JSONB
5 | from sqlalchemy.orm import Mapped, mapped_column, relationship
6 | from sqlalchemy.sql import func
7 | from stack.app.core.configuration import settings
8 | from stack.app.model.base import Base
9 |
10 |
11 | class File(Base):
12 | __tablename__ = "files"
13 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA}
14 |
15 | id: Mapped[uuid.UUID] = mapped_column(
16 | UUID(as_uuid=True),
17 | primary_key=True,
18 | server_default=text("gen_random_uuid()"),
19 | comment="A unique identifier for the file. It's a UUID type and is automatically generated by the database.",
20 | )
21 | user_id: Mapped[str] = mapped_column(
22 | String(),
23 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.users.user_id"),
24 | nullable=False,
25 | index=True,
26 | comment="The ID of the user who created the file.",
27 | )
28 | filename: Mapped[str] = mapped_column(
29 | String(), nullable=False, comment="The name of the file."
30 | )
31 | purpose: Mapped[str] = mapped_column(
32 | String(),
33 | nullable=False,
34 | comment="The purpose of the file - either thread, assistants, or personas.",
35 | )
36 | mime_type: Mapped[str] = mapped_column(
37 | String(), nullable=False, comment="The mime type of the file."
38 | )
39 | source: Mapped[str] = mapped_column(
40 | String(),
41 | nullable=True,
42 | comment="The source of the file. For local files, this will be the fully qualified file path plus filename and extension.",
43 | )
44 | bytes: Mapped[str] = mapped_column(
45 | Integer(),
46 | nullable=False,
47 | comment="The bytes of the file. (Added automatically when file is uploaded)",
48 | )
49 | kwargs: Mapped[JSONB] = mapped_column(
50 | JSONB(),
51 | nullable=True,
52 | comment="Any additional information to be included for the file.",
53 | )
54 | created_at: Mapped[datetime] = mapped_column(
55 | DateTime(timezone=True),
56 | default=func.now(),
57 | nullable=False,
58 | index=True,
59 | comment="Created date",
60 | )
61 | updated_at: Mapped[datetime] = mapped_column(
62 | DateTime(timezone=True),
63 | default=func.now(),
64 | onupdate=func.now(),
65 | nullable=False,
66 | comment="Last updated date",
67 | )
68 |
69 | user = relationship("User", back_populates="file")
70 |
--------------------------------------------------------------------------------
/stack/app/model/message.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | import uuid
3 | from sqlalchemy import String, text, DateTime, func, Boolean, ForeignKey
4 | from sqlalchemy.orm import Mapped, mapped_column, relationship
5 | from sqlalchemy.dialects.postgresql import UUID
6 | from stack.app.core.configuration import settings
7 | from stack.app.model.base import Base
8 |
9 |
10 | class Message(Base):
11 | __tablename__ = "messages"
12 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA}
13 |
14 | id: Mapped[uuid.UUID] = mapped_column(
15 | UUID(as_uuid=True),
16 | primary_key=True,
17 | server_default=text("gen_random_uuid()"),
18 | comment="A unique identifier for the message. It's a UUID type and is automatically generated by the database.",
19 | )
20 | thread_id: Mapped[str] = mapped_column(
21 | UUID(as_uuid=True),
22 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.threads.id"),
23 | nullable=False,
24 | index=True,
25 | comment="The ID of the thread to which this message belongs.",
26 | )
27 | user_id: Mapped[str] = mapped_column(
28 | String(),
29 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.users.user_id"),
30 | comment="The ID of the user who sent the message.",
31 | )
32 | assistant_id: Mapped[str] = mapped_column(
33 | UUID(as_uuid=True),
34 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.assistants.id"),
35 | comment="The ID of the assistant that processed the message.",
36 | )
37 | content: Mapped[str] = mapped_column(
38 | String(), comment="The content of the message."
39 | )
40 | role: Mapped[str] = mapped_column(
41 | String(), comment="The type of message (e.g., text, image, etc.)."
42 | )
43 | example: Mapped[Boolean] = mapped_column(
44 | Boolean(),
45 | nullable=True,
46 | comment="Indicates whether the message is an example message for training or demonstration purposes.",
47 | )
48 | kwargs: Mapped[str] = mapped_column(
49 | String(),
50 | nullable=True,
51 | comment="Additional arguments associated with the message.",
52 | )
53 | created_at: Mapped[datetime] = mapped_column(
54 | DateTime(timezone=True),
55 | default=func.now(),
56 | nullable=False,
57 | comment="Created date",
58 | )
59 | updated_at: Mapped[datetime] = mapped_column(
60 | DateTime(timezone=True),
61 | default=func.now(),
62 | onupdate=func.now(),
63 | nullable=False,
64 | comment="Last updated date",
65 | )
66 |
67 | thread = relationship("Thread", back_populates="message")
68 |
69 | user = relationship("User", back_populates="message")
70 |
--------------------------------------------------------------------------------
/stack/app/model/request_log.py:
--------------------------------------------------------------------------------
1 | import uuid
2 | from datetime import datetime
3 |
4 | from sqlalchemy import Integer, String, text
5 | from sqlalchemy.dialects.postgresql import JSONB
6 | from sqlalchemy.orm import Mapped, mapped_column
7 |
8 | from stack.app.core.configuration import settings
9 | from stack.app.model.base import Base
10 |
11 |
12 | from datetime import datetime
13 | import uuid
14 | from sqlalchemy import String, text, DateTime, Integer, Float
15 | from sqlalchemy.orm import Mapped, mapped_column
16 | from sqlalchemy.dialects.postgresql import UUID, JSONB
17 | from stack.app.model.base import Base
18 |
19 |
20 | class RequestLog(Base):
21 | __tablename__ = "request_logs"
22 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA}
23 |
24 | id: Mapped[uuid.UUID] = mapped_column(
25 | UUID(as_uuid=True),
26 | primary_key=True,
27 | server_default=text("gen_random_uuid()"),
28 | comment="A unique identifier for the log entry. It's a UUID type and is automatically generated by the database.",
29 | )
30 | timestamp: Mapped[datetime] = mapped_column(
31 | DateTime(timezone=True),
32 | default=datetime.utcnow,
33 | nullable=False,
34 | comment="The time when the log entry was created. Defaults to the current time.",
35 | )
36 | host: Mapped[str] = mapped_column(
37 | String(),
38 | nullable=True,
39 | comment="The host (i.e., IP address) from which the request originated.",
40 | )
41 | request_id: Mapped[str] = mapped_column(
42 | String(),
43 | nullable=True,
44 | comment="An identifier for the request, used for correlating logs.",
45 | )
46 | endpoint: Mapped[str] = mapped_column(
47 | String(), nullable=True, comment="The API endpoint that was accessed."
48 | )
49 | method: Mapped[str] = mapped_column(
50 | String(),
51 | nullable=True,
52 | comment="The HTTP method (e.g., GET, POST) used for the request.",
53 | )
54 | headers: Mapped[dict] = mapped_column(
55 | JSONB(), nullable=True, comment="The HTTP headers associated with the request."
56 | )
57 | query_parameters: Mapped[dict] = mapped_column(
58 | JSONB(), nullable=True, comment="The query parameters from the request URL."
59 | )
60 | request_body: Mapped[dict] = mapped_column(
61 | JSONB(), nullable=True, comment="The body of the request, stored as JSON."
62 | )
63 | response_body: Mapped[dict] = mapped_column(
64 | JSONB(), nullable=True, comment="The body of the response, stored as JSON."
65 | )
66 | status_code: Mapped[int] = mapped_column(
67 | Integer,
68 | nullable=True,
69 | comment="The HTTP status code returned by the API for the request.",
70 | )
71 | response_time: Mapped[float] = mapped_column(
72 | Float,
73 | nullable=True,
74 | comment="The time it took for the API to process the request.",
75 | )
76 |
--------------------------------------------------------------------------------
/stack/app/model/thread.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | import uuid
3 | from sqlalchemy import String, text, DateTime, func, ForeignKey
4 | from sqlalchemy.orm import Mapped, mapped_column, relationship
5 | from sqlalchemy.dialects.postgresql import UUID, JSONB
6 | from stack.app.core.configuration import settings
7 | from stack.app.model.base import Base
8 |
9 |
10 | class Thread(Base):
11 | __tablename__ = "threads"
12 | __table_args__ = {"schema": settings.INTERNAL_DATABASE_SCHEMA}
13 |
14 | id: Mapped[uuid.UUID] = mapped_column(
15 | UUID(as_uuid=True),
16 | primary_key=True,
17 | server_default=text("gen_random_uuid()"),
18 | comment="A unique identifier for the thread. It's a UUID type and is automatically generated by the database.",
19 | )
20 | user_id: Mapped[str] = mapped_column(
21 | String(),
22 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.users.user_id"),
23 | index=True,
24 | comment="The ID of the user who initiated the thread.",
25 | )
26 | assistant_id: Mapped[str] = mapped_column(
27 | UUID(as_uuid=True),
28 | ForeignKey(f"{settings.INTERNAL_DATABASE_SCHEMA}.assistants.id"),
29 | nullable=True,
30 | comment="The ID of the assistant that is associated with the thread.",
31 | )
32 | name: Mapped[str] = mapped_column(
33 | String(), nullable=True, comment="The title of the thread."
34 | )
35 | kwargs: Mapped[JSONB] = mapped_column(
36 | JSONB(),
37 | nullable=True,
38 | comment="Additional arguments associated with the thread.",
39 | )
40 | created_at: Mapped[datetime] = mapped_column(
41 | DateTime(timezone=True),
42 | default=func.now(),
43 | nullable=False,
44 | comment="Created date",
45 | )
46 | updated_at: Mapped[datetime] = mapped_column(
47 | DateTime(timezone=True),
48 | default=func.now(),
49 | onupdate=func.now(),
50 | nullable=False,
51 | index=True,
52 | comment="Last updated date",
53 | )
54 | # TODO: this is no longer in use, need to remove message table and relationship
55 | message = relationship(
56 | "Message",
57 | back_populates="thread",
58 | cascade="all, delete-orphan",
59 | )
60 |
61 | user = relationship("User", back_populates="thread")
62 |
63 | assistant = relationship("Assistant", back_populates="thread")
64 |
--------------------------------------------------------------------------------
/stack/app/model/util.py:
--------------------------------------------------------------------------------
1 | import string
2 | import random
3 |
4 |
5 | def generate_unique_identifier(length=16):
6 | """Generate a random string of fixed length."""
7 | return "".join(random.choices(string.ascii_letters + string.digits, k=length))
8 |
--------------------------------------------------------------------------------
/stack/app/rag/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/rag/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/rag/__init__.py
--------------------------------------------------------------------------------
/stack/app/rag/custom_retriever.py:
--------------------------------------------------------------------------------
1 | from typing import List, Dict, Any, Optional
2 |
3 | from langchain_core.retrievers import BaseRetriever
4 | from langchain_core.callbacks import (
5 | CallbackManagerForRetrieverRun,
6 | AsyncCallbackManagerForRetrieverRun,
7 | )
8 | from langchain_core.documents import Document
9 | from stack.app.schema.rag import QueryRequestPayload, BaseDocumentChunk
10 | from stack.app.rag.query import query_documents
11 | from stack.app.core.configuration import get_settings
12 |
13 |
14 | settings = get_settings()
15 |
16 |
17 | class Retriever(BaseRetriever):
18 | def __init__(
19 | self,
20 | tags: Optional[List[str]] = None,
21 | namespace: Optional[str] = None,
22 | metadata: Optional[Dict[str, Any]] = None,
23 | ):
24 | super().__init__(tags=tags, metadata=metadata)
25 | metadata = metadata or {}
26 | if namespace is not None:
27 | metadata["namespace"] = namespace
28 |
29 | def _get_relevant_documents(
30 | self, query: str, *, run_manager: CallbackManagerForRetrieverRun
31 | ) -> list[Document]:
32 | """Sync implementations for retriever."""
33 | raise NotImplementedError
34 |
35 | def _chunk_to_document(self, chunk: BaseDocumentChunk) -> Document:
36 | """Convert a BaseDocumentChunk to a langchain Document."""
37 | metadata = {"namespace": chunk.namespace, **chunk.metadata}
38 | return Document(page_content=chunk.page_content, metadata=metadata)
39 |
40 | async def _aget_relevant_documents(
41 | self, query: str, *, run_manager: AsyncCallbackManagerForRetrieverRun
42 | ) -> List[Document]:
43 | """Async implementations for retriever."""
44 | payload = QueryRequestPayload(
45 | input=query,
46 | namespace=self.metadata.get("namespace"),
47 | index_name=self.metadata.get(
48 | "index_name", settings.VECTOR_DB_COLLECTION_NAME
49 | ),
50 | vector_database=self.metadata.get("vector_database"),
51 | encoder=self.metadata.get("encoder"),
52 | enable_rerank=self.metadata.get("enable_rerank"),
53 | exclude_fields=self.metadata.get("exclude_fields"),
54 | )
55 | chunks = await query_documents(payload)
56 | return [self._chunk_to_document(chunk) for chunk in chunks]
57 |
--------------------------------------------------------------------------------
/stack/app/rag/encoders/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/rag/encoders/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/rag/encoders/__init__.py
--------------------------------------------------------------------------------
/stack/app/rag/encoders/ollama_encoder.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 | from pydantic import Field
3 | from semantic_router.encoders import BaseEncoder
4 | from langchain_community.embeddings import OllamaEmbeddings
5 | from stack.app.core.configuration import get_settings
6 |
7 | settings = get_settings()
8 |
9 |
10 | class OllamaEncoder(BaseEncoder):
11 | name: str = Field(default="all-minilm")
12 | score_threshold: float = Field(default=0.5)
13 | type: str = Field(default="ollama")
14 | dimensions: int = Field(default=384)
15 | embeddings: OllamaEmbeddings = Field(default=None)
16 |
17 | class Config:
18 | arbitrary_types_allowed = True
19 |
20 | def __init__(self, **data):
21 | super().__init__(**data)
22 | if "name" in data:
23 | self.embeddings = OllamaEmbeddings(
24 | model=self.name, base_url=settings.OLLAMA_BASE_URL
25 | )
26 |
27 | def __call__(self, docs: List[str]) -> List[List[float]]:
28 | return self.embeddings.embed_documents(docs)
29 |
--------------------------------------------------------------------------------
/stack/app/rag/ingest.py:
--------------------------------------------------------------------------------
1 | from stack.app.schema.rag import IngestRequestPayload
2 | from stack.app.rag.embedding_service import EmbeddingService
3 | from stack.app.rag.summarizer import SUMMARY_SUFFIX
4 | from stack.app.core.configuration import get_settings
5 | from stack.app.schema.file import FileSchema
6 | import structlog
7 |
8 |
9 | logger = structlog.get_logger()
10 | settings = get_settings()
11 |
12 |
13 | async def get_ingest_tasks_from_config(
14 | files_to_ingest: list[tuple[FileSchema, bytes]],
15 | config: IngestRequestPayload,
16 | ) -> list:
17 | vector_db_creds = config.vector_database
18 | document_processor_config = config.document_processor
19 | encoder = document_processor_config.encoder.get_encoder()
20 | collection_name = config.index_name
21 | namespace = (
22 | config.namespace if config.namespace else settings.VECTOR_DB_DEFAULT_NAMESPACE
23 | )
24 |
25 | embedding_service = EmbeddingService(
26 | encoder=encoder,
27 | index_name=collection_name,
28 | vector_credentials=vector_db_creds,
29 | dimensions=document_processor_config.encoder.dimensions,
30 | files=files_to_ingest,
31 | namespace=namespace,
32 | purpose=config.purpose,
33 | parser_config=document_processor_config.parser_config,
34 | )
35 |
36 | chunks = await embedding_service.generate_chunks(config=document_processor_config)
37 |
38 | summary_documents = None
39 | if document_processor_config.summarize:
40 | summary_documents = await embedding_service.generate_summary_documents(
41 | documents=chunks
42 | )
43 |
44 | tasks = [
45 | embedding_service.embed_and_upsert(
46 | chunks=chunks, encoder=encoder, index_name=collection_name
47 | ),
48 | ]
49 |
50 | if summary_documents and all(item is not None for item in summary_documents):
51 | tasks.append(
52 | embedding_service.embed_and_upsert(
53 | chunks=summary_documents,
54 | encoder=encoder,
55 | index_name=f"{collection_name}_{SUMMARY_SUFFIX}",
56 | )
57 | )
58 |
59 | return tasks
60 |
--------------------------------------------------------------------------------
/stack/app/rag/query.py:
--------------------------------------------------------------------------------
1 | import structlog
2 | from semantic_router.layer import RouteLayer
3 | from semantic_router.route import Route
4 | from semantic_router.encoders import BaseEncoder
5 |
6 | from stack.app.schema.rag import BaseDocumentChunk, QueryRequestPayload
7 | from .summarizer import SUMMARY_SUFFIX
8 | from stack.app.vectordbs import BaseVectorDatabase, get_vector_service
9 |
10 | from stack.app.core.configuration import get_settings
11 |
12 | logger = structlog.get_logger()
13 | settings = get_settings()
14 |
15 |
16 | def create_route_layer(encoder: BaseEncoder) -> RouteLayer:
17 | routes = [
18 | Route(
19 | name="summarize",
20 | utterances=[
21 | "Summmarize the following",
22 | "Could you summarize the",
23 | "Summarize",
24 | "Provide a summary of",
25 | ],
26 | score_threshold=0.5,
27 | )
28 | ]
29 | return RouteLayer(encoder=encoder, routes=routes)
30 |
31 |
32 | async def get_documents(
33 | *, vector_service: BaseVectorDatabase, payload: QueryRequestPayload
34 | ) -> list[BaseDocumentChunk]:
35 | chunks = await vector_service.query(input=payload.input, top_k=5)
36 | if not len(chunks):
37 | logger.info(f"No documents found for query: {payload.input}")
38 | return []
39 |
40 | if not payload.enable_rerank:
41 | return chunks
42 |
43 | reranked_chunks = []
44 | reranked_chunks.extend(
45 | await vector_service.rerank(query=payload.input, documents=chunks)
46 | )
47 | return reranked_chunks
48 |
49 |
50 | async def query_documents(payload: QueryRequestPayload) -> list[BaseDocumentChunk]:
51 | encoder = payload.encoder.get_encoder()
52 | rl = create_route_layer(encoder)
53 | decision = rl(payload.input).name
54 |
55 | vector_service: BaseVectorDatabase = get_vector_service(
56 | index_name=f"{payload.index_name}_{SUMMARY_SUFFIX}"
57 | if decision == "summarize"
58 | else payload.index_name,
59 | credentials=payload.vector_database,
60 | encoder=encoder,
61 | namespace=payload.namespace,
62 | )
63 | return await get_documents(vector_service=vector_service, payload=payload)
64 |
--------------------------------------------------------------------------------
/stack/app/rag/summarizer.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 | from stack.app.core.configuration import get_settings
3 | from stack.app.schema.rag import BaseDocumentChunk
4 | from stack.app.agents.llm import (
5 | get_openai_llm,
6 | get_anthropic_llm,
7 | get_google_llm,
8 | get_mixtral_fireworks,
9 | get_ollama_llm,
10 | )
11 | from langchain_core.language_models import BaseChatModel
12 | from langchain_core.output_parsers import StrOutputParser
13 | from langchain_core.prompts import ChatPromptTemplate
14 |
15 |
16 | SUMMARY_SUFFIX = "summary"
17 | settings = get_settings()
18 |
19 |
20 | class Summarizer:
21 | def __init__(self):
22 | self.llm = self._get_llm()
23 | self.prompt = ChatPromptTemplate.from_template(
24 | "Make an in-depth summary of the block of text below:\n\n"
25 | "Text:\n"
26 | "------------------------------------------\n"
27 | "{text}\n"
28 | "------------------------------------------\n\n"
29 | "Your summary:"
30 | )
31 | self.chain = self.prompt | self.llm | StrOutputParser()
32 |
33 | def _get_llm(self) -> BaseChatModel:
34 | provider = settings.SUMMARIZATION_MODEL_PROVIDER.lower()
35 | model_name = settings.SUMMARIZATION_MODEL_NAME
36 |
37 | if provider == "openai":
38 | return get_openai_llm(model=model_name)
39 | elif provider == "anthropic":
40 | return get_anthropic_llm()
41 | elif provider == "google":
42 | return get_google_llm()
43 | elif provider == "mixtral":
44 | return get_mixtral_fireworks()
45 | elif provider == "ollama":
46 | return get_ollama_llm()
47 | else:
48 | raise ValueError(f"Unsupported LLM provider: {provider}")
49 |
50 | async def summarize(self, document: BaseDocumentChunk) -> str:
51 | return await self.chain.ainvoke({"text": document.page_content})
52 |
53 |
54 | async def completion(*, document: BaseDocumentChunk) -> str:
55 | summarizer = Summarizer()
56 | return await summarizer.summarize(document)
57 |
--------------------------------------------------------------------------------
/stack/app/rag/table_parser.py:
--------------------------------------------------------------------------------
1 | from html.parser import HTMLParser
2 |
3 |
4 | class TableParser(HTMLParser):
5 | def __init__(self):
6 | super().__init__()
7 | self.in_table = False
8 | self.in_thead = False
9 | self.in_tbody = False
10 | self.in_row = False
11 | self.in_cell = False
12 | self.title_row = ""
13 | self.current_row = ""
14 | self.rows = []
15 | self.capture_next_row_as_title = True
16 |
17 | def handle_starttag(self, tag, _attrs):
18 | if tag == "table":
19 | self.in_table = True
20 | elif tag == "thead":
21 | self.in_thead = True
22 | elif tag == "tbody":
23 | self.in_tbody = True
24 | elif tag == "tr":
25 | self.in_row = True
26 | self.current_row = ""
27 | elif tag in ["td", "th"]:
28 | self.in_cell = True
29 | self.current_row += "<" + tag + ">"
30 |
31 | def handle_endtag(self, tag):
32 | if tag == "table":
33 | self.in_table = False
34 | elif tag == "thead":
35 | self.in_thead = False
36 | elif tag == "tbody":
37 | self.in_tbody = False
38 | elif tag == "tr":
39 | self.in_row = False
40 | self.current_row += ""
41 | if self.capture_next_row_as_title:
42 | self.title_row = self.current_row
43 | self.capture_next_row_as_title = False
44 | else:
45 | self.rows.append(self.current_row)
46 | elif tag in ["td", "th"]:
47 | self.in_cell = False
48 | self.current_row += "" + tag + ">"
49 |
50 | def handle_data(self, data):
51 | if self.in_cell:
52 | self.current_row += data
53 |
--------------------------------------------------------------------------------
/stack/app/rag/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/rag/tests/__init__.py
--------------------------------------------------------------------------------
/stack/app/rag/tests/unit/BUILD:
--------------------------------------------------------------------------------
1 | python_tests(
2 | name="tests",
3 | dependencies=["//:test-reqs"]
4 | )
5 |
6 | python_test_utils(
7 | name="test_utils",
8 | )
9 |
--------------------------------------------------------------------------------
/stack/app/rag/util.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | from typing import Tuple
3 |
4 | import tiktoken
5 |
6 |
7 | def get_tiktoken_length(text: str) -> int:
8 | tokenizer = tiktoken.get_encoding("cl100k_base")
9 | tokens = tokenizer.encode(text, disallowed_special=())
10 | return len(tokens)
11 |
12 |
13 | def check_content_is_useful(
14 | document_content: str,
15 | min_word_count: int = 10,
16 | max_number_ratio: float = 0.3,
17 | information_density_ratio: float = 0.5,
18 | max_density_word_count: int = 200,
19 | ) -> Tuple[bool, str]:
20 | words = document_content.split(" ")
21 | print(f"WORDS={len(words)}")
22 | if document_content == "" or not words or not len(words):
23 | return False, "No words in content"
24 |
25 | # Check min word length
26 | word_count = len(words)
27 | if word_count < min_word_count:
28 | return False, f"word_count={word_count} < threshold={min_word_count}"
29 |
30 | # Check information density
31 | density_ratio = len(set(words)) / len(words)
32 | if (
33 | word_count < max_density_word_count
34 | and density_ratio < information_density_ratio
35 | ):
36 | return (
37 | False,
38 | f"density_ratio={density_ratio} < threshold={information_density_ratio}",
39 | )
40 |
41 | # Check that this chunk is not full of useless numbers
42 | number_count = sum(word.replace(".", "").isdigit() for word in words)
43 | number_ratio = number_count / word_count if word_count > 0 else 0
44 | if number_ratio > max_number_ratio:
45 | return False, f"number_ratio={number_ratio} > threshold={max_number_ratio}"
46 |
47 | return True, "Document content passes checks."
48 |
49 |
50 | def sentence_hash(sentence):
51 | """Create a hash for a sentence, ignoring whitespace and capitalization."""
52 | return hashlib.md5(sentence.strip().lower().encode()).hexdigest()
53 |
54 |
55 | def deduplicate_chunk(chunk: str) -> str:
56 | # TODO: employ more advanced strategy here, perhaps with NLTK or Spacy
57 | if not chunk:
58 | return chunk
59 |
60 | final_chunk = ""
61 | memory = {}
62 | for sentence in chunk.split("."):
63 | # end of chunk
64 | if not sentence.strip():
65 | final_chunk += "."
66 | continue
67 | hash_value = sentence_hash(sentence.strip())
68 | if hash_value in memory:
69 | continue
70 | final_chunk += f".{sentence}" if final_chunk else sentence
71 | memory[hash_value] = 1
72 |
73 | return final_chunk
74 |
--------------------------------------------------------------------------------
/stack/app/repositories/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/repositories/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/repositories/__init__.py
--------------------------------------------------------------------------------
/stack/app/repositories/blacklist.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException, Depends
2 | from sqlalchemy import select
3 | from sqlalchemy.exc import SQLAlchemyError
4 | from stack.app.repositories.base import BaseRepository
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 | from stack.app.core.datastore import get_postgresql_session_provider
7 | from stack.app.model.blacklist import Blacklist
8 | import structlog
9 |
10 | logger = structlog.get_logger()
11 |
12 |
13 | async def get_blacklist_repository(
14 | session: AsyncSession = Depends(get_postgresql_session_provider),
15 | ):
16 | return BlacklistRepository(postgresql_session=session)
17 |
18 |
19 | class BlacklistRepository(BaseRepository):
20 | def __init__(self, postgresql_session):
21 | self.postgresql_session = postgresql_session
22 |
23 | @staticmethod
24 | def _get_retrieve_query() -> select:
25 | """A private method to construct the default query for retrieval."""
26 | return select(
27 | Blacklist.id,
28 | Blacklist.token_id,
29 | Blacklist.created_at,
30 | Blacklist.updated_at,
31 | )
32 |
33 | async def create_blacklist(self, data: Blacklist) -> Blacklist:
34 | """Creates a new blacklist in the database."""
35 | try:
36 | blacklist = await self.create(model=Blacklist, values=data)
37 | await self.postgresql_session.commit()
38 | return blacklist
39 | except SQLAlchemyError as e:
40 | await self.postgresql_session.rollback()
41 | logger.exception(
42 | f"Failed to create blacklist due to a database error.", exc_info=True
43 | )
44 | raise HTTPException(
45 | status_code=400, detail=f"Failed to create blacklist."
46 | ) from e
47 |
48 | async def retrieve_blacklist(self, token_id: str) -> Blacklist:
49 | """Retrieves a blacklist from the database by token_id."""
50 | try:
51 | query = self._get_retrieve_query()
52 | blacklist = await self.retrieve_one(query=query, object_id=token_id)
53 | # return db.query(Blacklist).filter(Blacklist.token_id == token_id).first()
54 | return blacklist
55 | except SQLAlchemyError as e:
56 | logger.exception(
57 | f"Failed to retrieve blacklist due to a database error.", exc_info=True
58 | )
59 | raise HTTPException(
60 | status_code=400, detail=f"Failed to retrieve blacklist."
61 | ) from e
62 |
--------------------------------------------------------------------------------
/stack/app/schema/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/schema/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/schema/__init__.py
--------------------------------------------------------------------------------
/stack/app/schema/auth.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import BaseModel
4 |
5 |
6 | class Auth(BaseModel):
7 | strategy: str
8 |
9 |
10 | class Login(Auth):
11 | payload: Optional[dict[str, str]] = None
12 |
13 |
14 | class Logout(BaseModel):
15 | pass
16 |
17 |
18 | class ListAuthStrategy(BaseModel):
19 | strategy: str
20 | client_id: str | None
21 | authorization_endpoint: str | None
22 | pkce_enabled: bool
23 |
24 |
25 | class JWTResponse(BaseModel):
26 | token: str
27 |
--------------------------------------------------------------------------------
/stack/app/schema/feedback.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Dict, Optional, Union
3 | from uuid import UUID
4 |
5 | from pydantic import BaseModel
6 |
7 |
8 | class BaseFeedback(BaseModel):
9 | """Shared information between create requests of feedback and feedback
10 | objects."""
11 |
12 | run_id: UUID
13 | """The associated run ID this feedback is logged for."""
14 |
15 | key: str
16 | """The metric name, tag, or aspect to provide feedback on."""
17 |
18 | score: Optional[Union[float, int, bool]] = None
19 | """Value or score to assign the run."""
20 |
21 | value: Optional[Union[float, int, bool, str, Dict]] = None
22 | """The display value for the feedback if not a metric."""
23 |
24 | comment: Optional[str] = None
25 | """Comment or explanation for the feedback."""
26 |
27 |
28 | class FeedbackCreateRequest(BaseFeedback):
29 | """Represents a request that creates feedback for an individual run."""
30 |
31 |
32 | class Feedback(BaseFeedback):
33 | """Represents feedback given on an individual run."""
34 |
35 | id: UUID
36 | """The unique ID of the feedback that was created."""
37 |
38 | created_at: datetime
39 | """The time the feedback was created."""
40 |
41 | modified_at: datetime
42 | """The time the feedback was last modified."""
43 |
44 | correction: Optional[Dict] = None
45 | """Correction for the run."""
46 |
--------------------------------------------------------------------------------
/stack/app/schema/message_types.py:
--------------------------------------------------------------------------------
1 | from typing import Any, get_args
2 |
3 | from langchain_core.messages import (
4 | AnyMessage,
5 | FunctionMessage,
6 | MessageLikeRepresentation,
7 | ToolMessage,
8 | )
9 | from langgraph.graph.message import add_messages, Messages
10 |
11 |
12 | class LiberalFunctionMessage(FunctionMessage):
13 | content: Any
14 |
15 |
16 | class LiberalToolMessage(ToolMessage):
17 | content: Any
18 |
19 |
20 | def _convert_pydantic_dict_to_message(
21 | data: MessageLikeRepresentation,
22 | ) -> MessageLikeRepresentation:
23 | if (
24 | isinstance(data, dict)
25 | and "content" in data
26 | and isinstance(data.get("type"), str)
27 | ):
28 | for cls in get_args(AnyMessage):
29 | if data["type"] == cls(content="").type:
30 | return cls(**data)
31 | return data
32 |
33 |
34 | def add_messages_liberal(left: Messages, right: Messages):
35 | # coerce to list
36 | if not isinstance(left, list):
37 | left = [left]
38 | if not isinstance(right, list):
39 | right = [right]
40 | return add_messages(
41 | [_convert_pydantic_dict_to_message(m) for m in left],
42 | [_convert_pydantic_dict_to_message(m) for m in right],
43 | )
44 |
--------------------------------------------------------------------------------
/stack/app/schema/thread.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, Field, validator
2 | import uuid
3 | from datetime import datetime
4 | from langchain.schema.messages import AnyMessage
5 | from typing import Any, Dict, List, Optional, Sequence, Union
6 |
7 |
8 | class Thread(BaseModel):
9 | id: uuid.UUID = Field(
10 | ...,
11 | description="A unique identifier for the thread. It's a UUID type and is automatically generated by the database.",
12 | )
13 | user_id: str = Field(..., description="The user id associated with the thread.")
14 | assistant_id: Optional[uuid.UUID] = Field(
15 | None, description="(Optional) The assistant id associated with the thread."
16 | )
17 | name: Optional[str] = Field(
18 | None, description="(Optional) The conversation title of the thread."
19 | )
20 | kwargs: Optional[dict] = Field(
21 | None, description="(Optional) Additional kwargs associated with the thread."
22 | )
23 | created_at: datetime = Field(..., description="Created date")
24 | updated_at: datetime = Field(..., description="Last updated date")
25 |
26 | class Config:
27 | from_attributes = True
28 | json_encoders = {
29 | datetime: lambda v: v.replace(microsecond=0).isoformat(),
30 | }
31 |
32 |
33 | class CreateThreadSchema(BaseModel):
34 | assistant_id: uuid.UUID = Field(
35 | ..., description="(Optional) The assistant id associated with the thread."
36 | )
37 | name: Optional[str] = Field(
38 | None, description="(Optional) The conversation title of the thread."
39 | )
40 | kwargs: Optional[dict] = Field(
41 | None, description="(Optional) Additional kwargs associated with the thread."
42 | )
43 |
44 |
45 | class UpdateThreadSchema(BaseModel):
46 | assistant_id: Optional[uuid.UUID] = Field(
47 | None, description="(Optional) The assistant id associated with the thread."
48 | )
49 | name: Optional[str] = Field(
50 | None, description="(Optional) The conversation title of the thread."
51 | )
52 | kwargs: Optional[dict] = Field(
53 | None, description="(Optional) Additional kwargs associated with the thread."
54 | )
55 |
56 |
57 | class GroupedThreads(BaseModel):
58 | """Grouped threads by time period."""
59 |
60 | Today: Optional[List[Thread]] = Field(None, alias="Today")
61 | Yesterday: Optional[List[Thread]] = Field(None, alias="Yesterday")
62 | Past_7_Days: Optional[List[Thread]] = Field(None, alias="Past 7 Days")
63 | Past_30_Days: Optional[List[Thread]] = Field(None, alias="Past 30 Days")
64 | This_Year: Optional[List[Thread]] = Field(None, alias="This Year")
65 | Previous_Years: Optional[List[Thread]] = Field(None, alias="Previous Years")
66 |
67 | class Config:
68 | populate_by_name = True
69 | json_encoders = {
70 | datetime: lambda v: v.replace(microsecond=0).isoformat(),
71 | }
72 |
73 |
74 | class ThreadPostRequest(BaseModel):
75 | """Payload for adding state to a thread."""
76 |
77 | values: Union[Sequence[AnyMessage], Dict[str, Any]] = Field(
78 | ...,
79 | description="The state values to add to the thread. It can be a list of messages or a dictionary.",
80 | )
81 | config: Optional[Dict[str, Any]] = Field(
82 | None,
83 | description="The configuration values to add to the thread. It can be a dictionary.",
84 | )
85 |
--------------------------------------------------------------------------------
/stack/app/schema/title.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, Field
2 | from typing import List, Optional
3 |
4 |
5 | class Source(BaseModel):
6 | url: str
7 | title: Optional[str]
8 |
9 |
10 | class Metadata(BaseModel):
11 | run_id: Optional[str]
12 | sources: Optional[List[Source]]
13 |
14 |
15 | class ChatMessage(BaseModel):
16 | content: str
17 | type: str
18 |
19 |
20 | class TitleRequest(BaseModel):
21 | thread_id: str = Field(
22 | ..., description="The id of the thread to generate the title for."
23 | )
24 | history: Optional[List[ChatMessage]] = Field(
25 | None,
26 | description="(Optional) The conversation history of the thread. This is used as context for the model when generating the title.",
27 | )
28 |
--------------------------------------------------------------------------------
/stack/app/schema/user.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from pydantic import BaseModel, Field
3 | from typing import Optional
4 |
5 |
6 | class UserBase(BaseModel):
7 | """Base model for user data."""
8 |
9 | username: Optional[str] = Field(
10 | None,
11 | description="(Optional) The username chosen by the user. It's a string type and does not need to be unique across the userbase.",
12 | )
13 | email: Optional[str] = Field(
14 | None,
15 | description="(Optional) The email address associated with the user's account. It's a string type and is expected to be unique across the userbase",
16 | )
17 | first_name: Optional[str] = Field(
18 | None, description="(Optional) The first name of the user."
19 | )
20 | last_name: Optional[str] = Field(
21 | None, description="(Optional) The last name of the user."
22 | )
23 | kwargs: Optional[dict] = Field(
24 | None, description="(Optional) Additional kwargs associated with the user."
25 | )
26 |
27 | class Config:
28 | from_attributes = True
29 |
30 |
31 | class User(UserBase):
32 | """Model representing a registered user in the application."""
33 |
34 | user_id: str = Field(
35 | ...,
36 | description="Unique identifier for the user to be used across the application. Set once when the user is created and cannot be updated thereafter. Can be used for correlating local user with external systems. Autogenerates if none is provided.",
37 | )
38 | created_at: datetime = Field(..., description="Created date")
39 | updated_at: datetime = Field(..., description="Last updated date")
40 |
41 | class Config:
42 | from_attributes = True
43 | json_encoders = {
44 | datetime: lambda v: v.replace(microsecond=0).isoformat(),
45 | }
46 |
47 |
48 | class CreateUpdateUserSchema(UserBase):
49 | password: Optional[str] = Field(
50 | None, description="(Optional) Password for the new user account."
51 | )
52 | user_id: Optional[str] = Field(
53 | None,
54 | description="Identifier for the user to be used across the application. Can be used for correlating local user with external systems. Autogenerates if none is provided.",
55 | )
56 |
--------------------------------------------------------------------------------
/stack/app/start.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | # Start the server
4 | os.system("uvicorn main:app --reload --port 9000")
5 |
--------------------------------------------------------------------------------
/stack/app/utils/BUILD:
--------------------------------------------------------------------------------
1 | python_sources(name="lib", dependencies=["//:reqs#beautifulsoup4"])
2 |
--------------------------------------------------------------------------------
/stack/app/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/utils/__init__.py
--------------------------------------------------------------------------------
/stack/app/utils/exceptions.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException
2 |
3 |
4 | class UniqueConstraintViolationError(HTTPException):
5 | def __init__(self, field: str):
6 | super().__init__(status_code=400, detail=f"{field} already exists")
7 | self.field = field
8 |
--------------------------------------------------------------------------------
/stack/app/utils/format_docs.py:
--------------------------------------------------------------------------------
1 | from typing import Sequence
2 | from langchain.schema import Document
3 |
4 |
5 | def format_docs(docs: Sequence[Document]) -> str:
6 | formatted_docs = []
7 | for i, doc in enumerate(docs):
8 | doc_string = f"{doc.page_content}"
9 | formatted_docs.append(doc_string)
10 | return "\n".join(formatted_docs)
11 |
--------------------------------------------------------------------------------
/stack/app/utils/group_threads.py:
--------------------------------------------------------------------------------
1 | from typing import List, Optional
2 | from stack.app.schema.thread import Thread, GroupedThreads
3 | from datetime import datetime, timedelta, timezone
4 |
5 |
6 | def group_threads(threads: List[Thread], client_tz_offset: int = 0) -> GroupedThreads:
7 | grouped = {
8 | "Today": [],
9 | "Yesterday": [],
10 | "Past 7 Days": [],
11 | "Past 30 Days": [],
12 | "This Year": [],
13 | "Previous Years": [],
14 | }
15 |
16 | # Adjusting for the client's timezone offset
17 | client_tz = timezone(timedelta(minutes=client_tz_offset))
18 | now_utc = datetime.now(timezone.utc)
19 | now_in_user_tz = now_utc.astimezone(client_tz)
20 |
21 | def to_user_tz(dt: datetime) -> datetime:
22 | if dt.tzinfo is None:
23 | dt = dt.replace(tzinfo=timezone.utc)
24 | return dt.astimezone(client_tz)
25 |
26 | for thread in threads:
27 | updated_at_user_tz = to_user_tz(thread.updated_at)
28 | updated_at_date = updated_at_user_tz.date()
29 |
30 | if updated_at_date == now_in_user_tz.date():
31 | grouped["Today"].append(thread)
32 | elif updated_at_date == (now_in_user_tz - timedelta(days=1)).date():
33 | grouped["Yesterday"].append(thread)
34 | elif (
35 | (now_in_user_tz - timedelta(days=7)).date()
36 | <= updated_at_date
37 | < (now_in_user_tz - timedelta(days=1)).date()
38 | ):
39 | grouped["Past 7 Days"].append(thread)
40 | elif (
41 | (now_in_user_tz - timedelta(days=30)).date()
42 | <= updated_at_date
43 | < (now_in_user_tz - timedelta(days=7)).date()
44 | ):
45 | grouped["Past 30 Days"].append(thread)
46 | elif updated_at_user_tz.year == now_in_user_tz.year:
47 | grouped["This Year"].append(thread)
48 | else:
49 | grouped["Previous Years"].append(thread)
50 |
51 | # Sorting each category by most recent first
52 | for category in grouped:
53 | grouped[category] = sorted(
54 | grouped[category], key=lambda x: x.updated_at, reverse=True
55 | )
56 |
57 | return grouped
58 |
--------------------------------------------------------------------------------
/stack/app/utils/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/app/utils/tests/__init__.py
--------------------------------------------------------------------------------
/stack/app/utils/tests/unit/BUILD:
--------------------------------------------------------------------------------
1 | python_tests(
2 | name="tests",
3 | dependencies=["//:test-reqs"]
4 | )
5 |
--------------------------------------------------------------------------------
/stack/app/utils/tests/unit/test_format_docs.py:
--------------------------------------------------------------------------------
1 | def test__format_docs__handles_no_documents():
2 | assert True
3 |
--------------------------------------------------------------------------------
/stack/app/vectordbs/BUILD:
--------------------------------------------------------------------------------
1 | python_sources()
2 |
--------------------------------------------------------------------------------
/stack/app/vectordbs/__init__.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 | from dotenv import load_dotenv
3 | from semantic_router.encoders import BaseEncoder
4 | from stack.app.schema.rag import (
5 | VectorDatabase,
6 | VectorDatabaseType,
7 | EncoderConfig,
8 | EncoderProvider,
9 | )
10 | from stack.app.vectordbs.base import BaseVectorDatabase
11 | from stack.app.vectordbs.qdrant import QdrantService
12 | from stack.app.core.configuration import get_settings
13 |
14 | load_dotenv()
15 | settings = get_settings()
16 |
17 |
18 | def get_vector_service(
19 | *,
20 | index_name: str = settings.VECTOR_DB_COLLECTION_NAME,
21 | namespace: Optional[str] = settings.VECTOR_DB_DEFAULT_NAMESPACE,
22 | credentials: Optional[VectorDatabase] = VectorDatabase(),
23 | encoder_provider: Optional[EncoderProvider] = EncoderProvider(
24 | settings.VECTOR_DB_ENCODER_NAME
25 | ),
26 | encoder: Optional[BaseEncoder] = None,
27 | dimensions: Optional[int] = settings.VECTOR_DB_ENCODER_DIMENSIONS,
28 | enable_rerank: Optional[bool] = settings.ENABLE_RERANK_BY_DEFAULT,
29 | ) -> BaseVectorDatabase:
30 | services = {
31 | VectorDatabaseType.qdrant: QdrantService,
32 | # Add other providers here
33 | }
34 |
35 | vector_db = credentials or VectorDatabase()
36 | # Convert string to enum if necessary
37 | db_type = (
38 | VectorDatabaseType(vector_db.type)
39 | if isinstance(vector_db.type, str)
40 | else vector_db.type
41 | )
42 |
43 | service = services.get(db_type)
44 | if service is None:
45 | raise ValueError(f"Unsupported provider: {db_type}")
46 |
47 | encoder_config = EncoderConfig.get_encoder_config(encoder_provider)
48 | if encoder_config is None:
49 | raise ValueError(f"Unsupported encoder provider: {encoder_provider}")
50 |
51 | if encoder is None:
52 | encoder_class = encoder_config["class"]
53 | if not issubclass(encoder_class, BaseEncoder):
54 | raise ValueError(
55 | f"Encoder class {encoder_class} is not a subclass of BaseEncoder"
56 | )
57 | encoder: BaseEncoder = encoder_class()
58 |
59 | return service(
60 | index_name=index_name,
61 | dimension=dimensions,
62 | credentials=dict(credentials.config),
63 | encoder=encoder,
64 | enable_rerank=enable_rerank,
65 | namespace=namespace,
66 | )
67 |
--------------------------------------------------------------------------------
/stack/app/vectordbs/base.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 | from semantic_router.encoders import BaseEncoder
4 | from tqdm import tqdm
5 |
6 | from stack.app.schema.rag import DeleteDocumentsResponse, BaseDocumentChunk
7 | import structlog
8 | from stack.app.core.configuration import get_settings
9 |
10 | logger = structlog.get_logger()
11 |
12 | settings = get_settings()
13 |
14 |
15 | class BaseVectorDatabase(ABC):
16 | def __init__(
17 | self,
18 | index_name: str,
19 | dimension: int,
20 | credentials: dict,
21 | encoder: BaseEncoder,
22 | enable_rerank: bool,
23 | namespace: str,
24 | ):
25 | self.index_name = index_name
26 | self.dimension = dimension
27 | self.credentials = credentials
28 | self.encoder = encoder
29 | self.enable_rerank = enable_rerank
30 | self.namespace = namespace
31 |
32 | @abstractmethod
33 | async def upsert(self, chunks: list[BaseDocumentChunk]):
34 | pass
35 |
36 | @abstractmethod
37 | async def query(self, input: str, top_k: int = 25) -> list[BaseDocumentChunk]:
38 | pass
39 |
40 | @abstractmethod
41 | async def delete(
42 | self, file_id: str, assistant_id: str = None
43 | ) -> DeleteDocumentsResponse:
44 | pass
45 |
46 | async def _generate_vectors(self, input: str) -> list[list[float]]:
47 | return self.encoder([input])
48 |
49 | async def rerank(
50 | self, query: str, documents: list[BaseDocumentChunk], top_n: int = 5
51 | ) -> list[BaseDocumentChunk]:
52 | if not self.enable_rerank:
53 | return documents
54 |
55 | from cohere import Client
56 |
57 | api_key = settings.COHERE_API_KEY
58 | if not api_key:
59 | raise ValueError("API key for Cohere is not present.")
60 | cohere_client = Client(api_key=api_key)
61 |
62 | # Avoid duplications, TODO: fix ingestion for duplications
63 | # Deduplicate documents based on content while preserving order
64 | seen = set()
65 | deduplicated_documents = [
66 | doc
67 | for doc in documents
68 | if doc.page_content not in seen and not seen.add(doc.page_content)
69 | ]
70 | docs_text = list(
71 | doc.page_content
72 | for doc in tqdm(
73 | deduplicated_documents,
74 | desc=f"Reranking {len(deduplicated_documents)} documents",
75 | )
76 | )
77 | try:
78 | re_ranked = cohere_client.rerank(
79 | model="rerank-multilingual-v2.0",
80 | query=query,
81 | documents=docs_text,
82 | top_n=top_n,
83 | ).results
84 | results = []
85 | for r in tqdm(re_ranked, desc="Processing reranked results "):
86 | doc = deduplicated_documents[r.index]
87 | results.append(doc)
88 | return results
89 | except Exception as e:
90 | logger.error(f"Error while reranking: {e}")
91 | raise Exception(f"Error while reranking: {e}")
92 |
--------------------------------------------------------------------------------
/stack/dev.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo Starting server...
4 | uvicorn app.main:app --reload --port 9000
5 |
--------------------------------------------------------------------------------
/stack/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/tests/__init__.py
--------------------------------------------------------------------------------
/stack/tests/unit/BUILD:
--------------------------------------------------------------------------------
1 | python_tests(
2 | name="tests",
3 | dependencies=["//:test-reqs"]
4 | )
5 |
6 | python_test_utils(
7 | name="test_utils",
8 | )
9 |
--------------------------------------------------------------------------------
/stack/tests/unit/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/stack/tests/unit/__init__.py
--------------------------------------------------------------------------------
/stack/tests/unit/test_app.py:
--------------------------------------------------------------------------------
1 | from starlette.testclient import TestClient
2 |
3 | from stack.app.app_factory import create_app
4 | from stack.app.core.configuration import Settings
5 |
6 | app = create_app(Settings())
7 | client = TestClient(app)
8 |
9 |
10 | async def test__app__healthcheck():
11 | response = client.get("/api/v1/health_check")
12 | assert response.status_code == 200
13 |
--------------------------------------------------------------------------------
/stack/tests/unit/test_app_auth.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch, MagicMock
2 |
3 | from starlette.testclient import TestClient
4 |
5 | from stack.app.app_factory import create_app
6 | from stack.app.core.configuration import Settings
7 | from stack.app.core.auth.auth_config import ENABLED_AUTH_STRATEGY_MAPPING
8 | from stack.tests.unit.conftest import passthrough
9 |
10 | app = create_app(Settings())
11 | client = TestClient(app)
12 |
13 |
14 | async def test__list_auth_strategies__responds_correctly():
15 | # Arrange
16 | mock_strategies = {
17 | "Basic": MagicMock(
18 | get_client_id=lambda: None,
19 | get_authorization_endpoint=lambda: None,
20 | get_pkce_enabled=lambda: False,
21 | ),
22 | "Google": MagicMock(
23 | get_client_id=lambda: "google_client_id",
24 | get_authorization_endpoint=lambda: "https://accounts.google.com/o/oauth2/v2/auth",
25 | get_pkce_enabled=lambda: True,
26 | ),
27 | "OIDC": MagicMock(
28 | get_client_id=lambda: "oidc_client_id",
29 | get_authorization_endpoint=lambda: "https://example.com/auth",
30 | get_pkce_enabled=lambda: True,
31 | ),
32 | }
33 |
34 | with patch.dict(ENABLED_AUTH_STRATEGY_MAPPING, mock_strategies):
35 | # Act
36 | response = client.get("/api/v1/auth/auth_strategies")
37 |
38 | # Assert
39 | assert response.status_code == 200
40 | data = response.json()
41 | assert len(data) == 3
42 |
43 | expected_strategies = ["Basic", "Google", "OIDC"]
44 | for strategy in data:
45 | assert strategy["strategy"] in expected_strategies
46 | assert "client_id" in strategy
47 | assert "authorization_endpoint" in strategy
48 | assert "pkce_enabled" in strategy
49 |
50 | basic_strategy = next(s for s in data if s["strategy"] == "Basic")
51 | assert basic_strategy["client_id"] is None
52 | assert basic_strategy["authorization_endpoint"] is None
53 | assert basic_strategy["pkce_enabled"] is False
54 |
55 | google_strategy = next(s for s in data if s["strategy"] == "Google")
56 | assert google_strategy["client_id"] == "google_client_id"
57 | assert (
58 | google_strategy["authorization_endpoint"]
59 | == "https://accounts.google.com/o/oauth2/v2/auth"
60 | )
61 | assert google_strategy["pkce_enabled"] is True
62 |
63 | oidc_strategy = next(s for s in data if s["strategy"] == "OIDC")
64 | assert oidc_strategy["client_id"] == "oidc_client_id"
65 | assert oidc_strategy["authorization_endpoint"] == "https://example.com/auth"
66 | assert oidc_strategy["pkce_enabled"] is True
67 |
68 |
69 | async def test__list_auth_strategies__no_strategies():
70 | # Arrange
71 | with patch.dict(ENABLED_AUTH_STRATEGY_MAPPING, {}, clear=True):
72 | # Act
73 | response = client.get("/api/v1/auth/auth_strategies")
74 |
75 | # Assert
76 | assert response.status_code == 200
77 | data = response.json()
78 | assert len(data) == 0
79 |
--------------------------------------------------------------------------------
/stack/tests/unit/test_format_docs.py:
--------------------------------------------------------------------------------
1 | def test__example():
2 | assert True
3 |
--------------------------------------------------------------------------------
/ui/.env.local.example:
--------------------------------------------------------------------------------
1 | # NEXT_PUBLIC_PERSONAFLOW_API_KEY=
2 | # NEXT_PUBLIC_BASE_API_URL=http://localhost:9000
--------------------------------------------------------------------------------
/ui/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "prettier"]
3 | }
4 |
--------------------------------------------------------------------------------
/ui/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/ui/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npx lint-staged
2 |
--------------------------------------------------------------------------------
/ui/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 |
--------------------------------------------------------------------------------
/ui/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "semi": false,
4 | "printWidth": 110,
5 | "useTabs": false
6 | }
7 |
--------------------------------------------------------------------------------
/ui/assets/PersonaFlowIcon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/ui/assets/PersonaFlowIcon-512.png
--------------------------------------------------------------------------------
/ui/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/styles/main.css",
9 | "baseColor": "stone",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/ui/jest.setup.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom'
2 |
--------------------------------------------------------------------------------
/ui/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {}
3 |
4 | export default nextConfig
5 |
--------------------------------------------------------------------------------
/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ui",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev -p 4000",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "test": "jest",
11 | "test:ci": "jest --ci --coverage",
12 | "prepare": "husky",
13 | "format:check": "prettier --check ."
14 | },
15 | "dependencies": {
16 | "@hookform/resolvers": "^3.4.2",
17 | "@microsoft/fetch-event-source": "^2.0.1",
18 | "@radix-ui/react-accordion": "^1.1.2",
19 | "@radix-ui/react-checkbox": "^1.0.4",
20 | "@radix-ui/react-dialog": "^1.0.5",
21 | "@radix-ui/react-icons": "^1.3.0",
22 | "@radix-ui/react-label": "^2.0.2",
23 | "@radix-ui/react-select": "^2.0.0",
24 | "@radix-ui/react-slot": "^1.0.2",
25 | "@radix-ui/react-switch": "^1.0.3",
26 | "@radix-ui/react-tabs": "^1.0.4",
27 | "@radix-ui/react-toast": "^1.1.5",
28 | "@radix-ui/react-tooltip": "^1.1.2",
29 | "@tanstack/react-query": "^5.36.2",
30 | "@tanstack/react-query-devtools": "^5.45.1",
31 | "axios": "^1.7.4",
32 | "class-variance-authority": "^0.7.0",
33 | "clsx": "^2.1.1",
34 | "cmdk": "^1.0.0",
35 | "jotai": "^2.9.1",
36 | "lucide-react": "^0.381.0",
37 | "next": "14.2.3",
38 | "react": "^18.2.0",
39 | "react-dom": "^18.2.0",
40 | "react-hook-form": "^7.51.5",
41 | "react-markdown": "^9.0.1",
42 | "remark": "^15.0.1",
43 | "remark-gfm": "^4.0.0",
44 | "remark-math": "^6.0.0",
45 | "tailwind-merge": "^2.3.0",
46 | "tailwindcss-animate": "^1.0.7",
47 | "zod": "^3.23.8"
48 | },
49 | "devDependencies": {
50 | "@tailwindcss/typography": "^0.5.13",
51 | "@tanstack/eslint-plugin-query": "^5.35.6",
52 | "@testing-library/jest-dom": "^6.4.6",
53 | "@testing-library/react": "^16.0.0",
54 | "@types/jest": "^29.5.12",
55 | "@types/node": "^20",
56 | "@types/react": "^18.3.3",
57 | "@types/react-dom": "^18.3.0",
58 | "eslint": "^8",
59 | "eslint-config-next": "14.2.3",
60 | "eslint-config-prettier": "^9.1.0",
61 | "husky": "^9.1.6",
62 | "jest": "^29.7.0",
63 | "jest-environment-jsdom": "^29.7.0",
64 | "lint-staged": "^15.2.10",
65 | "postcss": "^8",
66 | "prettier": "3.2.5",
67 | "tailwindcss": "^3.4.1",
68 | "ts-node": "^10.9.2",
69 | "typescript": "^5"
70 | },
71 | "lint-staged": {
72 | "**/*": "prettier --write --ignore-unknown"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/ui/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | }
7 |
8 | export default config
9 |
--------------------------------------------------------------------------------
/ui/src/app/[[...slug]]/page.tsx:
--------------------------------------------------------------------------------
1 | import ChatPanel from '@/components/features/chat-panel/chat-panel'
2 | import { isValidParam } from '@/utils/routeUtils'
3 | import { notFound } from 'next/navigation'
4 |
5 | export default function ConversationPage({ params }: { params: { slug?: string[] } }) {
6 | if (!isValidParam(params.slug)) return notFound()
7 |
8 | return (
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/ui/src/app/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/ui/src/app/icon.ico
--------------------------------------------------------------------------------
/ui/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 | import { Inter } from 'next/font/google'
3 | import '@/styles/main.css'
4 | import Providers from '@/providers/Providers'
5 | import { Toaster } from '@/components/ui/toaster'
6 | import BuildPanel from '@/components/features/build-panel/components/build-panel'
7 | import Navbar from '@/components/features/navbar/components/navbar'
8 | import Header from '@/components/features/header/components/header'
9 |
10 | const inter = Inter({ subsets: ['latin'] })
11 |
12 | export const metadata: Metadata = {
13 | title: 'PersonaFlow',
14 | description: 'Where AI meets individuality for unmatched personalization',
15 | icons: {
16 | icon: '/icon.ico',
17 | },
18 | }
19 |
20 | export default function RootLayout({
21 | children,
22 | }: Readonly<{
23 | children: React.ReactNode
24 | }>) {
25 | return (
26 |
27 |
28 |
31 |
32 |
33 |
34 |
35 | {children}
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/ui/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | const Page = () => {
2 | return (
3 |
4 |
Page not found.
5 |
6 | )
7 | }
8 |
9 | export default Page
10 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/__tests__/assistant-builder.spec.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react'
2 | import { AssistantBuilder } from '../assistant-builder'
3 | import { useRouter } from 'next/navigation'
4 | import { useAssistants } from '@/data-provider/query-service'
5 | import { useSlugRoutes } from '@/hooks/useSlugParams'
6 |
7 | jest.mock('../edit-assistant', () => {
8 | return {
9 | EditAssistant: jest.fn(() => EditAssistant
),
10 | }
11 | })
12 | jest.mock('../create-assistant', () => {
13 | return {
14 | CreateAssistant: jest.fn(() => CreateAssistant
),
15 | }
16 | })
17 |
18 | jest.mock('next/navigation', () => {
19 | return {
20 | useRouter: jest.fn(),
21 | }
22 | })
23 |
24 | jest.mock('@/data-provider/query-service', () => {
25 | return {
26 | useAssistants: jest.fn(),
27 | }
28 | })
29 |
30 | jest.mock('@/hooks/useSlugParams', () => {
31 | return {
32 | useSlugRoutes: jest.fn(),
33 | }
34 | })
35 |
36 | test('should render', () => {
37 | ;(useAssistants as jest.Mock).mockReturnValue({ data: [{ id: '1' }] })
38 | ;(useRouter as jest.Mock).mockReturnValue([])
39 | ;(useSlugRoutes as jest.Mock).mockReturnValue({ assistantId: '1' })
40 |
41 | render()
42 |
43 | expect(screen.getByText('EditAssistant')).toBeInTheDocument()
44 | expect(screen.queryByText('CreateAssistant')).toBeNull()
45 | })
46 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/__tests__/assistant-form.spec.tsx:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/react'
2 |
3 | test.todo('implement')
4 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/assistant-builder.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { EditAssistant } from './edit-assistant'
4 | import { CreateAssistant } from './create-assistant'
5 | import { useSlugRoutes } from '@/hooks/useSlugParams'
6 |
7 | export function AssistantBuilder() {
8 | const { assistantId } = useSlugRoutes()
9 |
10 | return <>{assistantId ? : }>
11 | }
12 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/assistant-selector.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
4 | import Spinner from '@/components/ui/spinner'
5 | import { useAssistants } from '@/data-provider/query-service'
6 | import { useSlugRoutes } from '@/hooks/useSlugParams'
7 | import { useRouter } from 'next/navigation'
8 |
9 | export function AssistantSelector() {
10 | const { assistantId } = useSlugRoutes()
11 | const { data: assistantsData, isLoading } = useAssistants()
12 | const router = useRouter()
13 |
14 | if (isLoading || !assistantsData) return
15 |
16 | const handleValueChange = (selectedAssistantId: string) => {
17 | router.push(`/a/${selectedAssistantId}`)
18 | }
19 | const selectedAssistant = assistantsData.find((assistant) => assistant.id === assistantId)
20 |
21 | return (
22 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/build-panel.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useState } from 'react'
3 | import { ChevronLeft, ChevronRight } from 'lucide-react'
4 | import { AssistantBuilder } from './assistant-builder'
5 |
6 | export default function BuildPanel() {
7 | const [isOpen, setIsOpen] = useState(true)
8 |
9 | const drawerStyles = {
10 | open: 'p-4 h-full flex flex-col gap-4 overflow-x-hidden sm:min-w-[520px]',
11 | closed: 'hidden',
12 | }
13 |
14 | return (
15 |
16 |
17 | {isOpen ? (
18 | setIsOpen((prev) => !prev)} />
19 | ) : (
20 | setIsOpen((prev) => !prev)} />
21 | )}
22 |
23 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/file-builder.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useFileStream } from '@/hooks/useFileStream'
4 | import FilesDialog from './files-dialog'
5 | import SelectFiles from './select-files'
6 | import Spinner from '@/components/ui/spinner'
7 |
8 | export default function FileBuilder() {
9 | const { startProgressStream, progressStream } = useFileStream()
10 | return (
11 |
12 |
13 |
14 |
15 | {progressStream?.status === 'error' &&
Something went wrong when ingesting files.
}
16 | {progressStream?.status === 'inflight' &&
}
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/form-select.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
4 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
5 | import { UseFormReturn } from 'react-hook-form'
6 |
7 | type TFormSelectProps = {
8 | form: UseFormReturn
9 | title: string
10 | formName: string
11 | options: string[]
12 | placeholder: string
13 | }
14 |
15 | export function FormSelect({ form, title, options, formName, placeholder }: TFormSelectProps) {
16 | return (
17 | (
21 |
22 | {title}
23 |
24 |
36 |
37 |
38 | )}
39 | />
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/public-switch.tsx:
--------------------------------------------------------------------------------
1 | import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
2 | import { Switch } from '@/components/ui/switch'
3 | import { UseFormReturn } from 'react-hook-form'
4 |
5 | type TPublicSwitchProps = {
6 | form: UseFormReturn
7 | }
8 |
9 | export default function PublicSwitch({ form }: TPublicSwitchProps) {
10 | return (
11 | {
15 | return (
16 |
17 | Public
18 |
19 | field.onChange(checked)} />
20 |
21 |
22 | )
23 | }}
24 | />
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/retrieval-description.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { FormField, FormItem, FormLabel } from '@/components/ui/form'
4 | import { Textarea } from '@/components/ui/textarea'
5 | import { UseFormReturn } from 'react-hook-form'
6 |
7 | type TRetrievalInstructionsProps = {
8 | form: UseFormReturn
9 | }
10 |
11 | export function RetrievalInstructions({ form }: TRetrievalInstructionsProps) {
12 | return (
13 | {
17 | return (
18 |
19 | Retrieval Instructions
20 |
21 |
22 | )
23 | }}
24 | />
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/select-actions.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
3 | import { Checkbox } from '@/components/ui/checkbox'
4 | import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
5 | import { UseFormReturn } from 'react-hook-form'
6 |
7 | const actions = [{}]
8 |
9 | type TSelectActionsProps = {
10 | form: UseFormReturn
11 | }
12 |
13 | export default function SelectActions({ form }: TSelectActionsProps) {
14 | return (
15 |
16 |
17 | Actions
18 |
19 | {/* {actions.map((action) => (
20 | {
25 | return (
26 |
27 |
28 | {
31 | return checked
32 | ? field.onChange([...field.value, action.value])
33 | : field.onChange(
34 | field.value?.filter(
35 | (value: string) => value !== action.value,
36 | ),
37 | );
38 | }}
39 | />
40 |
41 |
42 | {action.display}
43 |
44 |
45 | );
46 | }}
47 | />
48 | ))} */}
49 |
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/select-capabilities.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
3 | import { Checkbox } from '@/components/ui/checkbox'
4 | import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
5 | import { TTool } from '@/data-provider/types'
6 | import { useAvailableTools } from '@/hooks/useAvailableTools'
7 | import { UseFormReturn } from 'react-hook-form'
8 |
9 | type TSelectCapabilitiesProps = {
10 | form: UseFormReturn
11 | }
12 |
13 | const options = [
14 | 'retrieval',
15 | // { display: "Code interpretor", value: "Code interpretor" },
16 | ]
17 |
18 | export default function SelectCapabilities({ form }: TSelectCapabilitiesProps) {
19 | const { availableTools } = useAvailableTools()
20 | const { type: architectureType } = form.getValues().config.configurable
21 |
22 | const capabilities = availableTools?.filter((tool) => options.includes(tool.type))
23 |
24 | const isChatRetrieval = (checkboxValue: string) =>
25 | architectureType === 'chat_retrieval' && checkboxValue === 'retrieval'
26 |
27 | return (
28 |
29 |
30 | Capabilities
31 |
32 | {capabilities?.map((capability) => (
33 | {
38 | return (
39 |
40 |
41 | selection.type === capability.type) ||
45 | isChatRetrieval(capability.type)
46 | }
47 | onCheckedChange={(checked) => {
48 | return checked
49 | ? field.onChange([...field.value, capability])
50 | : field.onChange(
51 | field.value?.filter((selection: TTool) => selection.type !== capability.type),
52 | )
53 | }}
54 | />
55 |
56 | {capability.name}
57 |
58 | )
59 | }}
60 | />
61 | ))}
62 |
63 |
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/select-files.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from '@/components/ui/badge'
2 | import { FormControl, FormField, FormItem } from '@/components/ui/form'
3 | import Spinner from '@/components/ui/spinner'
4 | import { useToast } from '@/components/ui/use-toast'
5 | import {
6 | useAssistantFiles,
7 | useDeleteAssistantFile,
8 | useDeleteFile,
9 | useFiles,
10 | } from '@/data-provider/query-service'
11 | import { useSlugRoutes } from '@/hooks/useSlugParams'
12 | import { CircleX } from 'lucide-react'
13 | import { useEffect, useState } from 'react'
14 | import { UseFormReturn, useFieldArray } from 'react-hook-form'
15 |
16 | type TBadgeValue = {
17 | label: string
18 | value: string
19 | }
20 |
21 | export default function SelectFiles() {
22 | const deleteFile = useDeleteAssistantFile()
23 |
24 | const { assistantId } = useSlugRoutes()
25 |
26 | const { toast } = useToast()
27 |
28 | const { data: assistantFiles, isLoading } = useAssistantFiles(assistantId as string)
29 |
30 | const badgeValues = assistantFiles?.map((assistantFile) => {
31 | return { label: assistantFile.filename, value: assistantFile.id }
32 | })
33 |
34 | const handleClick = ({ value: fileId }: TBadgeValue) => {
35 | deleteFile.mutate(
36 | { assistantId: assistantId as string, fileId },
37 | {
38 | onSuccess: () =>
39 | toast({
40 | variant: 'default',
41 | title: 'File has been deleted.',
42 | }),
43 | },
44 | )
45 | }
46 |
47 | if (isLoading) return
48 |
49 | return (
50 |
51 | {badgeValues?.map((badgeValue: TBadgeValue, index: number) => {
52 | return (
53 |
handleClick(badgeValue)}
58 | >
59 |
60 | {badgeValue.label}
61 |
62 |
63 |
64 |
65 |
66 | )
67 | })}
68 |
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/select-options.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
3 | import { Checkbox } from '@/components/ui/checkbox'
4 | import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
5 | import { UseFormReturn } from 'react-hook-form'
6 |
7 | type TSelectOptionsProps = {
8 | form: UseFormReturn
9 | }
10 |
11 | const options = [
12 | {
13 | display: 'Interrupt before action',
14 | value: 'interrupt_before_action',
15 | name: 'config.configurable.interrupt_before_action',
16 | },
17 | ]
18 |
19 | export default function SelectOptions({ form }: TSelectOptionsProps) {
20 | return (
21 |
22 |
23 | Options
24 |
25 | {options.map((option) => (
26 | {
31 | return (
32 |
33 |
34 | field.onChange(!field.value)} />
35 |
36 | {option.display}
37 |
38 | )
39 | }}
40 | />
41 | ))}
42 |
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/select-tools.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
3 | import { FormControl, FormField, FormItem } from '@/components/ui/form'
4 | import { UseFormReturn, useFieldArray } from 'react-hook-form'
5 | import { ToolDialog } from './tool-dialog'
6 | import { CircleX } from 'lucide-react'
7 | import { Badge } from '@/components/ui/badge'
8 | import { TTool } from '@/data-provider/types'
9 |
10 | type TSelectToolsProps = {
11 | form: UseFormReturn
12 | }
13 |
14 | export default function SelectTools({ form }: TSelectToolsProps) {
15 | const { remove } = useFieldArray({
16 | name: 'config.configurable.tools',
17 | control: form.control,
18 | })
19 |
20 | const { tools } = form.getValues().config.configurable
21 |
22 | return (
23 |
24 |
25 | Tools
26 |
27 |
28 | {tools.map((tool: TTool, index: number) => {
29 | return (
30 |
{
35 | return (
36 |
37 |
38 | remove(index)}
41 | >
42 | {tool.name}
43 |
44 |
45 |
46 |
47 | )
48 | }}
49 | />
50 | )
51 | })}
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/system-prompt.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
3 | import { Textarea } from '@/components/ui/textarea'
4 | import { UseFormReturn } from 'react-hook-form'
5 |
6 | type TSystemPromptProps = {
7 | form: UseFormReturn
8 | }
9 |
10 | export function SystemPrompt({ form }: TSystemPromptProps) {
11 | return (
12 | (
16 |
17 | System Prompt
18 |
19 |
24 |
25 |
26 | )}
27 | />
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/ui/src/components/features/build-panel/components/tool-dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Button } from '@/components/ui/button'
3 | import { Checkbox } from '@/components/ui/checkbox'
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogHeader,
9 | DialogTitle,
10 | DialogTrigger,
11 | } from '@/components/ui/dialog'
12 | import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
13 | import { TTool } from '@/data-provider/types'
14 | import { useAvailableTools } from '@/hooks/useAvailableTools'
15 | import { UseFormReturn } from 'react-hook-form'
16 |
17 | type TToolDialog = {
18 | form: UseFormReturn
19 | }
20 |
21 | export function ToolDialog({ form }: TToolDialog) {
22 | const { availableTools } = useAvailableTools()
23 |
24 | return (
25 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/ui/src/components/features/chat-panel/chat-panel.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Composer } from './components/composer'
4 | import MessagesContainer from './components/messages-container'
5 | import { useStream } from '@/hooks/useStream'
6 | import { useEffect, useState } from 'react'
7 | import { MessageType, TStreamState } from '@/data-provider/types'
8 | import { useSlugRoutes } from '@/hooks/useSlugParams'
9 | import { useRouter } from 'next/navigation'
10 | import { QueryClient, useQueryClient } from '@tanstack/react-query'
11 | import { QueryKeys, useGenerateTitle } from '@/data-provider/query-service'
12 | import { useChatMessages } from '@/hooks/useChat'
13 |
14 | export default function ChatPanel() {
15 | const [userMessage, setUserMessage] = useState('')
16 | const [isNewThread, setIsNewThread] = useState(false)
17 |
18 | const queryClient = useQueryClient()
19 |
20 | const { stream, startStream, stopStream: handleStop, isStreaming } = useStream()
21 |
22 | const generateTitle = useGenerateTitle()
23 |
24 | const { assistantId, threadId } = useSlugRoutes()
25 |
26 | const router = useRouter()
27 |
28 | const { messages } = useChatMessages(threadId as string, stream)
29 |
30 | useEffect(() => {
31 | const isStreamDone = stream?.status === 'done'
32 |
33 | if (isNewThread && isStreamDone) {
34 | generateTitle.mutate({
35 | thread_id: stream?.thread_id as string,
36 | history: messages,
37 | })
38 |
39 | router.push(`/a/${assistantId}/c/${stream?.thread_id}`)
40 | }
41 | }, [stream?.status])
42 |
43 | const handleSend = async () => {
44 | const input = [
45 | {
46 | content: userMessage,
47 | type: MessageType.HUMAN,
48 | example: false,
49 | },
50 | ]
51 |
52 | setUserMessage('')
53 |
54 | if (!threadId) setIsNewThread(true)
55 |
56 | await startStream({
57 | input,
58 | thread_id: threadId as string,
59 | assistant_id: assistantId as string,
60 | })
61 | }
62 |
63 | return (
64 |
65 |
66 | {threadId || isNewThread ? (
67 |
68 | ) : (
69 |
70 |
71 | {'Select an assistant to begin a conversation.'}
72 |
73 |
74 | )}
75 |
76 |
setUserMessage(e.target.value)}
78 | value={userMessage}
79 | onSend={handleSend}
80 | onStop={handleStop}
81 | isStreaming={isStreaming}
82 | disabled={!assistantId}
83 | />
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/ui/src/components/features/chat-panel/components/composer.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Input } from '@/components/ui/input'
3 | import { Send, StopCircle } from 'lucide-react'
4 | import { ChangeEvent } from 'react'
5 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
6 | import { Button } from '@/components/ui/button'
7 |
8 | type Props = {
9 | onChange: (e: ChangeEvent) => void
10 | onSend: () => void
11 | value: string
12 | disabled?: boolean
13 | onStop: () => void
14 | isStreaming: boolean
15 | }
16 |
17 | export function Composer({ onChange, onSend, value, disabled, isStreaming, onStop }: Props) {
18 | const handleKeyDown = (e: React.KeyboardEvent) => {
19 | if (isStreaming) return
20 |
21 | if (e.key === 'Enter') {
22 | if (e.shiftKey) return
23 |
24 | e.preventDefault()
25 | onSend()
26 | }
27 | }
28 |
29 | return (
30 |
31 |
32 |
33 |
34 |
37 | {isStreaming ? : }
38 |
39 | }
40 | onChange={onChange}
41 | value={value}
42 | onKeyDown={handleKeyDown}
43 | disabled={disabled}
44 | />
45 |
46 | {disabled && (
47 |
48 | Select an assistant to start a new thread.
49 |
50 | )}
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/ui/src/components/features/chat-panel/components/message-item.tsx:
--------------------------------------------------------------------------------
1 | import { MessageType, TMessage, TAssistant } from '@/data-provider/types'
2 | import Markdown from '../../markdown/markdown'
3 | import { UserRound } from 'lucide-react'
4 | import { Bot } from 'lucide-react'
5 |
6 | type TMessageItemProps = {
7 | message: TMessage
8 | assistant?: TAssistant
9 | }
10 |
11 | export default function MessageItem({ message, assistant }: TMessageItemProps) {
12 | if (message.type === MessageType.HUMAN) {
13 | return (
14 |
24 | //
25 | //
26 | //
27 | //
28 | //
29 | )
30 | }
31 |
32 | if (message.type === MessageType.AI) {
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
{assistant?.name}
40 |
41 |
42 |
43 |
44 |
45 | {/* {isMostRecent && messageCompleted &&
} */}
46 |
47 |
48 | //
49 | //
50 | //
51 | )
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/ui/src/components/features/chat-panel/components/messages-container.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { MessageType, TMessage, TStreamState, TToolCall } from '@/data-provider/types'
3 | import { useAssistant } from '@/data-provider/query-service'
4 | import MessageItem from './message-item'
5 | import { ReactNode, useEffect, useRef } from 'react'
6 | import { useChatMessages } from '@/hooks/useChat'
7 | import ToolContainer from '../../tools/tool-container'
8 | import { ToolResult } from '../../tools/tool-result'
9 | import { useSlugRoutes } from '@/hooks/useSlugParams'
10 |
11 | type Props = {
12 | streamingMessage?: TMessage | null
13 | onRetry?: VoidFunction
14 | threadId: string
15 | stream: TStreamState
16 | }
17 |
18 | function usePrevious(value: T): T | undefined {
19 | const ref = useRef()
20 | useEffect(() => {
21 | ref.current = value
22 | })
23 | return ref.current
24 | }
25 |
26 | export default function MessagesContainer({ streamingMessage, onRetry, threadId, stream }: Props) {
27 | const { messages } = useChatMessages(threadId, stream)
28 | const prevMessages = usePrevious(messages)
29 | const { assistantId } = useSlugRoutes()
30 |
31 | const { data: selectedAssistant, isLoading: isLoadingAssistant } = useAssistant(assistantId as string, {
32 | enabled: !!assistantId,
33 | })
34 |
35 | const divRef = useRef(null)
36 |
37 | // Auto-scroll
38 | useEffect(() => {
39 | if (divRef.current) {
40 | divRef.current.scrollTo({
41 | top: divRef.current.scrollHeight,
42 | behavior: prevMessages && prevMessages?.length === messages?.length ? 'smooth' : undefined,
43 | })
44 | }
45 | }, [messages])
46 |
47 | return (
48 |
49 | {messages?.map((message, index) => {
50 | const isToolCall = message.tool_calls?.length && message.tool_calls.length > 0
51 |
52 | const isToolResult = message.type === MessageType.TOOL
53 |
54 | if (isToolResult) {
55 | return
56 | }
57 |
58 | if (isToolCall) {
59 | return (
60 |
61 | )
62 | }
63 |
64 | return
65 | })}
66 |
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/ui/src/components/features/header/components/header.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Button } from '@/components/ui/button'
3 | import { AssistantSelector } from '../../build-panel/components/assistant-selector'
4 | import { useRouter } from 'next/navigation'
5 | import { Plus } from 'lucide-react'
6 | import PersonaFlowIcon from '../../../../../assets/PersonaFlowIcon-512.png'
7 | import Image from 'next/image'
8 |
9 | export default function Header() {
10 | const router = useRouter()
11 | const createAssistantStyle =
12 | 'm-auto flex h-9 text-sm cursor-pointer items-center gap-2 rounded-md border border-white/50 px-3 py-4 text-md text-white transition-colors duration-200 hover:bg-gray-500/40'
13 |
14 | return (
15 |
16 |
17 | {/* */}
18 | AgentStack
19 |
20 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/ui/src/components/features/markdown/markdown.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/utils/utils'
2 | import ReactMarkdown from 'react-markdown'
3 | import remarkGfm from 'remark-gfm'
4 | import remarkMath from 'remark-math'
5 |
6 | type TMarkdownProps = {
7 | text: string
8 | className?: string
9 | }
10 |
11 | export default function Markdown({ text, className }: TMarkdownProps) {
12 | return (
13 |
30 | {text}
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/ui/src/components/features/navbar/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import Spinner from '@/components/ui/spinner'
3 | import { useGetMyThreads } from '@/data-provider/query-service'
4 | import { useEffect, useState } from 'react'
5 | import ThreadItem from './thread-item'
6 | import { TGroupedThreads } from '@/data-provider/types'
7 | import { useSlugRoutes } from '@/hooks/useSlugParams'
8 | import NewThreadBtn from './new-thread-btn'
9 | import { useRouter } from 'next/navigation'
10 |
11 | export default function Navbar() {
12 | const { data: threadsData, isLoading: threadsLoading, isFetching } = useGetMyThreads(true)
13 |
14 | const [filteredThreads, setFilteredThreads] = useState(threadsData || {})
15 |
16 | const router = useRouter()
17 |
18 | const { assistantId, threadId: currentThreadId } = useSlugRoutes()
19 |
20 | useEffect(() => {
21 | if (assistantId && threadsData) {
22 | let _filteredThreads = assistantId ? filterThreads(threadsData as TGroupedThreads) : threadsData
23 | setFilteredThreads(_filteredThreads)
24 | }
25 | }, [assistantId, threadsData])
26 |
27 | const filterThreads = (groupedThreads: TGroupedThreads) =>
28 | Object.entries(groupedThreads).reduce((newGroupedThreads, [grouping, threads]) => {
29 | const filtered = threads.filter((thread) => thread.assistant_id === assistantId)
30 | // @ts-ignore
31 | newGroupedThreads[grouping] = filtered
32 | return newGroupedThreads
33 | }, {})
34 |
35 | const onNewThreadClick = () => {
36 | router.push(`/a/${assistantId}`)
37 | }
38 |
39 | return (
40 |
41 |
42 | {!threadsLoading &&
}
43 | {!threadsLoading && Object.values(filteredThreads).every((value) => value.length === 0) && (
44 |
45 |
No threads found.
46 |
47 | )}
48 |
77 |
78 |
79 |
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/ui/src/components/features/navbar/components/new-thread-btn.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button'
2 | import { Plus, PlusSquareIcon } from 'lucide-react'
3 |
4 | type TNewThreadBtnProps = {
5 | handleClick: () => void
6 | disabled?: boolean
7 | }
8 |
9 | export default function NewThreadBtn({ handleClick, disabled }: TNewThreadBtnProps) {
10 | const newChatStyle =
11 | 'm-auto flex h-12 cursor-pointer items-center gap-2 rounded-md border border-white/50 px-3 py-3 text-md text-white transition-colors duration-200 hover:bg-gray-500/40'
12 |
13 | return (
14 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/ui/src/components/features/tools/tool-container.tsx:
--------------------------------------------------------------------------------
1 | import { TToolCall } from '@/data-provider/types'
2 | import ToolQuery from './tool-query'
3 |
4 | type TToolContainer = {
5 | toolCalls: TToolCall[]
6 | }
7 |
8 | export default function ToolContainer({ toolCalls }: TToolContainer) {
9 | return (
10 |
11 | {toolCalls.map((toolCall) => (
12 |
13 | ))}
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/ui/src/components/features/tools/tool-query.tsx:
--------------------------------------------------------------------------------
1 | import { List, Search } from 'lucide-react'
2 |
3 | const icons = [
4 | { title: 'query', icon:
},
5 | { title: 'search', icon: },
6 | ]
7 |
8 | type TToolQueryProps = {
9 | query: string
10 | tool: string
11 | }
12 |
13 | export default function ToolQuery({ query, tool }: TToolQueryProps) {
14 | return (
15 | <>
16 |
17 |
Query: {query}
18 |
19 |
20 | Using: {tool}
21 |
22 | >
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/ui/src/components/features/tools/tool-result.tsx:
--------------------------------------------------------------------------------
1 | import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
2 | import { Button } from '@/components/ui/button'
3 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
4 | import { TMessage } from '@/data-provider/types'
5 | import { CaretSortIcon } from '@radix-ui/react-icons'
6 | import { MoveUpRight } from 'lucide-react'
7 | import Link from 'next/link'
8 | import { useState } from 'react'
9 |
10 | const AccordionToolContent = ({ content, url }: { content: string; url: string }) => {
11 | return (
12 |
13 |
14 |
15 |
16 | {url}
17 |
23 |
24 |
25 | {content}
26 |
27 |
28 | )
29 | }
30 |
31 | export function ToolResult({ toolResult }: { toolResult: TMessage }) {
32 | const [isOpen, setIsOpen] = useState(false)
33 |
34 | return (
35 |
36 |
37 |
38 |
39 |
43 |
44 |
45 |
46 | {Array.isArray(toolResult.content)
47 | ? toolResult.content.map((item) => (
48 |
49 | ))
50 | : toolResult.content}
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/ui/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as AccordionPrimitive from '@radix-ui/react-accordion'
5 | import { ChevronDownIcon } from '@radix-ui/react-icons'
6 |
7 | import { cn } from '@/utils/utils'
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
16 | ))
17 | AccordionItem.displayName = 'AccordionItem'
18 |
19 | const AccordionTrigger = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef
22 | >(({ className, children, ...props }, ref) => (
23 |
24 | svg]:rotate-180',
28 | className,
29 | )}
30 | {...props}
31 | >
32 | {children}
33 |
34 |
35 |
36 | ))
37 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
38 |
39 | const AccordionContent = React.forwardRef<
40 | React.ElementRef,
41 | React.ComponentPropsWithoutRef
42 | >(({ className, children, ...props }, ref) => (
43 |
48 | {children}
49 |
50 | ))
51 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
52 |
53 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
54 |
--------------------------------------------------------------------------------
/ui/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 |
4 | import { cn } from '@/utils/utils'
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'border-transparent bg-primary text-primary-foreground shadow hover:opacity-80',
12 | secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
13 | destructive:
14 | 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
15 | outline: 'border-none bg-primary text-white shadow-sm transition-all duration-200 hover:opacity-80',
16 | },
17 | },
18 | defaultVariants: {
19 | variant: 'default',
20 | },
21 | },
22 | )
23 |
24 | export interface BadgeProps
25 | extends React.HTMLAttributes,
26 | VariantProps {}
27 |
28 | function Badge({ className, variant, ...props }: BadgeProps) {
29 | return
30 | }
31 |
32 | export { Badge, badgeVariants }
33 |
--------------------------------------------------------------------------------
/ui/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { Slot } from '@radix-ui/react-slot'
3 | import { cva, type VariantProps } from 'class-variance-authority'
4 |
5 | import { cn } from '@/utils/utils'
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground shadow hover:opacity-80 transition-all duration-200',
13 | destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
14 | outline:
15 | 'border border-input text-accent-foreground shadow-sm hover:bg-accent hover:text-accent-foreground',
16 | secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:opacity-50',
17 | ghost: 'hover:bg-accent hover:text-accent-foreground',
18 | link: 'text-primary underline-offset-4 hover:underline',
19 | },
20 | size: {
21 | default: 'h-9 px-4 py-2',
22 | sm: 'h-8 rounded-md px-3 text-xs',
23 | lg: 'h-10 rounded-md px-8',
24 | icon: 'h-9 w-9',
25 | },
26 | },
27 | defaultVariants: {
28 | variant: 'default',
29 | size: 'default',
30 | },
31 | },
32 | )
33 |
34 | export interface ButtonProps
35 | extends React.ButtonHTMLAttributes,
36 | VariantProps {
37 | asChild?: boolean
38 | }
39 |
40 | const Button = React.forwardRef(
41 | ({ className, variant, size, asChild = false, ...props }, ref) => {
42 | const Comp = asChild ? Slot : 'button'
43 | return
44 | },
45 | )
46 | Button.displayName = 'Button'
47 |
48 | export { Button, buttonVariants }
49 |
--------------------------------------------------------------------------------
/ui/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/utils/utils'
4 |
5 | const Card = React.forwardRef>(
6 | ({ className, ...props }, ref) => (
7 |
12 | ),
13 | )
14 | Card.displayName = 'Card'
15 |
16 | const CardHeader = React.forwardRef>(
17 | ({ className, ...props }, ref) => (
18 |
19 | ),
20 | )
21 | CardHeader.displayName = 'CardHeader'
22 |
23 | const CardTitle = React.forwardRef>(
24 | ({ className, ...props }, ref) => (
25 |
26 | ),
27 | )
28 | CardTitle.displayName = 'CardTitle'
29 |
30 | const CardDescription = React.forwardRef>(
31 | ({ className, ...props }, ref) => (
32 |
33 | ),
34 | )
35 | CardDescription.displayName = 'CardDescription'
36 |
37 | const CardContent = React.forwardRef>(
38 | ({ className, ...props }, ref) => ,
39 | )
40 | CardContent.displayName = 'CardContent'
41 |
42 | const CardFooter = React.forwardRef>(
43 | ({ className, ...props }, ref) => (
44 |
45 | ),
46 | )
47 | CardFooter.displayName = 'CardFooter'
48 |
49 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
50 |
--------------------------------------------------------------------------------
/ui/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
5 | import { CheckIcon } from '@radix-ui/react-icons'
6 |
7 | import { cn } from '@/utils/utils'
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
22 |
23 |
24 |
25 | ))
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
27 |
28 | export { Checkbox }
29 |
--------------------------------------------------------------------------------
/ui/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
4 |
5 | const Collapsible = CollapsiblePrimitive.Root
6 |
7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
8 |
9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
10 |
11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }
12 |
--------------------------------------------------------------------------------
/ui/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/utils/utils'
4 | import { LucideIcon } from 'lucide-react'
5 |
6 | export interface InputProps extends React.InputHTMLAttributes {
7 | startIcon?: LucideIcon
8 | endIcon?: React.ReactNode
9 | }
10 |
11 | const Input = React.forwardRef(
12 | ({ className, type, startIcon, endIcon, ...props }, ref) => {
13 | const StartIcon = startIcon
14 | const EndIcon = endIcon
15 | return (
16 |
17 | {StartIcon && (
18 |
19 |
20 |
21 | )}
22 |
34 | {EndIcon &&
{EndIcon}
}
35 |
36 | )
37 | },
38 | )
39 | Input.displayName = 'Input'
40 |
41 | export { Input }
42 |
--------------------------------------------------------------------------------
/ui/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as LabelPrimitive from '@radix-ui/react-label'
5 | import { cva, type VariantProps } from 'class-variance-authority'
6 |
7 | import { cn } from '@/utils/utils'
8 |
9 | const labelVariants = cva(
10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef & VariantProps
16 | >(({ className, ...props }, ref) => (
17 |
18 | ))
19 | Label.displayName = LabelPrimitive.Root.displayName
20 |
21 | export { Label }
22 |
--------------------------------------------------------------------------------
/ui/src/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/utils/utils'
2 | import { Loader2 } from 'lucide-react'
3 |
4 | const Spinner = ({ className }: { className?: string }) => {
5 | return
6 | }
7 |
8 | export default Spinner
9 |
--------------------------------------------------------------------------------
/ui/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SwitchPrimitives from '@radix-ui/react-switch'
5 |
6 | import { cn } from '@/utils/utils'
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/ui/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as TabsPrimitive from '@radix-ui/react-tabs'
5 |
6 | import { cn } from '@/utils/utils'
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/ui/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/utils/utils'
4 |
5 | export interface TextareaProps extends React.TextareaHTMLAttributes {}
6 |
7 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
8 | return (
9 |
17 | )
18 | })
19 | Textarea.displayName = 'Textarea'
20 |
21 | export { Textarea }
22 |
--------------------------------------------------------------------------------
/ui/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from '@/components/ui/toast'
11 | import { useToast } from '@/components/ui/use-toast'
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && {description}}
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/ui/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
5 | import { cn } from '@/utils/utils'
6 |
7 | const TooltipProvider = TooltipPrimitive.Provider
8 |
9 | const Tooltip = TooltipPrimitive.Root
10 |
11 | const TooltipTrigger = TooltipPrimitive.Trigger
12 |
13 | const TooltipContent = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, sideOffset = 4, ...props }, ref) => (
17 |
26 | ))
27 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
28 |
29 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
30 |
--------------------------------------------------------------------------------
/ui/src/data-provider/endpoints.ts:
--------------------------------------------------------------------------------
1 | const formatParam = (param?: any) => (param ? param : '')
2 |
3 | const BASE_PATH = '/api/v1'
4 |
5 | // --Runs--
6 | export const runs = `${BASE_PATH}/runs`
7 | export const runnableInputSchema = `${BASE_PATH}/runs/input_schema`
8 | export const runnableOutputSchema = `${BASE_PATH}/runs/output_schema`
9 | export const runnableConfigSchema = `${BASE_PATH}/runs/config_schema`
10 | export const title = `${BASE_PATH}/runs/title`
11 |
12 | // -- Me --
13 | export const me = `${BASE_PATH}/users/me`
14 |
15 | export const myThreads = (grouped?: boolean) => {
16 | const timezoneOffset = new Date().getTimezoneOffset()
17 | return `${BASE_PATH}/users/me/threads?grouped=${grouped}&timezoneOffset=${timezoneOffset}`
18 | }
19 |
20 | // --Admin Users--
21 | export const users = (userId?: string) => `${BASE_PATH}/admin/users/${formatParam(userId)}`
22 |
23 | // --Threads--
24 | export const threads = (threadId?: string) => `${BASE_PATH}/threads/${formatParam(threadId)}`
25 |
26 | export const threadState = (threadId: string) => `${BASE_PATH}/threads/${threadId}/state`
27 |
28 | // --Messages--
29 | export const messages = (messageId?: string) => `${BASE_PATH}/messages/${formatParam(messageId)}`
30 |
31 | // --Assistants--
32 | export const assistants = (assistantId?: string) => `${BASE_PATH}/assistants/${formatParam(assistantId)}`
33 |
34 | export const assistantFiles = (
35 | assistantId: string,
36 | limit?: number,
37 | order?: string,
38 | before?: string,
39 | after?: string,
40 | ) => `${BASE_PATH}/assistants/${assistantId}/files`
41 |
42 | export const assistantFile = (assistantId: string, fileId?: string) =>
43 | `${BASE_PATH}/assistants/${assistantId}/files/${formatParam(fileId)}`
44 |
45 | // --RAG--
46 | export const ingest = () => `${BASE_PATH}/rag/ingest`
47 | export const query = () => `${BASE_PATH}/rag/query`
48 |
49 | // --Files--
50 | export const files = `${BASE_PATH}/files`
51 |
52 | export const file = (fileId?: string, purpose?: string) => `${BASE_PATH}/files/${formatParam(fileId)}`
53 |
54 | export const fileContent = (fileId: string) => `${BASE_PATH}/files/${fileId}/content`
55 |
56 | export const deleteFile = (fileId: string) => `${BASE_PATH}files/${fileId}`
57 |
58 | // --Health Check--
59 | export const healthCheck = () => `${BASE_PATH}/health_check`
60 |
--------------------------------------------------------------------------------
/ui/src/data-provider/query-provider.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PersonaFlow/agentstack/f03a655768bbc42c233653ebf042655270eb71cd/ui/src/data-provider/query-provider.ts
--------------------------------------------------------------------------------
/ui/src/data-provider/requests.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosRequestConfig } from 'axios'
2 |
3 | async function _get(url: string, options?: AxiosRequestConfig): Promise {
4 | const response = await axios.get(url, { ...options })
5 | return response.data
6 | }
7 |
8 | async function _post(url: string, data?: any): Promise {
9 | const response = await axios.post(url, data)
10 | return response.data
11 | }
12 |
13 | async function _postMultiPart(url: string, formData: FormData): Promise {
14 | const response = await axios.postForm(url, formData, {
15 | headers: { 'Content-Type': 'multipart/form-data' },
16 | })
17 | return response.data
18 | }
19 |
20 | async function _put(url: string, data?: any): Promise {
21 | const response = await axios.put(url, JSON.stringify(data))
22 | return response.data
23 | }
24 |
25 | async function _patch(url: string, data?: any): Promise {
26 | const response = await axios.patch(url, data)
27 | return response.data
28 | }
29 |
30 | async function _delete(url: string, data?: any): Promise {
31 | const response = await axios.delete(url)
32 | return response.data
33 | }
34 |
35 | axios.defaults.baseURL = 'http://127.0.0.1:9000'
36 |
37 | // Set token for requests
38 | axios.interceptors.request.use(
39 | async (config) => {
40 | // @ts-ignore
41 | // config.headers = {
42 | // "X-API-KEY": process.env.NEXT_PUBLIC_PERSONAFLOW_API_KEY,
43 | // };
44 |
45 | return config
46 | },
47 | (error) => {
48 | return Promise.reject(error)
49 | },
50 | )
51 |
52 | const request = {
53 | get: _get,
54 | post: _post,
55 | postMultiPart: _postMultiPart,
56 | put: _put,
57 | patch: _patch,
58 | delete: _delete,
59 | }
60 |
61 | export default request
62 |
--------------------------------------------------------------------------------
/ui/src/hooks/useAvailableTools.ts:
--------------------------------------------------------------------------------
1 | import { useRunnableConfigSchema } from '@/data-provider/query-service'
2 | import { TConfigDefinitions, TSchemaField, TTool } from '@/data-provider/types'
3 |
4 | export const useAvailableTools = () => {
5 | const { data: configSchema, isLoading, isError } = useRunnableConfigSchema()
6 |
7 | if (!configSchema || isLoading || isError) return {}
8 |
9 | const { definitions } = configSchema
10 |
11 | const { AvailableTools } = definitions
12 |
13 | const configDefinitions = Object.entries(definitions)
14 | const availableTools: TTool[] = []
15 |
16 | // @ts-ignore to be able to build
17 | AvailableTools.enum.forEach((availableTool: string) => {
18 | // Find tool from config schema
19 | const toolDefinition = configDefinitions.find((definition) => {
20 | // @ts-ignore to be able to build
21 | const { properties } = definition[1] as TConfigDefinitions
22 | if (!properties || !properties.type) return false
23 |
24 | return properties.type.default === availableTool
25 | })
26 |
27 | // Format tool to store with assistant
28 | // @ts-ignore to be able to build
29 | const toolProperties = toolDefinition[1] as TConfigDefinitions
30 |
31 | const tool = {
32 | type: toolProperties.properties.type.default,
33 | name: toolProperties.properties.name.default,
34 | description: toolProperties.properties.description.default,
35 | multi_use: toolProperties.properties.multi_use.default,
36 | config: {},
37 | }
38 |
39 | availableTools.push(tool)
40 | })
41 |
42 | return {
43 | availableTools,
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/ui/src/hooks/useChat.ts:
--------------------------------------------------------------------------------
1 | import { TMessage, TStreamState } from '@/data-provider/types'
2 | import { useEffect } from 'react'
3 | import { mergeMessagesById } from './useStream'
4 | import { useThreadState } from '@/data-provider/query-service'
5 | import { useAtom } from 'jotai'
6 | import { messagesAtom } from '@/store'
7 |
8 | export function useChatMessages(threadId: string | null, stream: TStreamState | null) {
9 | const [streamedMessages, setStreamedMessages] = useAtom(messagesAtom)
10 |
11 | const {
12 | data: threadData,
13 | refetch,
14 | isFetched,
15 | } = useThreadState(threadId as string, {
16 | enabled: !!threadId,
17 | })
18 |
19 | // Refetch messages after streaming
20 | useEffect(() => {
21 | if (stream?.status !== 'inflight' && threadId) {
22 | refetch()
23 | }
24 | }, [stream?.status, threadId, refetch])
25 |
26 | // Stop persisting streamed messages after streaming and message refetch
27 | useEffect(() => {
28 | if (isFetched) {
29 | setStreamedMessages([])
30 | }
31 | }, [isFetched])
32 |
33 | useEffect(() => {
34 | if (stream?.messages) {
35 | setStreamedMessages(stream.messages as TMessage[])
36 | }
37 | }, [stream?.messages])
38 |
39 | const messages = threadData?.values ? threadData.values : null
40 |
41 | return {
42 | messages: mergeMessagesById(messages, streamedMessages),
43 | next: threadData?.next || [],
44 | refreshMessages: refetch,
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/ui/src/hooks/useConfig.ts:
--------------------------------------------------------------------------------
1 | import { useRunnableConfigSchema } from '@/data-provider/query-service'
2 | import { TConfigurableSchema } from '@/data-provider/types'
3 |
4 | export const useConfigSchema = (selectedArchType?: string) => {
5 | const { data: configSchema, isLoading, isError } = useRunnableConfigSchema()
6 |
7 | if (!configSchema || isLoading || isError) return {}
8 |
9 | const { definitions } = configSchema
10 |
11 | const configProperties = (definitions['Configurable'] as TConfigurableSchema).properties
12 |
13 | let systemMessage
14 | let retrievalDescription
15 |
16 | if (selectedArchType === 'chat_retrieval') {
17 | systemMessage = configProperties['type==chat_retrieval/system_message'].default
18 | retrievalDescription = configProperties['type==agent/retrieval_description'].default
19 | }
20 |
21 | if (selectedArchType === 'chatbot') {
22 | systemMessage = configProperties['type==chatbot/system_message'].default
23 | }
24 |
25 | if (selectedArchType === 'agent') {
26 | systemMessage = configProperties['type==agent/system_message'].default
27 | retrievalDescription = configProperties['type==agent/retrieval_description'].default
28 | }
29 |
30 | return {
31 | systemMessage,
32 | retrievalDescription,
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/ui/src/hooks/useFileStream.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { QueryKeys } from '@/data-provider/query-service'
4 | import { TFileIngest, TFileStreamStatus, TFileStreamProgressState } from '@/data-provider/types'
5 | import { fetchEventSource } from '@microsoft/fetch-event-source'
6 | import { useQueryClient } from '@tanstack/react-query'
7 | import { useCallback, useState } from 'react'
8 |
9 | export const useFileStream = () => {
10 | const [currentState, setCurrentState] = useState(null)
11 |
12 | const queryClient = useQueryClient()
13 |
14 | const isStreaming = currentState?.status === TFileStreamStatus.inflight
15 |
16 | const startProgressStream = useCallback(async ({ task_id }: TFileIngest) => {
17 | const controller = new AbortController()
18 | setCurrentState({ status: TFileStreamStatus.inflight })
19 |
20 | await fetchEventSource(`${process.env.NEXT_PUBLIC_BASE_API_URL}/api/v1/rag/ingest/${task_id}/progress`, {
21 | signal: controller.signal,
22 | method: 'GET',
23 | headers: {
24 | 'Content-Type': 'application/json',
25 | 'X-API-KEY': 'personaflow_api_key',
26 | },
27 | openWhenHidden: true,
28 | onmessage(msg) {
29 | if (msg.event === 'data') {
30 | const progressData = JSON.parse(msg.data)
31 | const { progress } = progressData
32 | setCurrentState(() => ({
33 | status: TFileStreamStatus.inflight,
34 | progress,
35 | }))
36 | } else if (msg.event === TFileStreamStatus.error) {
37 | setCurrentState({
38 | status: TFileStreamStatus.error,
39 | progress: 'Something went wrong.',
40 | })
41 | }
42 | },
43 | onclose() {
44 | setCurrentState((currentState) => ({
45 | status:
46 | currentState?.status === TFileStreamStatus.error ? currentState.status : TFileStreamStatus.done,
47 | progress:
48 | currentState?.status === TFileStreamStatus.error
49 | ? 'Something went wrong.'
50 | : currentState?.progress,
51 | }))
52 | queryClient.invalidateQueries({
53 | queryKey: [QueryKeys.assistantFiles],
54 | })
55 | },
56 | onerror(error) {
57 | setCurrentState({ status: TFileStreamStatus.error })
58 | },
59 | })
60 | }, [])
61 |
62 | return {
63 | startProgressStream,
64 | progressStream: currentState,
65 | isStreaming,
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/ui/src/hooks/useSlugParams.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { getSlugParams } from '@/utils/routeUtils'
3 | import { useParams } from 'next/navigation'
4 | import { useMemo } from 'react'
5 |
6 | /**
7 | *
8 | * @description This hook parses the current route and returns assistantId and threadId.
9 | * The slug can be in the following formats:
10 | * - [] - /
11 | * - [a, :assistantId] - /a/:assistantId
12 | * - [a, :assistantId, c, :threadId] - /a/:assistantId/c/:threadId
13 | */
14 |
15 | export const useSlugRoutes = () => {
16 | const { slug } = useParams()
17 |
18 | const { assistantId, threadId } = useMemo(() => {
19 | const formatSlug = (slug ?? []) as string[]
20 | return getSlugParams(formatSlug)
21 | }, [slug])
22 |
23 | return { assistantId, threadId }
24 | }
25 |
--------------------------------------------------------------------------------
/ui/src/mockFiles.ts:
--------------------------------------------------------------------------------
1 | export const mockFiles = [
2 | {
3 | filename: 'a.pdf',
4 | id: '1',
5 | },
6 | {
7 | filename: 'b.pdf',
8 | id: '2',
9 | },
10 | {
11 | filename: 'c.pdf',
12 | id: '3',
13 | },
14 | {
15 | filename: 'd.pdf',
16 | id: '4',
17 | },
18 | {
19 | filename: 'e.pdf',
20 | id: '5',
21 | },
22 | {
23 | filename: 'f.pdf',
24 | id: '6',
25 | },
26 | {
27 | filename: 'g.pdf',
28 | id: '7',
29 | },
30 | {
31 | filename: 'h.pdf',
32 | id: '8',
33 | },
34 | {
35 | filename: 'i.pdf',
36 | id: '9',
37 | },
38 | {
39 | filename: 'j.pdf',
40 | id: '10',
41 | },
42 | ]
43 |
--------------------------------------------------------------------------------
/ui/src/providers/Providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4 | import { ReactNode, useState } from 'react'
5 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
6 |
7 | export default function Providers({ children }: { children: ReactNode }) {
8 | const [queryClient] = useState(
9 | () =>
10 | new QueryClient({
11 | defaultOptions: {
12 | queries: {
13 | // With SSR, we usually want to set some default staleTime
14 | // above 0 to avoid refetching immediately on the client
15 | // staleTime: 60 * 1000,
16 | },
17 | },
18 | }),
19 | )
20 | return (
21 |
22 | {children}
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/ui/src/store.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'jotai'
2 | import { TMessage } from './data-provider/types'
3 |
4 | export const messagesAtom = atom([])
5 | export const threadAtom = atom(false)
6 | export const fileStreamAtom = atom(false)
7 |
--------------------------------------------------------------------------------
/ui/src/styles/main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 217, 33%, 17%;
8 | --foreground: 240, 5%, 96%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 20 14.3% 4.1%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 20 14.3% 4.1%;
15 |
16 | --primary: 200, 98%, 39%;
17 | --primary-foreground: 60 9.1% 97.8%;
18 |
19 | --secondary: 215, 16%, 47%;
20 | --secondary-foreground: 240, 6%, 90%;
21 |
22 | --muted: 60 4.8% 95.9%;
23 | --muted-foreground: 25 5.3% 44.7%;
24 |
25 | --accent: 204, 94%, 94%;
26 | --accent-foreground: 24 9.8% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 60 9.1% 97.8%;
30 |
31 | --border: 0 0% 0%;
32 |
33 | --input: 0 0% 0%;
34 | --ring: 20 14.3% 4.1%;
35 |
36 | --radius: 0.5rem;
37 | }
38 |
39 | .dark {
40 | --background: 240, 5%, 96%;
41 | --foreground: 60 9.1% 97.8%;
42 |
43 | --card: 20 14.3% 4.1%;
44 | --card-foreground: 60 9.1% 97.8%;
45 |
46 | --popover: 20 14.3% 4.1%;
47 | --popover-foreground: 60 9.1% 97.8%;
48 |
49 | --primary: 60 9.1% 97.8%;
50 | --primary-foreground: 24 9.8% 10%;
51 |
52 | --secondary: 12 6.5% 15.1%;
53 | --secondary-foreground: 60 9.1% 97.8%;
54 |
55 | --muted: 12 6.5% 15.1%;
56 | --muted-foreground: 24 5.4% 63.9%;
57 |
58 | --accent: 12 6.5% 15.1%;
59 | --accent-foreground: 60 9.1% 97.8%;
60 |
61 | --destructive: 0 62.8% 30.6%;
62 | --destructive-foreground: 60 9.1% 97.8%;
63 |
64 | --border: 0 0% 0%;
65 |
66 | --input: 0 0% 0%;
67 | --ring: 24 5.7% 82.9%;
68 | }
69 | }
70 |
71 | @layer base {
72 | * {
73 | @apply border-border;
74 | }
75 | body {
76 | @apply bg-background text-foreground;
77 | }
78 | }
79 |
80 | .hide-scrollbar {
81 | /* Hide scrollbar for Chrome, Safari, and Opera */
82 | scrollbar-width: none; /* For Firefox */
83 | -ms-overflow-style: none; /* For Internet Explorer and Edge */
84 | }
85 |
86 | .hide-scrollbar::-webkit-scrollbar {
87 | display: none; /* For WebKit browsers */
88 | }
89 |
--------------------------------------------------------------------------------
/ui/src/utils/routeUtils.ts:
--------------------------------------------------------------------------------
1 | export function isValidParam(params?: string[]) {
2 | if (!params) return true
3 |
4 | const route = params.join('/')
5 |
6 | const UUIDPattern = '[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
7 | const assistantPattern = new RegExp(`a/${UUIDPattern}$`, 'i')
8 |
9 | const threadPattern = new RegExp(`a/${UUIDPattern}/c/${UUIDPattern}$`, 'i')
10 |
11 | return assistantPattern.test(route) || threadPattern.test(route)
12 | }
13 |
14 | export const isUUID = (value: string) => {
15 | const isValidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
16 |
17 | return isValidRegex.test(value)
18 | }
19 |
20 | // Note: If we support creating threads independent of assistants in the future, need to support /c/:threadId
21 |
22 | /**
23 | *
24 | * @description This function is used to parse the slug from the URL and return the assistantId and threadId.
25 | * The slug can be in the following formats:
26 | * - [] - / - Home
27 | * - [a, :assistantId] - /a/:assistantId - Assistant is selected
28 | * - [a, :assistantId, c, :threadId] - /a/:assistantId/c/:threadId - Thread is selected
29 | */
30 |
31 | export const getSlugParams = (
32 | slugParams?: string | string[],
33 | ): {
34 | assistantId: string | undefined
35 | threadId: string | undefined
36 | } => {
37 | if (!slugParams || typeof slugParams === 'string') {
38 | return { assistantId: undefined, threadId: undefined }
39 | }
40 |
41 | const [firstParam, secondParam, thirdParam, fourthParam] = slugParams
42 |
43 | // Case 1: [/]
44 | if (!firstParam) {
45 | return { assistantId: undefined, threadId: undefined }
46 | }
47 |
48 | // Case 2: [/a/:assistantId]
49 | if (firstParam === 'a' && isUUID(secondParam) && !thirdParam) {
50 | return { assistantId: secondParam, threadId: undefined }
51 | }
52 |
53 | // Case 3: [/a/:assistantId/c/:threadId]
54 | if (firstParam === 'a' && isUUID(secondParam) && thirdParam === 'c' && isUUID(fourthParam)) {
55 | return { assistantId: secondParam, threadId: fourthParam }
56 | }
57 |
58 | return { assistantId: undefined, threadId: undefined }
59 | }
60 |
--------------------------------------------------------------------------------
/ui/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/ui/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | const config = {
4 | darkMode: ['class'],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: '',
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: '2rem',
16 | screens: {
17 | '2xl': '1400px',
18 | },
19 | },
20 | white: '#fff',
21 | green: {
22 | 950: '#022c22',
23 | },
24 | gray: {
25 | 200: '#ffedd5',
26 | },
27 | zinc: {
28 | 800: '#78716c',
29 | },
30 | extend: {
31 | colors: {
32 | border: 'hsl(var(--border))',
33 | input: 'hsl(var(--input))',
34 | ring: 'hsl(var(--ring))',
35 | background: 'hsl(var(--background))',
36 | foreground: 'hsl(var(--foreground))',
37 | primary: {
38 | DEFAULT: 'hsl(var(--primary))',
39 | foreground: 'hsl(var(--primary-foreground))',
40 | },
41 | secondary: {
42 | DEFAULT: 'hsl(var(--secondary))',
43 | foreground: 'hsl(var(--secondary-foreground))',
44 | },
45 | destructive: {
46 | DEFAULT: 'hsl(var(--destructive))',
47 | foreground: 'hsl(var(--destructive-foreground))',
48 | },
49 | muted: {
50 | DEFAULT: 'hsl(var(--muted))',
51 | foreground: 'hsl(var(--muted-foreground))',
52 | },
53 | accent: {
54 | DEFAULT: 'hsl(var(--accent))',
55 | foreground: 'hsl(var(--accent-foreground))',
56 | },
57 | popover: {
58 | DEFAULT: 'hsl(var(--popover))',
59 | foreground: 'hsl(var(--popover-foreground))',
60 | },
61 | card: {
62 | DEFAULT: 'hsl(var(--card))',
63 | foreground: 'hsl(var(--card-foreground))',
64 | },
65 | },
66 | borderRadius: {
67 | lg: 'var(--radius)',
68 | md: 'calc(var(--radius) - 2px)',
69 | sm: 'calc(var(--radius) - 4px)',
70 | },
71 | keyframes: {
72 | 'accordion-down': {
73 | from: { height: '0' },
74 | to: { height: 'var(--radix-accordion-content-height)' },
75 | },
76 | 'accordion-up': {
77 | from: { height: 'var(--radix-accordion-content-height)' },
78 | to: { height: '0' },
79 | },
80 | },
81 | animation: {
82 | 'accordion-down': 'accordion-down 0.2s ease-out',
83 | 'accordion-up': 'accordion-up 0.2s ease-out',
84 | },
85 | },
86 | },
87 | plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
88 | } satisfies Config
89 |
90 | export default config
91 |
--------------------------------------------------------------------------------
/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------