mirror of
https://github.com/khoaliber/khoj.git
synced 2026-04-20 01:24:31 +00:00
Speed up app development using a faster, modern toolchain (#1196)
## Overview Speed up app install and development using a faster, modern development toolchain ## Details ### Major - Use [uv](https://docs.astral.sh/uv/) for faster server install (vs pip) - Use [bun](https://bun.sh/) for faster web app install (vs yarn) - Use [ruff](https://docs.astral.sh/ruff/) for faster formatting of server code (vs black, isort) - Fix devcontainer builds. See if uv and bun can speed up server and client installs ### Minor - Format web app with prettier and server with ruff. This is most of the file changes in this PR. - Simplify copying web app built files in pypi workflow to make it less flaky.
This commit is contained in:
@@ -1,40 +1,49 @@
|
||||
ARG PYTHON_VERSION=3.10
|
||||
ARG PYTHON_VERSION=3.12
|
||||
FROM mcr.microsoft.com/devcontainers/python:${PYTHON_VERSION}
|
||||
|
||||
# Install Node.js and Yarn
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
|
||||
apt-get install -y nodejs
|
||||
# Install UV and Bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash && mv /root/.bun/bin/bun /usr/local/bin/bun
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
RUN uv python pin $PYTHON_VERSION
|
||||
# create python virtual environment
|
||||
RUN uv venv /opt/venv --python $PYTHON_VERSION --seed
|
||||
# Add venv to PATH for subsequent RUN commands and for the container environment
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
# Tell pip, uv to use this virtual environment
|
||||
ENV VIRTUAL_ENV="/opt/venv"
|
||||
ENV UV_PROJECT_ENVIRONMENT="/opt/venv"
|
||||
|
||||
# Setup working directory
|
||||
WORKDIR /workspace
|
||||
WORKDIR /workspaces/khoj
|
||||
|
||||
# --- Python Server App Dependencies ---
|
||||
# Create Python virtual environment
|
||||
RUN python3 -m venv /opt/venv
|
||||
# Add venv to PATH for subsequent RUN commands and for the container environment
|
||||
ENV PATH="/opt/venv/bin:${PATH}"
|
||||
|
||||
# Copy files required for Python dependency installation.
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Setup python environment
|
||||
# Use the pre-built torch cpu wheel
|
||||
ENV PIP_EXTRA_INDEX_URL="https://download.pytorch.org/whl/cpu" \
|
||||
ENV UV_INDEX="https://download.pytorch.org/whl/cpu" \
|
||||
UV_INDEX_STRATEGY="unsafe-best-match" \
|
||||
# Avoid downloading unused cuda specific python packages
|
||||
CUDA_VISIBLE_DEVICES="" \
|
||||
# Use static version to build app without git dependency
|
||||
VERSION=0.0.0
|
||||
VERSION=0.0.0 \
|
||||
# Use embedded db
|
||||
USE_EMBEDDED_DB="True" \
|
||||
PGSERVER_DATA_DIR="/opt/khoj_db"
|
||||
# Install Python dependencies from pyproject.toml in editable mode
|
||||
RUN sed -i "s/dynamic = \\[\"version\"\\]/version = \"$VERSION\"/" pyproject.toml && \
|
||||
pip install --no-cache-dir ".[dev]"
|
||||
uv sync --all-extras && \
|
||||
# Save the lock file generated with correct Linux platform wheels
|
||||
cp uv.lock /opt/uv.lock.linux && \
|
||||
chown -R vscode:vscode /opt/venv
|
||||
|
||||
# --- Web App Dependencies ---
|
||||
# Copy web app manifest files
|
||||
COPY src/interface/web/package.json src/interface/web/yarn.lock /tmp/web/
|
||||
COPY src/interface/web/package.json src/interface/web/bun.lock /opt/khoj_web/
|
||||
|
||||
# Install web app dependencies
|
||||
# note: yarn will be available from the "features" in devcontainer.json
|
||||
RUN yarn install --cwd /tmp/web --cache-folder /opt/yarn-cache
|
||||
RUN cd /opt/khoj_web && bun install && chown -R vscode:vscode .
|
||||
|
||||
# The .venv and node_modules are now populated in the image.
|
||||
# The rest of the source code will be mounted by VS Code from your local checkout,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "..", // Build context is the project root
|
||||
"args": {
|
||||
"PYTHON_VERSION": "3.10"
|
||||
"PYTHON_VERSION": "3.12"
|
||||
}
|
||||
},
|
||||
"forwardPorts": [
|
||||
@@ -53,11 +53,6 @@
|
||||
"postCreateCommand": "scripts/dev_setup.sh --devcontainer",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "lts",
|
||||
"installYarnUsingApt": false,
|
||||
"nodeGypDependencies": true
|
||||
}
|
||||
},
|
||||
"remoteUser": "vscode"
|
||||
}
|
||||
|
||||
18
.github/workflows/pre-commit.yml
vendored
18
.github/workflows/pre-commit.yml
vendored
@@ -35,18 +35,24 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
python-version: 3.11
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: ⏬️ Install Dependencies
|
||||
run: |
|
||||
sudo apt update && sudo apt install -y libegl1
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
- name: ⬇️ Install Application
|
||||
run: pip install --no-cache-dir --upgrade .[dev]
|
||||
env:
|
||||
UV_INDEX: "https://download.pytorch.org/whl/cpu"
|
||||
UV_INDEX_STRATEGY: "unsafe-best-match"
|
||||
CUDA_VISIBLE_DEVICES: ""
|
||||
run: uv sync --all-extras
|
||||
|
||||
- name: 🌡️ Validate Application
|
||||
run: pre-commit run --hook-stage manual --all
|
||||
run: uv run pre-commit run --hook-stage manual --all
|
||||
|
||||
31
.github/workflows/pypi.yml
vendored
31
.github/workflows/pypi.yml
vendored
@@ -34,25 +34,28 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
python-version: '3.11.12'
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11.12
|
||||
|
||||
- name: ⬇️ Install Server
|
||||
run: python -m pip install --upgrade pip && pip install --upgrade .
|
||||
run: uv sync --all-extras
|
||||
|
||||
- name: Install bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: ⬇️ Install Web Client
|
||||
run: |
|
||||
yarn install
|
||||
yarn pypiciexport
|
||||
bun install
|
||||
bun pypiciexport
|
||||
working-directory: src/interface/web
|
||||
|
||||
- name: 📂 Copy Generated Files
|
||||
run: |
|
||||
mkdir -p src/khoj/interface/compiled
|
||||
cp -r /opt/hostedtoolcache/Python/3.11.12/x64/lib/python3.11/site-packages/khoj/interface/compiled/* src/khoj/interface/compiled/
|
||||
|
||||
- name: ⚙️ Build Python Package
|
||||
run: |
|
||||
# Setup Environment for Reproducible Builds
|
||||
@@ -61,13 +64,13 @@ jobs:
|
||||
rm -rf dist
|
||||
|
||||
# Build PyPI Package
|
||||
pipx run build
|
||||
uv build
|
||||
|
||||
- name: 🌡️ Validate Python Package
|
||||
run: |
|
||||
# Validate PyPi Package
|
||||
pipx run check-wheel-contents dist/*.whl --ignore W004
|
||||
pipx run twine check dist/*
|
||||
uv tool run check-wheel-contents dist/*.whl --ignore W004
|
||||
uv tool run twine check dist/*
|
||||
|
||||
- name: ⏫ Upload Python Package Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
19
.github/workflows/run_evals.yml
vendored
19
.github/workflows/run_evals.yml
vendored
@@ -106,10 +106,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.10
|
||||
|
||||
- name: Get App Version
|
||||
id: hatch
|
||||
@@ -127,16 +130,18 @@ jobs:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run: |
|
||||
# install dependencies
|
||||
sudo apt update && sudo apt install -y git python3-pip libegl1 sqlite3 libsqlite3-dev libsqlite3-0 ffmpeg libsm6 libxext6
|
||||
# upgrade pip
|
||||
python -m ensurepip --upgrade && python -m pip install --upgrade pip
|
||||
sudo apt update && sudo apt install -y git libegl1 sqlite3 libsqlite3-dev libsqlite3-0 ffmpeg libsm6 libxext6
|
||||
# install terrarium for code sandbox
|
||||
git clone https://github.com/khoj-ai/terrarium.git && cd terrarium && npm install --legacy-peer-deps && mkdir pyodide_cache
|
||||
|
||||
- name: ⬇️ Install Application
|
||||
env:
|
||||
UV_INDEX: "https://download.pytorch.org/whl/cpu"
|
||||
UV_INDEX_STRATEGY: "unsafe-best-match"
|
||||
CUDA_VISIBLE_DEVICES: ""
|
||||
run: |
|
||||
sed -i 's/dynamic = \["version"\]/version = "${{ steps.hatch.outputs.version }}"/' pyproject.toml
|
||||
pip install --upgrade .[dev]
|
||||
uv sync --all-extras
|
||||
|
||||
- name: 📝 Run Eval
|
||||
env:
|
||||
|
||||
22
.github/workflows/test.yml
vendored
22
.github/workflows/test.yml
vendored
@@ -54,10 +54,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python_version }}
|
||||
version: "latest"
|
||||
|
||||
- name: Set up Python ${{ matrix.python_version }}
|
||||
run: uv python install ${{ matrix.python_version }}
|
||||
|
||||
- name: ⏬️ Install Dependencies
|
||||
env:
|
||||
@@ -71,17 +74,12 @@ jobs:
|
||||
run : |
|
||||
apt install -y postgresql postgresql-client && apt install -y postgresql-server-dev-16
|
||||
|
||||
- name: ⬇️ Install pip
|
||||
run: |
|
||||
apt install -y python3-pip
|
||||
python3 -m ensurepip --upgrade
|
||||
python3 -m pip install --upgrade pip
|
||||
|
||||
- name: ⬇️ Install Application
|
||||
env:
|
||||
PIP_EXTRA_INDEX_URL: "https://download.pytorch.org/whl/cpu https://abetlen.github.io/llama-cpp-python/whl/cpu"
|
||||
UV_INDEX: "https://download.pytorch.org/whl/cpu"
|
||||
UV_INDEX_STRATEGY: "unsafe-best-match"
|
||||
CUDA_VISIBLE_DEVICES: ""
|
||||
run: sed -i 's/dynamic = \["version"\]/version = "0.0.0"/' pyproject.toml && pip install --break-system-packages --upgrade .[dev]
|
||||
run: sed -i 's/dynamic = \["version"\]/version = "0.0.0"/' pyproject.toml && uv sync --all-extras
|
||||
|
||||
- name: 🧪 Test Application
|
||||
env:
|
||||
@@ -91,5 +89,5 @@ jobs:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
run: pytest
|
||||
run: uv run pytest
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.3
|
||||
hooks:
|
||||
- id: black
|
||||
- id: ruff-check
|
||||
args: [ --fix ]
|
||||
files: \.py$
|
||||
- id: ruff-format
|
||||
files: \.py$
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
@@ -16,13 +20,6 @@ repos:
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
args: ["--profile", "black", "--filter-files"]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.0.0
|
||||
hooks:
|
||||
|
||||
@@ -35,17 +35,17 @@ RUN sed -i "s/dynamic = \\[\"version\"\\]/version = \"$VERSION\"/" pyproject.tom
|
||||
pip install --no-cache-dir .
|
||||
|
||||
# Build Web App
|
||||
FROM node:23-alpine AS web-app
|
||||
FROM oven/bun:1-alpine AS web-app
|
||||
# Set build optimization env vars
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app/src/interface/web
|
||||
# Install dependencies first (cache layer)
|
||||
COPY src/interface/web/package.json src/interface/web/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY src/interface/web/package.json src/interface/web/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
# Copy source and build
|
||||
COPY src/interface/web/. ./
|
||||
RUN yarn build
|
||||
RUN bun run build
|
||||
|
||||
# Merge the Server and Web App into a Single Image
|
||||
FROM base
|
||||
|
||||
@@ -72,39 +72,31 @@ RUN apt update \
|
||||
&& apt remove -y light-locker xfce4-screensaver xfce4-power-manager || true
|
||||
|
||||
# Create Computer User
|
||||
ENV USERNAME=operator
|
||||
ENV USERNAME=khoj
|
||||
ENV HOME=/home/$USERNAME
|
||||
RUN useradd -m -s /bin/bash -d $HOME -g $USERNAME $USERNAME && echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||
RUN groupadd $USERNAME && \
|
||||
useradd -m -s /bin/bash -d $HOME -g $USERNAME $USERNAME && \
|
||||
echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||
USER $USERNAME
|
||||
WORKDIR $HOME
|
||||
|
||||
# Setup Python
|
||||
RUN git clone https://github.com/pyenv/pyenv.git ~/.pyenv && \
|
||||
cd ~/.pyenv && src/configure && make -C src && cd .. && \
|
||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc && \
|
||||
echo 'command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc && \
|
||||
echo 'eval "$(pyenv init -)"' >> ~/.bashrc
|
||||
ENV PYENV_ROOT="$HOME/.pyenv"
|
||||
ENV PATH="$PYENV_ROOT/bin:$PATH"
|
||||
ENV PYENV_VERSION_MAJOR=3
|
||||
ENV PYENV_VERSION_MINOR=11
|
||||
ENV PYENV_VERSION_PATCH=6
|
||||
ENV PYENV_VERSION=$PYENV_VERSION_MAJOR.$PYENV_VERSION_MINOR.$PYENV_VERSION_PATCH
|
||||
RUN eval "$(pyenv init -)" && \
|
||||
pyenv install $PYENV_VERSION && \
|
||||
pyenv global $PYENV_VERSION && \
|
||||
pyenv rehash
|
||||
ENV PATH="$HOME/.pyenv/shims:$HOME/.pyenv/bin:$PATH"
|
||||
# Install Python using uv and create a virtual environment
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
ENV PYTHON_VERSION=3.11.6
|
||||
RUN uv python pin $PYTHON_VERSION
|
||||
RUN uv venv $HOME/.venv --python $PYTHON_VERSION --seed
|
||||
RUN echo 'export PATH="$HOME/.venv/bin:$PATH"' >> "$HOME/.bashrc"
|
||||
ENV PATH="$HOME/.venv/bin:$PATH"
|
||||
|
||||
# Install Python Packages
|
||||
RUN python3 -m pip install --no-cache-dir \
|
||||
RUN uv pip install --no-cache-dir \
|
||||
pyautogui \
|
||||
Pillow \
|
||||
pyperclip \
|
||||
pygetwindow
|
||||
|
||||
# Setup VNC
|
||||
RUN x11vnc -storepasswd secret /home/operator/.vncpass
|
||||
RUN x11vnc -storepasswd secret /home/khoj/.vncpass
|
||||
|
||||
ARG WIDTH=1024
|
||||
ARG HEIGHT=768
|
||||
@@ -115,13 +107,22 @@ ENV DISPLAY_NUM=$DISPLAY_NUM
|
||||
ENV DISPLAY=":$DISPLAY_NUM"
|
||||
|
||||
# Expose VNC on port 5900
|
||||
# run Xvfb, x11vnc, Xfce (no login manager)
|
||||
EXPOSE 5900
|
||||
CMD ["/bin/sh", "-c", " export XDG_RUNTIME_DIR=/run/user/$(id -u); \
|
||||
mkdir -p $XDG_RUNTIME_DIR && chown $USERNAME:$USERNAME $XDG_RUNTIME_DIR && chmod 0700 $XDG_RUNTIME_DIR; \
|
||||
|
||||
# Start Virtual Display (Xvfb), Desktop Manager (XFCE) and Remote Viewer (X11 VNC)
|
||||
CMD ["/bin/sh", "-c", " \
|
||||
# Create and permission XDG_RUNTIME_DIR with sudo \n\
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u); \
|
||||
sudo mkdir -p $XDG_RUNTIME_DIR && \
|
||||
sudo chown $(id -u):$(id -g) $XDG_RUNTIME_DIR && \
|
||||
sudo chmod 0700 $XDG_RUNTIME_DIR; \
|
||||
\
|
||||
# Start Virtual Display \n\
|
||||
Xvfb $DISPLAY -screen 0 ${WIDTH}x${HEIGHT}x24 -dpi 96 -auth /home/$USERNAME/.Xauthority >/dev/null 2>&1 & \
|
||||
sleep 1; \
|
||||
xauth add $DISPLAY . $(mcookie); \
|
||||
\
|
||||
# Start VNC Server \n\
|
||||
x11vnc -display $DISPLAY -forever -rfbauth /home/$USERNAME/.vncpass -listen 0.0.0.0 -rfbport 5900 >/dev/null 2>&1 & \
|
||||
eval $(dbus-launch --sh-syntax) && \
|
||||
startxfce4 & \
|
||||
|
||||
@@ -30,7 +30,7 @@ git clone https://github.com/khoj-ai/khoj && cd khoj
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
|
||||
# For MacOS or zsh users run this
|
||||
pip install -e '.[dev]'
|
||||
uv sync --all-extras
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="windows" label="Windows">
|
||||
@@ -42,7 +42,7 @@ git clone https://github.com/khoj-ai/khoj && cd khoj
|
||||
python3 -m venv .venv && .venv\Scripts\activate
|
||||
|
||||
# Install Khoj for Development
|
||||
pip install -e '.[dev]'
|
||||
uv sync --all-extras
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="linux" label="Linux">
|
||||
@@ -54,7 +54,7 @@ git clone https://github.com/khoj-ai/khoj && cd khoj
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
|
||||
# Install Khoj for Development
|
||||
pip install -e '.[dev]'
|
||||
uv sync --all-extras
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -129,7 +129,7 @@ Always run `yarn export` to test your front-end changes on http://localhost:4211
|
||||
- Try reactivating the virtual environment and rerunning the `khoj` command.
|
||||
- If it still doesn't work repeat the installation process.
|
||||
2. Python Package Missing
|
||||
- Use `pip install xxx` and try running the `khoj` command.
|
||||
- Use `uv add xxx` and try running the `khoj` command.
|
||||
3. Command `createdb` Not Recognized
|
||||
- make sure path to postgres binaries is included in environment variables. It usually looks something like
|
||||
```
|
||||
|
||||
@@ -34,17 +34,17 @@ RUN sed -i "s/dynamic = \\[\"version\"\\]/version = \"$VERSION\"/" pyproject.tom
|
||||
pip install --no-cache-dir -e .[prod]
|
||||
|
||||
# Build Web App
|
||||
FROM node:20-alpine AS web-app
|
||||
FROM oven/bun:1-alpine AS web-app
|
||||
# Set build optimization env vars
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app/src/interface/web
|
||||
# Install dependencies first (cache layer)
|
||||
COPY src/interface/web/package.json src/interface/web/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY src/interface/web/package.json src/interface/web/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
# Copy source and build
|
||||
COPY src/interface/web/. ./
|
||||
RUN yarn build
|
||||
RUN bun run build
|
||||
|
||||
# Merge the Server and Web App into a Single Image
|
||||
FROM base
|
||||
|
||||
@@ -50,6 +50,7 @@ dependencies = [
|
||||
"pydantic[email] >= 2.0.0",
|
||||
"pyyaml ~= 6.0",
|
||||
"rich >= 13.3.1",
|
||||
"click < 8.2.2",
|
||||
"schedule == 1.1.0",
|
||||
"sentence-transformers == 3.4.1",
|
||||
"einops == 0.8.0",
|
||||
@@ -122,7 +123,7 @@ dev = [
|
||||
"freezegun >= 1.2.0",
|
||||
"factory-boy >= 3.2.1",
|
||||
"mypy >= 1.0.1",
|
||||
"black >= 23.1.0",
|
||||
"ruff >= 0.12.0",
|
||||
"pre-commit >= 3.0.4",
|
||||
"gitpython ~= 3.1.43",
|
||||
"datasets",
|
||||
@@ -149,11 +150,31 @@ non_interactive = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
|
||||
[tool.black]
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"] # Enable error, warning, and import checks
|
||||
ignore = [
|
||||
"E501", # Ignore line length
|
||||
"F405", # Ignore name not defined (e.g., from imports)
|
||||
"E402", # Ignore module level import not at top of file
|
||||
]
|
||||
unfixable = ["F841"] # Don't auto-remove unused variables
|
||||
exclude = [ "tests/*.py" ]
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"src/khoj/main.py" = [
|
||||
"I001", # Ignore Import order
|
||||
"I002", # Ignore Import not at top of file
|
||||
"E402", # Ignore module level import not at top of file
|
||||
]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["khoj"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers"
|
||||
|
||||
@@ -7,11 +7,11 @@ INSTALL_FULL=false
|
||||
DEVCONTAINER=false
|
||||
for arg in "$@"
|
||||
do
|
||||
if [ "$arg" == "--full" ]
|
||||
if [ "$arg" = "--full" ]
|
||||
then
|
||||
INSTALL_FULL=true
|
||||
fi
|
||||
if [ "$arg" == "--devcontainer" ]
|
||||
if [ "$arg" = "--devcontainer" ]
|
||||
then
|
||||
DEVCONTAINER=true
|
||||
fi
|
||||
@@ -24,26 +24,40 @@ if [ "$DEVCONTAINER" = true ]; then
|
||||
# Use devcontainer launch.json
|
||||
mkdir -p .vscode && cp .devcontainer/launch.json .vscode/launch.json
|
||||
|
||||
# Activate the pre-installed venv (no need to create new one)
|
||||
echo "Using Python environment at /opt/venv"
|
||||
# PATH should already include /opt/venv/bin from Dockerfile
|
||||
|
||||
# Install khoj in editable mode (dependencies already installed)
|
||||
python3 -m pip install -e '.[dev]'
|
||||
# Install Server App using pre-installed dependencies
|
||||
echo "Setup Server App with UV. Use pre-installed dependencies in $UV_PROJECT_ENVIRONMENT."
|
||||
sed -i "s/dynamic = \\[\"version\"\\]/version = \"$VERSION\"/" pyproject.toml
|
||||
cp /opt/uv.lock.linux uv.lock
|
||||
uv sync --all-extras
|
||||
|
||||
# Install Web App using cached dependencies
|
||||
echo "Installing Web App using cached dependencies..."
|
||||
echo "Setup Web App with Bun. Use pre-installed dependencies in /opt/khoj_web."
|
||||
cd "$PROJECT_ROOT/src/interface/web"
|
||||
yarn install --cache-folder /opt/yarn-cache && yarn export
|
||||
ln -sf /opt/khoj_web/node_modules node_modules
|
||||
bun install && bun run ciexport
|
||||
else
|
||||
# Standard setup
|
||||
echo "Installing Server App..."
|
||||
cd "$PROJECT_ROOT"
|
||||
python3 -m venv .venv && . .venv/bin/activate && python3 -m pip install -e '.[dev]'
|
||||
if command -v uv &> /dev/null
|
||||
then
|
||||
uv venv
|
||||
uv sync --all-extras
|
||||
else
|
||||
python3 -m venv .venv && . .venv/bin/activate
|
||||
python3 -m pip install -e '.[dev]'
|
||||
fi
|
||||
|
||||
echo "Installing Web App..."
|
||||
cd "$PROJECT_ROOT/src/interface/web"
|
||||
yarn install && yarn export
|
||||
if command -v bun &> /dev/null
|
||||
then
|
||||
echo "using Bun."
|
||||
bun install && bun run export
|
||||
else
|
||||
echo "using Yarn."
|
||||
yarn install && yarn export
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install Obsidian App
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn run lint-staged
|
||||
yarn test
|
||||
bun run lint-staged
|
||||
bun run test
|
||||
|
||||
@@ -5,19 +5,19 @@ This is a [Next.js](https://nextjs.org/) project.
|
||||
First, install the dependencies:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
bun install
|
||||
```
|
||||
|
||||
In case you run into any dependency linking issues, you can try running:
|
||||
|
||||
```bash
|
||||
yarn add next
|
||||
bun add next
|
||||
```
|
||||
|
||||
### Run the development server:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
bun dev
|
||||
```
|
||||
|
||||
Make sure the `rewrites` in `next.config.mjs` are set up correctly for your environment. The rewrites are used to proxy requests to the API server.
|
||||
@@ -44,27 +44,30 @@ You can start editing the page by modifying any of the `.tsx` pages. The page au
|
||||
We've setup a utility command for building and serving the built files. This is useful for testing the production build locally.
|
||||
|
||||
1. Exporting code
|
||||
To build the files once and serve them, run:
|
||||
To build the files once and serve them, run:
|
||||
|
||||
```bash
|
||||
yarn export
|
||||
bun export
|
||||
```
|
||||
|
||||
If you're using Windows:
|
||||
```bash
|
||||
yarn windowsexport
|
||||
```
|
||||
|
||||
```bash
|
||||
bun windowsexport
|
||||
```
|
||||
|
||||
2. Continuously building code
|
||||
|
||||
To keep building the files and serving them, run:
|
||||
|
||||
```bash
|
||||
yarn watch
|
||||
bun watch
|
||||
```
|
||||
|
||||
If you're using Windows:
|
||||
|
||||
```bash
|
||||
yarn windowswatch
|
||||
bun windowswatch
|
||||
```
|
||||
|
||||
Now you should be able to load your custom pages from the Khoj app at http://localhost:42110/. To server any of the built files, you should update the routes in the `web_client.py` like so, where `new_file` is the new page you've added in this repo:
|
||||
|
||||
@@ -344,14 +344,16 @@ export default function Agents() {
|
||||
/>
|
||||
<span className="font-bold">How it works</span> Use any of these
|
||||
specialized personas to tune your conversation to your needs.
|
||||
{
|
||||
!isSubscribed && (
|
||||
<span>
|
||||
{" "}
|
||||
<Link href="/settings" className="font-bold">Upgrade your plan</Link> to leverage custom models. You will fallback to the default model when chatting.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{!isSubscribed && (
|
||||
<span>
|
||||
{" "}
|
||||
<Link href="/settings" className="font-bold">
|
||||
Upgrade your plan
|
||||
</Link>{" "}
|
||||
to leverage custom models. You will fallback to the
|
||||
default model when chatting.
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="pt-6 md:pt-8">
|
||||
|
||||
@@ -90,11 +90,9 @@ export interface UserConfig {
|
||||
|
||||
export function useUserConfig(detailed: boolean = false) {
|
||||
const url = `/api/settings?detailed=${detailed}`;
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<UserConfig>(url, fetcher, { revalidateOnFocus: false });
|
||||
const { data, error, isLoading } = useSWR<UserConfig>(url, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
if (error || !data || data?.detail === "Forbidden") {
|
||||
return { data: null, error, isLoading };
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { PopoverProps } from "@radix-ui/react-popover"
|
||||
import { PopoverProps } from "@radix-ui/react-popover";
|
||||
|
||||
import { Check, CaretUpDown } from "@phosphor-icons/react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useIsMobileWidth, useMutationObserver } from "@/app/common/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -17,11 +17,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
import { ModelOptions, useUserConfig } from "./auth";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
@@ -35,7 +31,7 @@ interface ModelSelectorProps extends PopoverProps {
|
||||
}
|
||||
|
||||
export function ModelSelector({ ...props }: ModelSelectorProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [peekedModel, setPeekedModel] = useState<ModelOptions | undefined>(undefined);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelOptions | undefined>(undefined);
|
||||
const { data: userConfig, error, isLoading: isLoadingUserConfig } = useUserConfig(true);
|
||||
@@ -48,14 +44,18 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
|
||||
if (userConfig) {
|
||||
setModels(userConfig.chat_model_options);
|
||||
if (!props.initialModel) {
|
||||
const selectedChatModelOption = userConfig.chat_model_options.find(model => model.id === userConfig.selected_chat_model_config);
|
||||
const selectedChatModelOption = userConfig.chat_model_options.find(
|
||||
(model) => model.id === userConfig.selected_chat_model_config,
|
||||
);
|
||||
if (!selectedChatModelOption && userConfig.chat_model_options.length > 0) {
|
||||
setSelectedModel(userConfig.chat_model_options[0]);
|
||||
} else {
|
||||
setSelectedModel(selectedChatModelOption);
|
||||
}
|
||||
} else {
|
||||
const model = userConfig.chat_model_options.find(model => model.name === props.initialModel);
|
||||
const model = userConfig.chat_model_options.find(
|
||||
(model) => model.name === props.initialModel,
|
||||
);
|
||||
setSelectedModel(model);
|
||||
}
|
||||
}
|
||||
@@ -68,15 +68,11 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
|
||||
}, [selectedModel, userConfig, props.onSelect]);
|
||||
|
||||
if (isLoadingUserConfig) {
|
||||
return (
|
||||
<Skeleton className="w-full h-10" />
|
||||
);
|
||||
return <Skeleton className="w-full h-10" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-sm text-error">{error.message}</div>
|
||||
);
|
||||
return <div className="text-sm text-error">{error.message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -92,30 +88,85 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
|
||||
disabled={props.disabled ?? false}
|
||||
>
|
||||
<p className="truncate">
|
||||
{selectedModel ? selectedModel.name?.substring(0, 20) : "Select a model..."}
|
||||
{selectedModel
|
||||
? selectedModel.name?.substring(0, 20)
|
||||
: "Select a model..."}
|
||||
</p>
|
||||
<CaretUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[250px] p-0">
|
||||
{
|
||||
isMobileWidth ?
|
||||
{isMobileWidth ? (
|
||||
<div>
|
||||
<Command loop>
|
||||
<CommandList className="h-[var(--cmdk-list-height)]">
|
||||
<CommandInput placeholder="Search Models..." />
|
||||
<CommandEmpty>No Models found.</CommandEmpty>
|
||||
<CommandGroup key={"models"} heading={"Models"}>
|
||||
{models &&
|
||||
models.length > 0 &&
|
||||
models.map((model) => (
|
||||
<ModelItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
isSelected={selectedModel?.id === model.id}
|
||||
onPeek={(model) => setPeekedModel(model)}
|
||||
onSelect={() => {
|
||||
setSelectedModel(model);
|
||||
setOpen(false);
|
||||
}}
|
||||
isActive={props.isActive}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
) : (
|
||||
<HoverCard>
|
||||
<HoverCardContent
|
||||
side="left"
|
||||
align="start"
|
||||
forceMount
|
||||
className="min-h-[280px]"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<h4 className="font-medium leading-none">
|
||||
{peekedModel?.name}
|
||||
</h4>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{peekedModel?.description}
|
||||
</div>
|
||||
{peekedModel?.strengths ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<h5 className="text-sm font-medium leading-none">
|
||||
Strengths
|
||||
</h5>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{peekedModel.strengths}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
<div>
|
||||
<HoverCardTrigger />
|
||||
<Command loop>
|
||||
<CommandList className="h-[var(--cmdk-list-height)]">
|
||||
<CommandInput placeholder="Search Models..." />
|
||||
<CommandEmpty>No Models found.</CommandEmpty>
|
||||
<CommandGroup key={"models"} heading={"Models"}>
|
||||
{models && models.length > 0 && models
|
||||
.map((model) => (
|
||||
{models &&
|
||||
models.length > 0 &&
|
||||
models.map((model) => (
|
||||
<ModelItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
isSelected={selectedModel?.id === model.id}
|
||||
onPeek={(model) => setPeekedModel(model)}
|
||||
onSelect={() => {
|
||||
setSelectedModel(model)
|
||||
setOpen(false)
|
||||
setSelectedModel(model);
|
||||
setOpen(false);
|
||||
}}
|
||||
isActive={props.isActive}
|
||||
/>
|
||||
@@ -124,74 +175,24 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
:
|
||||
<HoverCard>
|
||||
<HoverCardContent
|
||||
side="left"
|
||||
align="start"
|
||||
forceMount
|
||||
className="min-h-[280px]"
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<h4 className="font-medium leading-none">{peekedModel?.name}</h4>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{peekedModel?.description}
|
||||
</div>
|
||||
{peekedModel?.strengths ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<h5 className="text-sm font-medium leading-none">
|
||||
Strengths
|
||||
</h5>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{peekedModel.strengths}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
<div>
|
||||
<HoverCardTrigger />
|
||||
<Command loop>
|
||||
<CommandList className="h-[var(--cmdk-list-height)]">
|
||||
<CommandInput placeholder="Search Models..." />
|
||||
<CommandEmpty>No Models found.</CommandEmpty>
|
||||
<CommandGroup key={"models"} heading={"Models"}>
|
||||
{models && models.length > 0 && models
|
||||
.map((model) => (
|
||||
<ModelItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
isSelected={selectedModel?.id === model.id}
|
||||
onPeek={(model) => setPeekedModel(model)}
|
||||
onSelect={() => {
|
||||
setSelectedModel(model)
|
||||
setOpen(false)
|
||||
}}
|
||||
isActive={props.isActive}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
</HoverCard>
|
||||
}
|
||||
</HoverCard>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelItemProps {
|
||||
model: ModelOptions,
|
||||
isSelected: boolean,
|
||||
onSelect: () => void,
|
||||
onPeek: (model: ModelOptions) => void
|
||||
isActive?: boolean
|
||||
model: ModelOptions;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onPeek: (model: ModelOptions) => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
function ModelItem({ model, isSelected, onSelect, onPeek, isActive }: ModelItemProps) {
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useMutationObserver(ref, (mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
@@ -200,10 +201,10 @@ function ModelItem({ model, isSelected, onSelect, onPeek, isActive }: ModelItemP
|
||||
mutation.attributeName === "aria-selected" &&
|
||||
ref.current?.getAttribute("aria-selected") === "true"
|
||||
) {
|
||||
onPeek(model)
|
||||
onPeek(model);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
@@ -213,10 +214,9 @@ function ModelItem({ model, isSelected, onSelect, onPeek, isActive }: ModelItemP
|
||||
className="data-[selected=true]:bg-muted data-[selected=true]:text-secondary-foreground"
|
||||
disabled={!isActive && model.tier !== "free"}
|
||||
>
|
||||
{model.name} {model.tier === "standard" && <span className="text-green-500 ml-2">(Futurist)</span>}
|
||||
<Check
|
||||
className={cn("ml-auto", isSelected ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{model.name}{" "}
|
||||
{model.tier === "standard" && <span className="text-green-500 ml-2">(Futurist)</span>}
|
||||
<Check className={cn("ml-auto", isSelected ? "opacity-100" : "opacity-0")} />
|
||||
</CommandItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
export interface LocationData {
|
||||
city?: string;
|
||||
@@ -78,16 +78,16 @@ export const useMutationObserver = (
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
}
|
||||
},
|
||||
) => {
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
const observer = new MutationObserver(callback)
|
||||
observer.observe(ref.current, options)
|
||||
return () => observer.disconnect()
|
||||
const observer = new MutationObserver(callback);
|
||||
observer.observe(ref.current, options);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [ref, callback, options])
|
||||
}
|
||||
}, [ref, callback, options]);
|
||||
};
|
||||
|
||||
export function useIsDarkMode() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
@@ -1061,12 +1061,27 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const filteredFiles = allFileOptions.filter(file =>
|
||||
file.toLowerCase().includes(fileSearchValue.toLowerCase())
|
||||
const filteredFiles =
|
||||
allFileOptions.filter((file) =>
|
||||
file
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
fileSearchValue.toLowerCase(),
|
||||
),
|
||||
);
|
||||
const currentFiles =
|
||||
props.form.getValues("files") ||
|
||||
[];
|
||||
const newFiles = [
|
||||
...new Set([
|
||||
...currentFiles,
|
||||
...filteredFiles,
|
||||
]),
|
||||
];
|
||||
props.form.setValue(
|
||||
"files",
|
||||
newFiles,
|
||||
);
|
||||
const currentFiles = props.form.getValues("files") || [];
|
||||
const newFiles = [...new Set([...currentFiles, ...filteredFiles])];
|
||||
props.form.setValue("files", newFiles);
|
||||
}}
|
||||
>
|
||||
Select All
|
||||
@@ -1078,12 +1093,28 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const filteredFiles = allFileOptions.filter(file =>
|
||||
file.toLowerCase().includes(fileSearchValue.toLowerCase())
|
||||
const filteredFiles =
|
||||
allFileOptions.filter((file) =>
|
||||
file
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
fileSearchValue.toLowerCase(),
|
||||
),
|
||||
);
|
||||
const currentFiles =
|
||||
props.form.getValues("files") ||
|
||||
[];
|
||||
const newFiles =
|
||||
currentFiles.filter(
|
||||
(file) =>
|
||||
!filteredFiles.includes(
|
||||
file,
|
||||
),
|
||||
);
|
||||
props.form.setValue(
|
||||
"files",
|
||||
newFiles,
|
||||
);
|
||||
const currentFiles = props.form.getValues("files") || [];
|
||||
const newFiles = currentFiles.filter(file => !filteredFiles.includes(file));
|
||||
props.form.setValue("files", newFiles);
|
||||
}}
|
||||
>
|
||||
Deselect All
|
||||
|
||||
@@ -127,7 +127,7 @@ function renameConversation(conversationId: string, newTitle: string) {
|
||||
},
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => { })
|
||||
.then((data) => {})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
return;
|
||||
@@ -171,7 +171,7 @@ function deleteConversation(conversationId: string) {
|
||||
mutate("/api/chat/sessions");
|
||||
}
|
||||
})
|
||||
.then((data) => { })
|
||||
.then((data) => {})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
return;
|
||||
@@ -245,9 +245,7 @@ export function FilesMenu(props: FilesMenuProps) {
|
||||
Context
|
||||
<p>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{
|
||||
error ? "Failed to load files" : "Failed to load selected files"
|
||||
}
|
||||
{error ? "Failed to load files" : "Failed to load selected files"}
|
||||
</span>
|
||||
</p>
|
||||
</h4>
|
||||
@@ -257,7 +255,7 @@ export function FilesMenu(props: FilesMenuProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!files) return <InlineLoading />;
|
||||
@@ -443,10 +441,7 @@ function SessionsAndFiles(props: SessionsAndFilesProps) {
|
||||
<div>
|
||||
{props.sideBarOpen && (
|
||||
<ScrollArea>
|
||||
<ScrollAreaScrollbar
|
||||
orientation="vertical"
|
||||
className="h-full w-2.5"
|
||||
/>
|
||||
<ScrollAreaScrollbar orientation="vertical" className="h-full w-2.5" />
|
||||
<div className="p-0 m-0">
|
||||
{props.subsetOrganizedData != null &&
|
||||
Object.keys(props.subsetOrganizedData)
|
||||
@@ -471,7 +466,9 @@ function SessionsAndFiles(props: SessionsAndFilesProps) {
|
||||
agent_name={chatHistory.agent_name}
|
||||
agent_color={chatHistory.agent_color}
|
||||
agent_icon={chatHistory.agent_icon}
|
||||
agent_is_hidden={chatHistory.agent_is_hidden}
|
||||
agent_is_hidden={
|
||||
chatHistory.agent_is_hidden
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@@ -709,7 +706,7 @@ function ChatSession(props: ChatHistory) {
|
||||
className="flex items-center gap-2 no-underline"
|
||||
>
|
||||
<p
|
||||
className={`${styles.session} ${props.compressed ? styles.compressed : 'max-w-[15rem] md:max-w-[22rem]'}`}
|
||||
className={`${styles.session} ${props.compressed ? styles.compressed : "max-w-[15rem] md:max-w-[22rem]"}`}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
|
||||
@@ -48,13 +48,12 @@ async function openChat(userData: UserProfile | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Menu items.
|
||||
const items = [
|
||||
{
|
||||
title: "Home",
|
||||
url: "/",
|
||||
icon: HouseSimple
|
||||
icon: HouseSimple,
|
||||
},
|
||||
{
|
||||
title: "Agents",
|
||||
|
||||
@@ -52,7 +52,7 @@ interface TrainOfThoughtFrame {
|
||||
}
|
||||
|
||||
interface TrainOfThoughtGroup {
|
||||
type: 'video' | 'text';
|
||||
type: "video" | "text";
|
||||
frames?: TrainOfThoughtFrame[];
|
||||
textEntries?: TrainOfThoughtObject[];
|
||||
}
|
||||
@@ -65,7 +65,9 @@ interface TrainOfThoughtComponentProps {
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
function extractTrainOfThoughtGroups(trainOfThought?: TrainOfThoughtObject[]): TrainOfThoughtGroup[] {
|
||||
function extractTrainOfThoughtGroups(
|
||||
trainOfThought?: TrainOfThoughtObject[],
|
||||
): TrainOfThoughtGroup[] {
|
||||
if (!trainOfThought) return [];
|
||||
|
||||
const groups: TrainOfThoughtGroup[] = [];
|
||||
@@ -94,8 +96,8 @@ function extractTrainOfThoughtGroups(trainOfThought?: TrainOfThoughtObject[]): T
|
||||
// If we have accumulated text entries, add them as a text group
|
||||
if (currentTextEntries.length > 0) {
|
||||
groups.push({
|
||||
type: 'text',
|
||||
textEntries: [...currentTextEntries]
|
||||
type: "text",
|
||||
textEntries: [...currentTextEntries],
|
||||
});
|
||||
currentTextEntries = [];
|
||||
}
|
||||
@@ -116,8 +118,8 @@ function extractTrainOfThoughtGroups(trainOfThought?: TrainOfThoughtObject[]): T
|
||||
// If we have accumulated video frames, add them as a video group
|
||||
if (currentVideoFrames.length > 0) {
|
||||
groups.push({
|
||||
type: 'video',
|
||||
frames: [...currentVideoFrames]
|
||||
type: "video",
|
||||
frames: [...currentVideoFrames],
|
||||
});
|
||||
currentVideoFrames = [];
|
||||
}
|
||||
@@ -130,14 +132,14 @@ function extractTrainOfThoughtGroups(trainOfThought?: TrainOfThoughtObject[]): T
|
||||
// Add any remaining frames/entries
|
||||
if (currentVideoFrames.length > 0) {
|
||||
groups.push({
|
||||
type: 'video',
|
||||
frames: currentVideoFrames
|
||||
type: "video",
|
||||
frames: currentVideoFrames,
|
||||
});
|
||||
}
|
||||
if (currentTextEntries.length > 0) {
|
||||
groups.push({
|
||||
type: 'text',
|
||||
textEntries: currentTextEntries
|
||||
type: "text",
|
||||
textEntries: currentTextEntries,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,10 +179,10 @@ function TrainOfThoughtComponent(props: TrainOfThoughtComponentProps) {
|
||||
// Convert string array to TrainOfThoughtObject array if needed
|
||||
let trainOfThoughtObjects: TrainOfThoughtObject[];
|
||||
|
||||
if (typeof props.trainOfThought[0] === 'string') {
|
||||
if (typeof props.trainOfThought[0] === "string") {
|
||||
trainOfThoughtObjects = (props.trainOfThought as string[]).map((data, index) => ({
|
||||
type: 'text',
|
||||
data: data
|
||||
type: "text",
|
||||
data: data,
|
||||
}));
|
||||
} else {
|
||||
trainOfThoughtObjects = props.trainOfThought as TrainOfThoughtObject[];
|
||||
@@ -221,28 +223,37 @@ function TrainOfThoughtComponent(props: TrainOfThoughtComponentProps) {
|
||||
<motion.div initial="closed" animate="open" exit="closed" variants={variants}>
|
||||
{trainOfThoughtGroups.map((group, groupIndex) => (
|
||||
<div key={`train-group-${groupIndex}`}>
|
||||
{group.type === 'video' && group.frames && group.frames.length > 0 && (
|
||||
<TrainOfThoughtVideoPlayer
|
||||
frames={group.frames}
|
||||
autoPlay={false}
|
||||
playbackSpeed={1500}
|
||||
/>
|
||||
)}
|
||||
{group.type === 'text' && group.textEntries && group.textEntries.map((entry, entryIndex) => {
|
||||
const lastIndex = trainOfThoughtGroups.length - 1;
|
||||
const isLastGroup = groupIndex === lastIndex;
|
||||
const isLastEntry = entryIndex === group.textEntries!.length - 1;
|
||||
const isPrimaryEntry = isLastGroup && isLastEntry && props.lastMessage && !props.completed;
|
||||
|
||||
return (
|
||||
<TrainOfThought
|
||||
key={`train-text-${groupIndex}-${entryIndex}-${entry.data.length}`}
|
||||
message={entry.data}
|
||||
primary={isPrimaryEntry}
|
||||
agentColor={props.agentColor}
|
||||
{group.type === "video" &&
|
||||
group.frames &&
|
||||
group.frames.length > 0 && (
|
||||
<TrainOfThoughtVideoPlayer
|
||||
frames={group.frames}
|
||||
autoPlay={false}
|
||||
playbackSpeed={1500}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
{group.type === "text" &&
|
||||
group.textEntries &&
|
||||
group.textEntries.map((entry, entryIndex) => {
|
||||
const lastIndex = trainOfThoughtGroups.length - 1;
|
||||
const isLastGroup = groupIndex === lastIndex;
|
||||
const isLastEntry =
|
||||
entryIndex === group.textEntries!.length - 1;
|
||||
const isPrimaryEntry =
|
||||
isLastGroup &&
|
||||
isLastEntry &&
|
||||
props.lastMessage &&
|
||||
!props.completed;
|
||||
|
||||
return (
|
||||
<TrainOfThought
|
||||
key={`train-text-${groupIndex}-${entryIndex}-${entry.data.length}`}
|
||||
message={entry.data}
|
||||
primary={isPrimaryEntry}
|
||||
agentColor={props.agentColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
@@ -300,7 +311,8 @@ export default function ChatHistory(props: ChatHistoryProps) {
|
||||
// ResizeObserver to handle content height changes (e.g., images loading)
|
||||
useEffect(() => {
|
||||
const contentWrapper = scrollableContentWrapperRef.current;
|
||||
const scrollViewport = scrollAreaRef.current?.querySelector<HTMLElement>(scrollAreaSelector);
|
||||
const scrollViewport =
|
||||
scrollAreaRef.current?.querySelector<HTMLElement>(scrollAreaSelector);
|
||||
|
||||
if (!contentWrapper || !scrollViewport) return;
|
||||
|
||||
@@ -308,14 +320,18 @@ export default function ChatHistory(props: ChatHistoryProps) {
|
||||
// Check current scroll position to decide if auto-scroll is warranted
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollViewport;
|
||||
const bottomThreshold = 50;
|
||||
const currentlyNearBottom = (scrollHeight - (scrollTop + clientHeight)) <= bottomThreshold;
|
||||
const currentlyNearBottom =
|
||||
scrollHeight - (scrollTop + clientHeight) <= bottomThreshold;
|
||||
|
||||
if (currentlyNearBottom) {
|
||||
// Only auto-scroll if there are incoming messages being processed
|
||||
if (props.incomingMessages && props.incomingMessages.length > 0) {
|
||||
const lastMessage = props.incomingMessages[props.incomingMessages.length - 1];
|
||||
// If the last message is not completed, or it just completed (indicated by incompleteIncomingMessageIndex still being set)
|
||||
if (!lastMessage.completed || (lastMessage.completed && incompleteIncomingMessageIndex !== null)) {
|
||||
if (
|
||||
!lastMessage.completed ||
|
||||
(lastMessage.completed && incompleteIncomingMessageIndex !== null)
|
||||
) {
|
||||
scrollToBottom(true); // Use instant scroll
|
||||
}
|
||||
}
|
||||
@@ -463,7 +479,12 @@ export default function ChatHistory(props: ChatHistoryProps) {
|
||||
});
|
||||
});
|
||||
// Optimistically set, the scroll listener will verify
|
||||
if (instant || scrollAreaEl && (scrollAreaEl.scrollHeight - (scrollAreaEl.scrollTop + scrollAreaEl.clientHeight)) < 5) {
|
||||
if (
|
||||
instant ||
|
||||
(scrollAreaEl &&
|
||||
scrollAreaEl.scrollHeight - (scrollAreaEl.scrollTop + scrollAreaEl.clientHeight) <
|
||||
5)
|
||||
) {
|
||||
setIsNearBottom(true);
|
||||
}
|
||||
};
|
||||
@@ -626,16 +647,19 @@ export default function ChatHistory(props: ChatHistoryProps) {
|
||||
conversationId={props.conversationId}
|
||||
turnId={messageTurnId}
|
||||
/>
|
||||
{message.trainOfThought && message.trainOfThought.length > 0 && (
|
||||
<TrainOfThoughtComponent
|
||||
trainOfThought={message.trainOfThought}
|
||||
lastMessage={index === incompleteIncomingMessageIndex}
|
||||
agentColor={data?.agent?.color || "orange"}
|
||||
key={`${index}trainOfThought-${message.trainOfThought.length}-${message.trainOfThought.map(t => t.length).join('-')}`}
|
||||
keyId={`${index}trainOfThought`}
|
||||
completed={message.completed}
|
||||
/>
|
||||
)}
|
||||
{message.trainOfThought &&
|
||||
message.trainOfThought.length > 0 && (
|
||||
<TrainOfThoughtComponent
|
||||
trainOfThought={message.trainOfThought}
|
||||
lastMessage={
|
||||
index === incompleteIncomingMessageIndex
|
||||
}
|
||||
agentColor={data?.agent?.color || "orange"}
|
||||
key={`${index}trainOfThought-${message.trainOfThought.length}-${message.trainOfThought.map((t) => t.length).join("-")}`}
|
||||
keyId={`${index}trainOfThought`}
|
||||
completed={message.completed}
|
||||
/>
|
||||
)}
|
||||
<ChatMessage
|
||||
key={`${index}incoming`}
|
||||
isMobileWidth={isMobileWidth}
|
||||
|
||||
@@ -817,38 +817,44 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{props.chatMessage.by === "khoj" && props.onRetryMessage && props.isLastMessage && (
|
||||
<button
|
||||
title="Retry"
|
||||
className={`${styles.retryButton}`}
|
||||
onClick={() => {
|
||||
const turnId = props.chatMessage.turnId || props.turnId;
|
||||
const query = props.chatMessage.rawQuery || props.chatMessage.intent?.query;
|
||||
console.log("Retry button clicked for turnId:", turnId);
|
||||
console.log("ChatMessage data:", {
|
||||
rawQuery: props.chatMessage.rawQuery,
|
||||
intent: props.chatMessage.intent,
|
||||
message: props.chatMessage.message
|
||||
});
|
||||
console.log("Extracted query:", query);
|
||||
if (query) {
|
||||
props.onRetryMessage?.(query, turnId);
|
||||
} else {
|
||||
console.error("No original query found for retry");
|
||||
// Fallback: try to get from a previous user message or show an input dialog
|
||||
const fallbackQuery = prompt("Enter the original query to retry:");
|
||||
if (fallbackQuery) {
|
||||
props.onRetryMessage?.(fallbackQuery, turnId);
|
||||
{props.chatMessage.by === "khoj" &&
|
||||
props.onRetryMessage &&
|
||||
props.isLastMessage && (
|
||||
<button
|
||||
title="Retry"
|
||||
className={`${styles.retryButton}`}
|
||||
onClick={() => {
|
||||
const turnId = props.chatMessage.turnId || props.turnId;
|
||||
const query =
|
||||
props.chatMessage.rawQuery ||
|
||||
props.chatMessage.intent?.query;
|
||||
console.log("Retry button clicked for turnId:", turnId);
|
||||
console.log("ChatMessage data:", {
|
||||
rawQuery: props.chatMessage.rawQuery,
|
||||
intent: props.chatMessage.intent,
|
||||
message: props.chatMessage.message,
|
||||
});
|
||||
console.log("Extracted query:", query);
|
||||
if (query) {
|
||||
props.onRetryMessage?.(query, turnId);
|
||||
} else {
|
||||
console.error("No original query found for retry");
|
||||
// Fallback: try to get from a previous user message or show an input dialog
|
||||
const fallbackQuery = prompt(
|
||||
"Enter the original query to retry:",
|
||||
);
|
||||
if (fallbackQuery) {
|
||||
props.onRetryMessage?.(fallbackQuery, turnId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowClockwise
|
||||
alt="Retry Message"
|
||||
className="hsl(var(--muted-foreground)) hover:text-blue-500"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
}}
|
||||
>
|
||||
<ArrowClockwise
|
||||
alt="Retry Message"
|
||||
className="hsl(var(--muted-foreground)) hover:text-blue-500"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title="Copy"
|
||||
className={`${styles.copyButton}`}
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { ArrowsDownUp, CaretCircleDown, CheckCircle, Circle, CircleNotch, PersonSimpleTaiChi, Sparkle } from "@phosphor-icons/react";
|
||||
import {
|
||||
ArrowsDownUp,
|
||||
CaretCircleDown,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
CircleNotch,
|
||||
PersonSimpleTaiChi,
|
||||
Sparkle,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ModelSelector } from "@/app/common/modelSelector";
|
||||
import { FilesMenu } from "../allConversations/allConversations";
|
||||
@@ -14,21 +33,39 @@ import { mutate } from "swr";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { AgentData } from "../agentCard/agentCard";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAvailableIcons, getIconForSlashCommand, getIconFromIconName } from "@/app/common/iconUtils";
|
||||
import {
|
||||
getAvailableIcons,
|
||||
getIconForSlashCommand,
|
||||
getIconFromIconName,
|
||||
} from "@/app/common/iconUtils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { TooltipContent } from "@radix-ui/react-tooltip";
|
||||
import { useAuthenticatedData } from "@/app/common/auth";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { convertColorToTextClass, tailwindColors } from "@/app/common/colorUtils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
|
||||
interface ChatSideBarProps {
|
||||
conversationId: string;
|
||||
isOpen: boolean;
|
||||
@@ -40,15 +77,10 @@ interface ChatSideBarProps {
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
export function ChatSidebar({ ...props }: ChatSideBarProps) {
|
||||
|
||||
if (props.isMobileWidth) {
|
||||
return (
|
||||
<Sheet
|
||||
open={props.isOpen}
|
||||
onOpenChange={props.onOpenChange}>
|
||||
<SheetContent
|
||||
className="w-[300px] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
>
|
||||
<Sheet open={props.isOpen} onOpenChange={props.onOpenChange}>
|
||||
<SheetContent className="w-[300px] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden">
|
||||
<ChatSidebarInternal {...props} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -110,14 +142,14 @@ function AgentCreationForm(props: IAgentCreationProps) {
|
||||
fetch(createAgentUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data: AgentData | AgentError) => {
|
||||
console.log("Success:", data);
|
||||
if ('detail' in data) {
|
||||
if ("detail" in data) {
|
||||
setError(`Error creating agent: ${data.detail}`);
|
||||
setIsCreating(false);
|
||||
return;
|
||||
@@ -142,162 +174,151 @@ function AgentCreationForm(props: IAgentCreationProps) {
|
||||
}, [customAgentName, customAgentIcon, customAgentColor]);
|
||||
|
||||
return (
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
>
|
||||
<Button className="w-full" variant="secondary">
|
||||
Create Agent
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
{
|
||||
doneCreating && createdSlug ? (
|
||||
<DialogTitle>
|
||||
Created {customAgentName}
|
||||
</DialogTitle>
|
||||
) : (
|
||||
<DialogTitle>
|
||||
Create a New Agent
|
||||
</DialogTitle>
|
||||
)
|
||||
}
|
||||
{doneCreating && createdSlug ? (
|
||||
<DialogTitle>Created {customAgentName}</DialogTitle>
|
||||
) : (
|
||||
<DialogTitle>Create a New Agent</DialogTitle>
|
||||
)}
|
||||
<DialogClose />
|
||||
<DialogDescription>
|
||||
If these settings have been helpful, create a dedicated agent you can re-use across conversations.
|
||||
If these settings have been helpful, create a dedicated agent you can re-use
|
||||
across conversations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{
|
||||
doneCreating && createdSlug ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20
|
||||
}}
|
||||
>
|
||||
<CheckCircle
|
||||
className="w-16 h-16 text-green-500"
|
||||
weight="fill"
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-center text-lg font-medium text-accent-foreground"
|
||||
>
|
||||
Created successfully!
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Link href={`/agents?agent=${createdSlug}`}>
|
||||
<Button variant="secondary" className="mt-2">
|
||||
Manage Agent
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
{doneCreating && createdSlug ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20,
|
||||
}}
|
||||
>
|
||||
<CheckCircle className="w-16 h-16 text-green-500" weight="fill" />
|
||||
</motion.div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-center text-lg font-medium text-accent-foreground"
|
||||
>
|
||||
Created successfully!
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Link href={`/agents?agent=${createdSlug}`}>
|
||||
<Button variant="secondary" className="mt-2">
|
||||
Manage Agent
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="agent_name">Name</Label>
|
||||
<Input
|
||||
id="agent_name"
|
||||
className="w-full p-2 border mt-4 border-slate-500 rounded-lg"
|
||||
disabled={isCreating}
|
||||
value={customAgentName}
|
||||
onChange={(e) => setCustomAgentName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) :
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="agent_name">Name</Label>
|
||||
<Input
|
||||
id="agent_name"
|
||||
className="w-full p-2 border mt-4 border-slate-500 rounded-lg"
|
||||
disabled={isCreating}
|
||||
value={customAgentName}
|
||||
onChange={(e) => setCustomAgentName(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
onValueChange={setCustomAgentColor}
|
||||
defaultValue={customAgentColor}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full dark:bg-muted"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<SelectValue placeholder="Color" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="items-center space-y-1 inline-flex flex-col">
|
||||
{colorOptions.map((colorOption) => (
|
||||
<SelectItem key={colorOption} value={colorOption}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Circle
|
||||
className={`w-6 h-6 mr-2 ${convertColorToTextClass(colorOption)}`}
|
||||
weight="fill"
|
||||
/>
|
||||
{colorOption}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<Select onValueChange={setCustomAgentColor} defaultValue={customAgentColor}>
|
||||
<SelectTrigger className="w-full dark:bg-muted" disabled={isCreating}>
|
||||
<SelectValue placeholder="Color" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="items-center space-y-1 inline-flex flex-col">
|
||||
{colorOptions.map((colorOption) => (
|
||||
<SelectItem key={colorOption} value={colorOption}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Circle
|
||||
className={`w-6 h-6 mr-2 ${convertColorToTextClass(colorOption)}`}
|
||||
weight="fill"
|
||||
/>
|
||||
{colorOption}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Select onValueChange={setCustomAgentIcon} defaultValue={customAgentIcon}>
|
||||
<SelectTrigger className="w-full dark:bg-muted" disabled={isCreating}>
|
||||
<SelectValue placeholder="Icon" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="items-center space-y-1 inline-flex flex-col">
|
||||
{iconOptions.map((iconOption) => (
|
||||
<SelectItem key={iconOption} value={iconOption}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getIconFromIconName(
|
||||
iconOption,
|
||||
customAgentColor ?? "gray",
|
||||
"w-6",
|
||||
"h-6",
|
||||
)}
|
||||
{iconOption}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
onValueChange={setCustomAgentIcon}
|
||||
defaultValue={customAgentIcon}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full dark:bg-muted"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<SelectValue placeholder="Icon" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="items-center space-y-1 inline-flex flex-col">
|
||||
{iconOptions.map((iconOption) => (
|
||||
<SelectItem key={iconOption} value={iconOption}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getIconFromIconName(
|
||||
iconOption,
|
||||
customAgentColor ?? "gray",
|
||||
"w-6",
|
||||
"h-6",
|
||||
)}
|
||||
{iconOption}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{
|
||||
error && (
|
||||
<div className="text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!doneCreating && (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => createAgent()}
|
||||
disabled={isCreating || !isValid}
|
||||
>
|
||||
{
|
||||
isCreating ?
|
||||
<CircleNotch className="animate-spin" />
|
||||
:
|
||||
<PersonSimpleTaiChi />
|
||||
}
|
||||
Create
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
{error && <div className="text-red-500 text-sm">{error}</div>}
|
||||
{!doneCreating && (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => createAgent()}
|
||||
disabled={isCreating || !isValid}
|
||||
>
|
||||
{isCreating ? (
|
||||
<CircleNotch className="animate-spin" />
|
||||
) : (
|
||||
<PersonSimpleTaiChi />
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose />
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
|
||||
)
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
@@ -305,7 +326,14 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
const { data: agentConfigurationOptions, error: agentConfigurationOptionsError } =
|
||||
useSWR<AgentConfigurationOptions>("/api/agents/options", fetcher);
|
||||
|
||||
const { data: agentData, isLoading: agentDataLoading, error: agentDataError } = useSWR<AgentData>(`/api/agents/conversation?conversation_id=${props.conversationId}`, fetcher);
|
||||
const {
|
||||
data: agentData,
|
||||
isLoading: agentDataLoading,
|
||||
error: agentDataError,
|
||||
} = useSWR<AgentData>(
|
||||
`/api/agents/conversation?conversation_id=${props.conversationId}`,
|
||||
fetcher,
|
||||
);
|
||||
const {
|
||||
data: authenticatedData,
|
||||
error: authenticationError,
|
||||
@@ -317,7 +345,9 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
const [inputTools, setInputTools] = useState<string[] | undefined>();
|
||||
const [outputModes, setOutputModes] = useState<string[] | undefined>();
|
||||
const [hasModified, setHasModified] = useState<boolean>(false);
|
||||
const [isDefaultAgent, setIsDefaultAgent] = useState<boolean>(!agentData || agentData?.slug?.toLowerCase() === "khoj");
|
||||
const [isDefaultAgent, setIsDefaultAgent] = useState<boolean>(
|
||||
!agentData || agentData?.slug?.toLowerCase() === "khoj",
|
||||
);
|
||||
const [displayInputTools, setDisplayInputTools] = useState<string[] | undefined>();
|
||||
const [displayOutputModes, setDisplayOutputModes] = useState<string[] | undefined>();
|
||||
|
||||
@@ -330,12 +360,20 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
setInputTools(agentData.input_tools);
|
||||
setDisplayInputTools(agentData.input_tools);
|
||||
if (agentData.input_tools === undefined || agentData.input_tools.length === 0) {
|
||||
setDisplayInputTools(agentConfigurationOptions?.input_tools ? Object.keys(agentConfigurationOptions.input_tools) : []);
|
||||
setDisplayInputTools(
|
||||
agentConfigurationOptions?.input_tools
|
||||
? Object.keys(agentConfigurationOptions.input_tools)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
setOutputModes(agentData.output_modes);
|
||||
setDisplayOutputModes(agentData.output_modes);
|
||||
if (agentData.output_modes === undefined || agentData.output_modes.length === 0) {
|
||||
setDisplayOutputModes(agentConfigurationOptions?.output_modes ? Object.keys(agentConfigurationOptions.output_modes) : []);
|
||||
setDisplayOutputModes(
|
||||
agentConfigurationOptions?.output_modes
|
||||
? Object.keys(agentConfigurationOptions.output_modes)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
if (agentData.name?.toLowerCase() === "khoj" || agentData.is_hidden === true) {
|
||||
@@ -367,8 +405,12 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
const promptChanged = !!customPrompt && customPrompt !== agentData.persona;
|
||||
|
||||
// Order independent check to ensure input tools or output modes haven't been changed.
|
||||
const toolsChanged = JSON.stringify(inputTools?.sort() || []) !== JSON.stringify(agentData.input_tools?.sort());
|
||||
const modesChanged = JSON.stringify(outputModes?.sort() || []) !== JSON.stringify(agentData.output_modes?.sort());
|
||||
const toolsChanged =
|
||||
JSON.stringify(inputTools?.sort() || []) !==
|
||||
JSON.stringify(agentData.input_tools?.sort());
|
||||
const modesChanged =
|
||||
JSON.stringify(outputModes?.sort() || []) !==
|
||||
JSON.stringify(agentData.output_modes?.sort());
|
||||
|
||||
setHasModified(modelChanged || promptChanged || toolsChanged || modesChanged);
|
||||
|
||||
@@ -394,7 +436,9 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
function handleSave() {
|
||||
if (hasModified) {
|
||||
if (!isDefaultAgent && agentData?.is_hidden === false) {
|
||||
alert("This agent is not a hidden agent. It cannot be modified from this interface.");
|
||||
alert(
|
||||
"This agent is not a hidden agent. It cannot be modified from this interface.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -409,12 +453,14 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
chat_model: selectedModel,
|
||||
input_tools: inputTools,
|
||||
output_modes: outputModes,
|
||||
...(isDefaultAgent ? {} : { slug: agentData?.slug })
|
||||
...(isDefaultAgent ? {} : { slug: agentData?.slug }),
|
||||
};
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
const url = !isDefaultAgent ? `/api/agents/hidden` : `/api/agents/hidden?conversation_id=${props.conversationId}`;
|
||||
const url = !isDefaultAgent
|
||||
? `/api/agents/hidden`
|
||||
: `/api/agents/hidden?conversation_id=${props.conversationId}`;
|
||||
|
||||
// There are four scenarios here.
|
||||
// 1. If the agent is a default agent, then we need to create a new agent just to associate with this conversation.
|
||||
@@ -424,13 +470,13 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
fetch(url, {
|
||||
method: mode,
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((res) => {
|
||||
setIsSaving(false);
|
||||
res.json()
|
||||
res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
mutate(`/api/agents/conversation?conversation_id=${props.conversationId}`);
|
||||
@@ -456,43 +502,47 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className={`ml-auto opacity-30 rounded-lg p-2 transition-all transform duration-300 ease-in-out
|
||||
${props.isOpen
|
||||
? "translate-x-0 opacity-100 w-[300px] relative"
|
||||
: "translate-x-full opacity-100 w-0 p-0 m-0"}
|
||||
${
|
||||
props.isOpen
|
||||
? "translate-x-0 opacity-100 w-[300px] relative"
|
||||
: "translate-x-full opacity-100 w-0 p-0 m-0"
|
||||
}
|
||||
`}
|
||||
variant="floating">
|
||||
variant="floating"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarHeader>
|
||||
{
|
||||
agentData && !isEditable ? (
|
||||
<div className="flex items-center relative text-sm">
|
||||
<a className="text-lg font-bold flex flex-row items-center" href={`/agents?agent=${agentData.slug}`}>
|
||||
{getIconFromIconName(agentData.icon, agentData.color)}
|
||||
{agentData.name}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center relative text-sm justify-between">
|
||||
<p>
|
||||
Chat Options
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{agentData && !isEditable ? (
|
||||
<div className="flex items-center relative text-sm">
|
||||
<a
|
||||
className="text-lg font-bold flex flex-row items-center"
|
||||
href={`/agents?agent=${agentData.slug}`}
|
||||
>
|
||||
{getIconFromIconName(agentData.icon, agentData.color)}
|
||||
{agentData.name}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center relative text-sm justify-between">
|
||||
<p>Chat Options</p>
|
||||
</div>
|
||||
)}
|
||||
</SidebarHeader>
|
||||
<SidebarGroup key={"knowledge"} className="border-b last:border-none">
|
||||
<SidebarGroupContent className="gap-0">
|
||||
<SidebarMenu className="p-0 m-0">
|
||||
{
|
||||
agentData && agentData.has_files ? (
|
||||
<SidebarMenuItem key={"agent_knowledge"} className="list-none">
|
||||
<div className="flex items-center space-x-2 rounded-full">
|
||||
<div className="text-muted-foreground"><Sparkle /></div>
|
||||
<div className="text-muted-foreground text-sm">Using custom knowledge base</div>
|
||||
{agentData && agentData.has_files ? (
|
||||
<SidebarMenuItem key={"agent_knowledge"} className="list-none">
|
||||
<div className="flex items-center space-x-2 rounded-full">
|
||||
<div className="text-muted-foreground">
|
||||
<Sparkle />
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
) : null
|
||||
}
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Using custom knowledge base
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
) : null}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -506,39 +556,41 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
value={customPrompt || ""}
|
||||
onChange={(e) => handleCustomPromptChange(e.target.value)}
|
||||
readOnly={!isEditable}
|
||||
disabled={!isEditable} />
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
{
|
||||
!agentDataLoading && agentData && (
|
||||
<SidebarGroup key={"model"}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarGroupLabel>
|
||||
Model
|
||||
{
|
||||
!isSubscribed && (
|
||||
<a href="/settings" className="hover:font-bold text-accent-foreground m-2 bg-accent bg-opacity-10 p-1 rounded-lg">
|
||||
Upgrade
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenu className="p-0 m-0">
|
||||
<SidebarMenuItem key={"model"} className="list-none">
|
||||
<ModelSelector
|
||||
disabled={!isEditable}
|
||||
onSelect={(model) => handleModelSelect(model.name)}
|
||||
initialModel={isDefaultAgent ? undefined : agentData?.chat_model}
|
||||
isActive={props.isActive}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
{!agentDataLoading && agentData && (
|
||||
<SidebarGroup key={"model"}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarGroupLabel>
|
||||
Model
|
||||
{!isSubscribed && (
|
||||
<a
|
||||
href="/settings"
|
||||
className="hover:font-bold text-accent-foreground m-2 bg-accent bg-opacity-10 p-1 rounded-lg"
|
||||
>
|
||||
Upgrade
|
||||
</a>
|
||||
)}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenu className="p-0 m-0">
|
||||
<SidebarMenuItem key={"model"} className="list-none">
|
||||
<ModelSelector
|
||||
disabled={!isEditable}
|
||||
onSelect={(model) => handleModelSelect(model.name)}
|
||||
initialModel={
|
||||
isDefaultAgent ? undefined : agentData?.chat_model
|
||||
}
|
||||
isActive={props.isActive}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
<Popover defaultOpen={false}>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
@@ -550,82 +602,118 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
<PopoverContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="p-1 m-0">
|
||||
{
|
||||
Object.entries(agentConfigurationOptions?.input_tools ?? {}).map(([key, value]) => {
|
||||
return (
|
||||
<SidebarMenuItem key={key} className="list-none">
|
||||
<Tooltip>
|
||||
<TooltipTrigger key={key} asChild>
|
||||
<div className="flex items-center space-x-2 py-1 justify-between">
|
||||
<Label htmlFor={key} className="flex items-center gap-2 text-accent-foreground p-1 cursor-pointer">
|
||||
{getIconForSlashCommand(key)}
|
||||
<p className="text-sm my-auto flex items-center">
|
||||
{key}
|
||||
</p>
|
||||
</Label>
|
||||
<Checkbox
|
||||
id={key}
|
||||
className={`${isEditable ? "cursor-pointer" : ""}`}
|
||||
checked={isValueChecked(key, displayInputTools ?? [])}
|
||||
onCheckedChange={() => {
|
||||
let updatedInputTools = handleCheckToggle(key, displayInputTools ?? [])
|
||||
setInputTools(updatedInputTools);
|
||||
setDisplayInputTools(updatedInputTools);
|
||||
}}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
{Object.entries(
|
||||
agentConfigurationOptions?.input_tools ?? {},
|
||||
).map(([key, value]) => {
|
||||
return (
|
||||
<SidebarMenuItem key={key} className="list-none">
|
||||
<Tooltip>
|
||||
<TooltipTrigger key={key} asChild>
|
||||
<div className="flex items-center space-x-2 py-1 justify-between">
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="flex items-center gap-2 text-accent-foreground p-1 cursor-pointer"
|
||||
>
|
||||
{getIconForSlashCommand(key)}
|
||||
<p className="text-sm my-auto flex items-center">
|
||||
{key}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={5} side="left" align="start" className="text-sm bg-background text-foreground shadow-sm border border-slate-500 border-opacity-20 p-2 rounded-lg">
|
||||
{value}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
{
|
||||
Object.entries(agentConfigurationOptions?.output_modes ?? {}).map(([key, value]) => {
|
||||
return (
|
||||
<SidebarMenuItem key={key} className="list-none">
|
||||
<Tooltip>
|
||||
<TooltipTrigger key={key} asChild>
|
||||
<div className="flex items-center space-x-2 py-1 justify-between">
|
||||
<Label htmlFor={key} className="flex items-center gap-2 p-1 rounded-lg cursor-pointer">
|
||||
{getIconForSlashCommand(key)}
|
||||
<p className="text-sm my-auto flex items-center">
|
||||
{key}
|
||||
</p>
|
||||
</Label>
|
||||
<Checkbox
|
||||
id={key}
|
||||
className={`${isEditable ? "cursor-pointer" : ""}`}
|
||||
checked={isValueChecked(key, displayOutputModes ?? [])}
|
||||
onCheckedChange={() => {
|
||||
let updatedOutputModes = handleCheckToggle(key, displayOutputModes ?? [])
|
||||
setOutputModes(updatedOutputModes);
|
||||
setDisplayOutputModes(updatedOutputModes);
|
||||
}}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
</p>
|
||||
</Label>
|
||||
<Checkbox
|
||||
id={key}
|
||||
className={`${isEditable ? "cursor-pointer" : ""}`}
|
||||
checked={isValueChecked(
|
||||
key,
|
||||
displayInputTools ?? [],
|
||||
)}
|
||||
onCheckedChange={() => {
|
||||
let updatedInputTools =
|
||||
handleCheckToggle(
|
||||
key,
|
||||
displayInputTools ?? [],
|
||||
);
|
||||
setInputTools(
|
||||
updatedInputTools,
|
||||
);
|
||||
setDisplayInputTools(
|
||||
updatedInputTools,
|
||||
);
|
||||
}}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={5}
|
||||
side="left"
|
||||
align="start"
|
||||
className="text-sm bg-background text-foreground shadow-sm border border-slate-500 border-opacity-20 p-2 rounded-lg"
|
||||
>
|
||||
{value}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
{Object.entries(
|
||||
agentConfigurationOptions?.output_modes ?? {},
|
||||
).map(([key, value]) => {
|
||||
return (
|
||||
<SidebarMenuItem key={key} className="list-none">
|
||||
<Tooltip>
|
||||
<TooltipTrigger key={key} asChild>
|
||||
<div className="flex items-center space-x-2 py-1 justify-between">
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="flex items-center gap-2 p-1 rounded-lg cursor-pointer"
|
||||
>
|
||||
{getIconForSlashCommand(key)}
|
||||
<p className="text-sm my-auto flex items-center">
|
||||
{key}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={5} side="left" align="start" className="text-sm bg-background text-foreground shadow-sm border border-slate-500 border-opacity-20 p-2 rounded-lg">
|
||||
{value}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</Label>
|
||||
<Checkbox
|
||||
id={key}
|
||||
className={`${isEditable ? "cursor-pointer" : ""}`}
|
||||
checked={isValueChecked(
|
||||
key,
|
||||
displayOutputModes ?? [],
|
||||
)}
|
||||
onCheckedChange={() => {
|
||||
let updatedOutputModes =
|
||||
handleCheckToggle(
|
||||
key,
|
||||
displayOutputModes ??
|
||||
[],
|
||||
);
|
||||
setOutputModes(
|
||||
updatedOutputModes,
|
||||
);
|
||||
setDisplayOutputModes(
|
||||
updatedOutputModes,
|
||||
);
|
||||
}}
|
||||
disabled={!isEditable}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={5}
|
||||
side="left"
|
||||
align="start"
|
||||
className="text-sm bg-background text-foreground shadow-sm border border-slate-500 border-opacity-20 p-2 rounded-lg"
|
||||
>
|
||||
{value}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
|
||||
</SidebarGroupContent>
|
||||
</PopoverContent>
|
||||
</SidebarGroup>
|
||||
@@ -645,79 +733,75 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
{
|
||||
props.isOpen && (
|
||||
<SidebarFooter key={"actions"}>
|
||||
<SidebarMenu className="p-0 m-0">
|
||||
|
||||
{
|
||||
(agentData && !isEditable && agentData.is_creator) ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={"ghost"}
|
||||
onClick={() => window.location.href = `/agents?agent=${agentData?.slug}`}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) :
|
||||
<>
|
||||
{
|
||||
!hasModified && isEditable && customPrompt && !isDefaultAgent && selectedModel && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<AgentCreationForm
|
||||
customPrompt={customPrompt}
|
||||
selectedModel={selectedModel}
|
||||
inputTools={displayInputTools ?? []}
|
||||
outputModes={displayOutputModes ?? []}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
{props.isOpen && (
|
||||
<SidebarFooter key={"actions"}>
|
||||
<SidebarMenu className="p-0 m-0">
|
||||
{agentData && !isEditable && agentData.is_creator ? (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={"ghost"}
|
||||
onClick={() =>
|
||||
(window.location.href = `/agents?agent=${agentData?.slug}`)
|
||||
}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
<>
|
||||
{!hasModified &&
|
||||
isEditable &&
|
||||
customPrompt &&
|
||||
!isDefaultAgent &&
|
||||
selectedModel && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleReset()}
|
||||
variant={"ghost"}
|
||||
disabled={!isEditable || !hasModified}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<AgentCreationForm
|
||||
customPrompt={customPrompt}
|
||||
selectedModel={selectedModel}
|
||||
inputTools={displayInputTools ?? []}
|
||||
outputModes={displayOutputModes ?? []}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Button
|
||||
className={`w-full ${hasModified ? "bg-accent-foreground text-accent" : ""}`}
|
||||
variant={"secondary"}
|
||||
onClick={() => handleSave()}
|
||||
disabled={!isEditable || !hasModified || isSaving}
|
||||
>
|
||||
{
|
||||
isSaving ?
|
||||
<CircleNotch className="animate-spin" />
|
||||
:
|
||||
<ArrowsDownUp />
|
||||
}
|
||||
{
|
||||
isSaving ? "Saving" : "Save"
|
||||
}
|
||||
</Button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
</>
|
||||
}
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
)
|
||||
}
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleReset()}
|
||||
variant={"ghost"}
|
||||
disabled={!isEditable || !hasModified}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Button
|
||||
className={`w-full ${hasModified ? "bg-accent-foreground text-accent" : ""}`}
|
||||
variant={"secondary"}
|
||||
onClick={() => handleSave()}
|
||||
disabled={!isEditable || !hasModified || isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<CircleNotch className="animate-spin" />
|
||||
) : (
|
||||
<ArrowsDownUp />
|
||||
)}
|
||||
{isSaving ? "Saving" : "Save"}
|
||||
</Button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
)}
|
||||
</Sidebar>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,9 +147,7 @@ const Mermaid: React.FC<MermaidProps> = ({ chart }) => {
|
||||
<span>{mermaidError}</span>
|
||||
</div>
|
||||
<code className="block bg-secondary text-secondary-foreground p-4 mt-3 rounded-lg font-mono text-sm whitespace-pre-wrap overflow-x-auto max-h-[400px] border border-gray-200">
|
||||
{
|
||||
chart
|
||||
}
|
||||
{chart}
|
||||
</code>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -12,7 +12,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Moon, Sun, UserCircle, Question, ArrowRight, Code, BuildingOffice } from "@phosphor-icons/react";
|
||||
import {
|
||||
Moon,
|
||||
Sun,
|
||||
UserCircle,
|
||||
Question,
|
||||
ArrowRight,
|
||||
Code,
|
||||
BuildingOffice,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useIsDarkMode, useIsMobileWidth } from "@/app/common/utils";
|
||||
import LoginPrompt from "../loginPrompt/loginPrompt";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -69,7 +77,7 @@ export default function FooterMenu({ sideBarIsOpen }: NavMenuProps) {
|
||||
icon: <BuildingOffice className="w-6 h-6" />,
|
||||
link: "https://khoj.dev/teams",
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<SidebarMenu className="border-none p-0 m-0">
|
||||
@@ -131,18 +139,16 @@ export default function FooterMenu({ sideBarIsOpen }: NavMenuProps) {
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{
|
||||
menuItems.map((menuItem, index) => (
|
||||
<DropdownMenuItem key={index}>
|
||||
<Link href={menuItem.link} className="no-underline w-full">
|
||||
<div className="flex flex-rows">
|
||||
{menuItem.icon}
|
||||
<p className="ml-3 font-semibold">{menuItem.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
}
|
||||
{menuItems.map((menuItem, index) => (
|
||||
<DropdownMenuItem key={index}>
|
||||
<Link href={menuItem.link} className="no-underline w-full">
|
||||
<div className="flex flex-rows">
|
||||
{menuItem.icon}
|
||||
<p className="ml-3 font-semibold">{menuItem.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{!userData ? (
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useIsDarkMode } from '@/app/common/utils'
|
||||
import { useIsDarkMode } from "@/app/common/utils";
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [darkMode, setDarkMode] = useIsDarkMode();
|
||||
|
||||
@@ -508,7 +508,7 @@ function FileFilterComboBox(props: FileFilterComboBoxProps) {
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Popover open={open || (noMatchingFiles && (!!inputText))} onOpenChange={setOpen}>
|
||||
<Popover open={open || (noMatchingFiles && !!inputText)} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -521,14 +521,18 @@ function FileFilterComboBox(props: FileFilterComboBoxProps) {
|
||||
? "✔️"
|
||||
: "Selected"
|
||||
: props.isMobileWidth
|
||||
? " "
|
||||
: "Select file"}
|
||||
? " "
|
||||
: "Select file"}
|
||||
<Funnel className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search files..." value={inputText} onInput={(e) => setInputText(e.currentTarget.value)} />
|
||||
<CommandInput
|
||||
placeholder="Search files..."
|
||||
value={inputText}
|
||||
onInput={(e) => setInputText(e.currentTarget.value)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No files found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
@@ -614,7 +618,6 @@ export default function Search() {
|
||||
setSelectedFileFilter("INITIALIZE");
|
||||
}
|
||||
}
|
||||
|
||||
}, [searchQuery]);
|
||||
|
||||
function handleSearchInputChange(value: string) {
|
||||
|
||||
@@ -23,8 +23,15 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||||
AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
|
||||
@@ -72,7 +79,7 @@ import { KhojLogoType } from "../components/logo/khojLogo";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
import JSZip from "jszip";
|
||||
import { saveAs } from 'file-saver';
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
interface DropdownComponentProps {
|
||||
items: ModelOptions[];
|
||||
@@ -81,7 +88,12 @@ interface DropdownComponentProps {
|
||||
callbackFunc: (value: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DropdownComponent: React.FC<DropdownComponentProps> = ({ items, selected, isActive, callbackFunc }) => {
|
||||
const DropdownComponent: React.FC<DropdownComponentProps> = ({
|
||||
items,
|
||||
selected,
|
||||
isActive,
|
||||
callbackFunc,
|
||||
}) => {
|
||||
const [position, setPosition] = useState(selected?.toString() ?? "0");
|
||||
|
||||
return (
|
||||
@@ -114,7 +126,10 @@ const DropdownComponent: React.FC<DropdownComponentProps> = ({ items, selected,
|
||||
value={item.id.toString()}
|
||||
disabled={!isActive && item.tier !== "free"}
|
||||
>
|
||||
{item.name} {item.tier === "standard" && <span className="text-green-500 ml-2">(Futurist)</span>}
|
||||
{item.name}{" "}
|
||||
{item.tier === "standard" && (
|
||||
<span className="text-green-500 ml-2">(Futurist)</span>
|
||||
)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
@@ -327,8 +342,8 @@ export default function SettingsView() {
|
||||
initialUserConfig?.is_phone_number_verified
|
||||
? PhoneNumberValidationState.Verified
|
||||
: initialUserConfig?.phone_number
|
||||
? PhoneNumberValidationState.SendOTP
|
||||
: PhoneNumberValidationState.Setup,
|
||||
? PhoneNumberValidationState.SendOTP
|
||||
: PhoneNumberValidationState.Setup,
|
||||
);
|
||||
setName(initialUserConfig?.given_name);
|
||||
setNotionToken(initialUserConfig?.notion_token ?? null);
|
||||
@@ -524,13 +539,14 @@ export default function SettingsView() {
|
||||
|
||||
const updateModel = (modelType: string) => async (id: string) => {
|
||||
// Get the selected model from the options
|
||||
const modelOptions = modelType === "chat"
|
||||
? userConfig?.chat_model_options
|
||||
: modelType === "paint"
|
||||
? userConfig?.paint_model_options
|
||||
: userConfig?.voice_model_options;
|
||||
const modelOptions =
|
||||
modelType === "chat"
|
||||
? userConfig?.chat_model_options
|
||||
: modelType === "paint"
|
||||
? userConfig?.paint_model_options
|
||||
: userConfig?.voice_model_options;
|
||||
|
||||
const selectedModel = modelOptions?.find(model => model.id.toString() === id);
|
||||
const selectedModel = modelOptions?.find((model) => model.id.toString() === id);
|
||||
const modelName = selectedModel?.name;
|
||||
|
||||
// Check if the model is free tier or if the user is active
|
||||
@@ -551,7 +567,8 @@ export default function SettingsView() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to switch ${modelType} model to ${modelName}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to switch ${modelType} model to ${modelName}`);
|
||||
|
||||
toast({
|
||||
title: `✅ Switched ${modelType} model to ${modelName}`,
|
||||
@@ -570,7 +587,7 @@ export default function SettingsView() {
|
||||
setIsExporting(true);
|
||||
|
||||
// Get total conversation count
|
||||
const statsResponse = await fetch('/api/chat/stats');
|
||||
const statsResponse = await fetch("/api/chat/stats");
|
||||
const stats = await statsResponse.json();
|
||||
const total = stats.num_conversations;
|
||||
setTotalConversations(total);
|
||||
@@ -586,7 +603,7 @@ export default function SettingsView() {
|
||||
conversations.push(...data);
|
||||
|
||||
setExportedConversations((page + 1) * 10);
|
||||
setExportProgress(((page + 1) * 10 / total) * 100);
|
||||
setExportProgress((((page + 1) * 10) / total) * 100);
|
||||
}
|
||||
|
||||
// Add conversations to zip
|
||||
@@ -605,7 +622,7 @@ export default function SettingsView() {
|
||||
toast({
|
||||
title: "Export Failed",
|
||||
description: "Failed to export chats. Please try again.",
|
||||
variant: "destructive"
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
@@ -808,93 +825,93 @@ export default function SettingsView() {
|
||||
)) ||
|
||||
(userConfig.subscription_state ===
|
||||
"subscribed" && (
|
||||
<>
|
||||
<p className="text-xl text-primary/80">
|
||||
Futurist
|
||||
</p>
|
||||
<p className="text-gray-400">
|
||||
Subscription <b>renews</b> on{" "}
|
||||
<b>
|
||||
{
|
||||
userConfig.subscription_renewal_date
|
||||
}
|
||||
</b>
|
||||
</p>
|
||||
</>
|
||||
)) ||
|
||||
<>
|
||||
<p className="text-xl text-primary/80">
|
||||
Futurist
|
||||
</p>
|
||||
<p className="text-gray-400">
|
||||
Subscription <b>renews</b> on{" "}
|
||||
<b>
|
||||
{
|
||||
userConfig.subscription_renewal_date
|
||||
}
|
||||
</b>
|
||||
</p>
|
||||
</>
|
||||
)) ||
|
||||
(userConfig.subscription_state ===
|
||||
"unsubscribed" && (
|
||||
<>
|
||||
<p className="text-xl">Futurist</p>
|
||||
<>
|
||||
<p className="text-xl">Futurist</p>
|
||||
<p className="text-gray-400">
|
||||
Subscription <b>ends</b> on{" "}
|
||||
<b>
|
||||
{
|
||||
userConfig.subscription_renewal_date
|
||||
}
|
||||
</b>
|
||||
</p>
|
||||
</>
|
||||
)) ||
|
||||
(userConfig.subscription_state ===
|
||||
"expired" && (
|
||||
<>
|
||||
<p className="text-xl">Humanist</p>
|
||||
{(userConfig.subscription_renewal_date && (
|
||||
<p className="text-gray-400">
|
||||
Subscription <b>ends</b> on{" "}
|
||||
Subscription <b>expired</b>{" "}
|
||||
on{" "}
|
||||
<b>
|
||||
{
|
||||
userConfig.subscription_renewal_date
|
||||
}
|
||||
</b>
|
||||
</p>
|
||||
</>
|
||||
)) ||
|
||||
(userConfig.subscription_state ===
|
||||
"expired" && (
|
||||
<>
|
||||
<p className="text-xl">Humanist</p>
|
||||
{(userConfig.subscription_renewal_date && (
|
||||
<p className="text-gray-400">
|
||||
Subscription <b>expired</b>{" "}
|
||||
on{" "}
|
||||
<b>
|
||||
{
|
||||
userConfig.subscription_renewal_date
|
||||
}
|
||||
</b>
|
||||
</p>
|
||||
)) || (
|
||||
<p className="text-gray-400">
|
||||
Check{" "}
|
||||
<a
|
||||
href="https://khoj.dev/#pricing"
|
||||
target="_blank"
|
||||
>
|
||||
pricing page
|
||||
</a>{" "}
|
||||
to compare plans.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
)) || (
|
||||
<p className="text-gray-400">
|
||||
Check{" "}
|
||||
<a
|
||||
href="https://khoj.dev/#pricing"
|
||||
target="_blank"
|
||||
>
|
||||
pricing page
|
||||
</a>{" "}
|
||||
to compare plans.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap gap-4">
|
||||
{(userConfig.subscription_state ==
|
||||
"subscribed" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:text-red-400"
|
||||
onClick={() =>
|
||||
setSubscription("cancel")
|
||||
}
|
||||
>
|
||||
<ArrowCircleDown className="h-5 w-5 mr-2" />
|
||||
Unsubscribe
|
||||
</Button>
|
||||
)) ||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:text-red-400"
|
||||
onClick={() =>
|
||||
setSubscription("cancel")
|
||||
}
|
||||
>
|
||||
<ArrowCircleDown className="h-5 w-5 mr-2" />
|
||||
Unsubscribe
|
||||
</Button>
|
||||
)) ||
|
||||
(userConfig.subscription_state ==
|
||||
"unsubscribed" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary/80 hover:text-primary"
|
||||
onClick={() =>
|
||||
setSubscription("resubscribe")
|
||||
}
|
||||
>
|
||||
<ArrowCircleUp
|
||||
weight="bold"
|
||||
className="h-5 w-5 mr-2"
|
||||
/>
|
||||
Resubscribe
|
||||
</Button>
|
||||
)) ||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-primary/80 hover:text-primary"
|
||||
onClick={() =>
|
||||
setSubscription("resubscribe")
|
||||
}
|
||||
>
|
||||
<ArrowCircleUp
|
||||
weight="bold"
|
||||
className="h-5 w-5 mr-2"
|
||||
/>
|
||||
Resubscribe
|
||||
</Button>
|
||||
)) ||
|
||||
(userConfig.subscription_enabled_trial_at && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -984,16 +1001,16 @@ export default function SettingsView() {
|
||||
<Button variant="outline" size="sm">
|
||||
{(userConfig.enabled_content_source
|
||||
.github && (
|
||||
<>
|
||||
<Files className="h-5 w-5 inline mr-1" />
|
||||
Manage
|
||||
</>
|
||||
)) || (
|
||||
<>
|
||||
<Plugs className="h-5 w-5 inline mr-1" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<Files className="h-5 w-5 inline mr-1" />
|
||||
Manage
|
||||
</>
|
||||
)) || (
|
||||
<>
|
||||
<Plugs className="h-5 w-5 inline mr-1" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1035,8 +1052,8 @@ export default function SettingsView() {
|
||||
{
|
||||
/* Show connect to notion button if notion oauth url setup and user disconnected*/
|
||||
userConfig.notion_oauth_url &&
|
||||
!userConfig.enabled_content_source
|
||||
.notion ? (
|
||||
!userConfig.enabled_content_source
|
||||
.notion ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1050,39 +1067,39 @@ export default function SettingsView() {
|
||||
Connect
|
||||
</Button>
|
||||
) : /* Show sync button if user connected to notion and API key unchanged */
|
||||
userConfig.enabled_content_source.notion &&
|
||||
notionToken ===
|
||||
userConfig.notion_token ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
syncContent("notion")
|
||||
}
|
||||
>
|
||||
<ArrowsClockwise className="h-5 w-5 inline mr-1" />
|
||||
Sync
|
||||
</Button>
|
||||
) : /* Show set API key button notion oauth url not set setup */
|
||||
!userConfig.notion_oauth_url ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={saveNotionToken}
|
||||
disabled={
|
||||
notionToken ===
|
||||
userConfig.notion_token
|
||||
}
|
||||
>
|
||||
<FloppyDisk className="h-5 w-5 inline mr-1" />
|
||||
{(userConfig.enabled_content_source
|
||||
.notion &&
|
||||
"Update API Key") ||
|
||||
"Set API Key"}
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
userConfig.enabled_content_source.notion &&
|
||||
notionToken ===
|
||||
userConfig.notion_token ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
syncContent("notion")
|
||||
}
|
||||
>
|
||||
<ArrowsClockwise className="h-5 w-5 inline mr-1" />
|
||||
Sync
|
||||
</Button>
|
||||
) : /* Show set API key button notion oauth url not set setup */
|
||||
!userConfig.notion_oauth_url ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={saveNotionToken}
|
||||
disabled={
|
||||
notionToken ===
|
||||
userConfig.notion_token
|
||||
}
|
||||
>
|
||||
<FloppyDisk className="h-5 w-5 inline mr-1" />
|
||||
{(userConfig.enabled_content_source
|
||||
.notion &&
|
||||
"Update API Key") ||
|
||||
"Set API Key"}
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1123,7 +1140,10 @@ export default function SettingsView() {
|
||||
<CardFooter className="flex flex-wrap gap-4">
|
||||
{!userConfig.is_active && (
|
||||
<p className="text-gray-400">
|
||||
{userConfig.chat_model_options.some(model => model.tier === "free")
|
||||
{userConfig.chat_model_options.some(
|
||||
(model) =>
|
||||
model.tier === "free",
|
||||
)
|
||||
? "Free models available"
|
||||
: "Subscribe to switch model"}
|
||||
</p>
|
||||
@@ -1154,7 +1174,10 @@ export default function SettingsView() {
|
||||
<CardFooter className="flex flex-wrap gap-4">
|
||||
{!userConfig.is_active && (
|
||||
<p className="text-gray-400">
|
||||
{userConfig.paint_model_options.some(model => model.tier === "free")
|
||||
{userConfig.paint_model_options.some(
|
||||
(model) =>
|
||||
model.tier === "free",
|
||||
)
|
||||
? "Free models available"
|
||||
: "Subscribe to switch model"}
|
||||
</p>
|
||||
@@ -1185,7 +1208,10 @@ export default function SettingsView() {
|
||||
<CardFooter className="flex flex-wrap gap-4">
|
||||
{!userConfig.is_active && (
|
||||
<p className="text-gray-400">
|
||||
{userConfig.voice_model_options.some(model => model.tier === "free")
|
||||
{userConfig.voice_model_options.some(
|
||||
(model) =>
|
||||
model.tier === "free",
|
||||
)
|
||||
? "Free models available"
|
||||
: "Subscribe to switch model"}
|
||||
</p>
|
||||
@@ -1219,9 +1245,13 @@ export default function SettingsView() {
|
||||
</p>
|
||||
{exportProgress > 0 && (
|
||||
<div className="w-full mt-4">
|
||||
<Progress value={exportProgress} className="w-full" />
|
||||
<Progress
|
||||
value={exportProgress}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Exported {exportedConversations} of {totalConversations} conversations
|
||||
Exported {exportedConversations} of{" "}
|
||||
{totalConversations} conversations
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1233,7 +1263,9 @@ export default function SettingsView() {
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Download className="h-5 w-5 mr-2" />
|
||||
{isExporting ? "Exporting..." : "Export Chats"}
|
||||
{isExporting
|
||||
? "Exporting..."
|
||||
: "Export Chats"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -1245,7 +1277,11 @@ export default function SettingsView() {
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-hidden">
|
||||
<p className="pb-4 text-gray-400">
|
||||
This will delete all your account data, including conversations, agents, and any assets you{"'"}ve generated. Be sure to export before you do this if you want to keep your information.
|
||||
This will delete all your account data,
|
||||
including conversations, agents, and any
|
||||
assets you{"'"}ve generated. Be sure to
|
||||
export before you do this if you want to
|
||||
keep your information.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap gap-4">
|
||||
@@ -1261,36 +1297,56 @@ export default function SettingsView() {
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
Are you absolutely sure?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action is irreversible. This will permanently delete your account
|
||||
and remove all your data from our servers.
|
||||
This action is irreversible.
|
||||
This will permanently delete
|
||||
your account and remove all your
|
||||
data from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch('/api/self', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete account');
|
||||
const response =
|
||||
await fetch(
|
||||
"/api/self",
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
"Failed to delete account",
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "Account Deleted",
|
||||
description: "Your account has been successfully deleted.",
|
||||
description:
|
||||
"Your account has been successfully deleted.",
|
||||
});
|
||||
|
||||
// Redirect to home page after successful deletion
|
||||
window.location.href = "/";
|
||||
window.location.href =
|
||||
"/";
|
||||
} catch (error) {
|
||||
console.error('Error deleting account:', error);
|
||||
console.error(
|
||||
"Error deleting account:",
|
||||
error,
|
||||
);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete account. Please try again or contact support.",
|
||||
variant: "destructive"
|
||||
description:
|
||||
"Failed to delete account. Please try again or contact support.",
|
||||
variant:
|
||||
"destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
||||
1568
src/interface/web/bun.lock
Normal file
1568
src/interface/web/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,30 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-secondary-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-gray-500 data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-secondary-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-gray-500 data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
|
||||
@@ -1,118 +1,99 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = "Pagination";
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
|
||||
),
|
||||
);
|
||||
PaginationContent.displayName = "PaginationContent";
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
|
||||
({ className, ...props }, ref) => <li ref={ref} className={cn("", className)} {...props} />,
|
||||
);
|
||||
PaginationItem.displayName = "PaginationItem";
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
"no-underline",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
"no-underline",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = "PaginationPrevious";
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = "PaginationNext";
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis";
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
};
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"collectstatic": "bash -c 'pushd ../../../ && source .venv/bin/activate && python3 src/khoj/manage.py collectstatic --noinput && deactivate && popd'",
|
||||
"cicollectstatic": "bash -c 'pushd ../../../ && python3 src/khoj/manage.py collectstatic --noinput && popd'",
|
||||
"export": "yarn build && cp -r out/ ../../khoj/interface/built && yarn collectstatic",
|
||||
"ciexport": "yarn build && cp -r out/ ../../khoj/interface/built && yarn cicollectstatic",
|
||||
"pypiciexport": "yarn build && cp -r out/ /opt/hostedtoolcache/Python/3.11.12/x64/lib/python3.11/site-packages/khoj/interface/compiled && yarn cicollectstatic",
|
||||
"watch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'yarn export'",
|
||||
"windowswatch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'yarn windowsexport'",
|
||||
"cicollectstatic": "bash -c 'pushd ../../../ && uv run python src/khoj/manage.py collectstatic --noinput && popd'",
|
||||
"export": "bun run build && cp -r out/ ../../khoj/interface/built && bun run collectstatic",
|
||||
"ciexport": "bun run build && cp -r out/ ../../khoj/interface/built && bun run cicollectstatic",
|
||||
"pypiciexport": "bun run build && mkdir -p ../../khoj/interface/compiled && cp -r out/. ../../khoj/interface/compiled && bun run cicollectstatic",
|
||||
"watch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'bun run export'",
|
||||
"windowswatch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'bun run windowsexport'",
|
||||
"windowscollectstatic": "cd ..\\..\\.. && .\\.venv\\Scripts\\Activate.bat && py .\\src\\khoj\\manage.py collectstatic --noinput && .\\.venv\\Scripts\\deactivate.bat && cd ..",
|
||||
"windowsexport": "yarn build && xcopy out ..\\..\\khoj\\interface\\built /E /Y && yarn windowscollectstatic",
|
||||
"windowsexport": "bun run build && xcopy out ..\\..\\khoj\\interface\\built /E /Y && bun run windowscollectstatic",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -43,8 +43,6 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@radix-ui/themes": "^3.1.1",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -52,8 +50,6 @@
|
||||
"dompurify": "^3.1.6",
|
||||
"embla-carousel-autoplay": "^8.5.1",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^12.0.6",
|
||||
"input-otp": "^1.2.4",
|
||||
@@ -66,29 +62,26 @@
|
||||
"markdown-it-highlightjs": "^4.1.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"next": "14.2.30",
|
||||
"nodemon": "^3.1.3",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
"shadcn-ui": "^0.9.0",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5",
|
||||
"vaul": "^0.9.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/intl-tel-input": "^18.1.4",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.3",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
@@ -96,7 +89,10 @@
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.7",
|
||||
"nodemon": "^3.1.3",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "3.3.3",
|
||||
"shadcn-ui": "^0.9.0",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"prettier": {
|
||||
@@ -104,6 +100,6 @@
|
||||
"printWidth": 100
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "yarn lint --fix"
|
||||
"*": "bun run lint"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,7 @@ sudo -u postgres createdb khoj
|
||||
### Install Khoj
|
||||
|
||||
```bash
|
||||
pip install -e '.[dev]'
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
### Make Khoj DB migrations
|
||||
|
||||
@@ -14,6 +14,7 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from django.urls import path
|
||||
|
||||
@@ -1910,9 +1910,9 @@ class EntryAdapters:
|
||||
|
||||
owner_filter = Q()
|
||||
|
||||
if user != None:
|
||||
if user is not None:
|
||||
owner_filter = Q(user=user)
|
||||
if agent != None:
|
||||
if agent is not None:
|
||||
owner_filter |= Q(agent=agent)
|
||||
|
||||
if owner_filter == Q():
|
||||
@@ -1972,9 +1972,9 @@ class EntryAdapters:
|
||||
):
|
||||
owner_filter = Q()
|
||||
|
||||
if user != None:
|
||||
if user is not None:
|
||||
owner_filter = Q(user=user)
|
||||
if agent != None:
|
||||
if agent is not None:
|
||||
owner_filter |= Q(agent=agent)
|
||||
|
||||
if owner_filter == Q():
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, OuterRef
|
||||
|
||||
from khoj.database.models import Entry, FileObject
|
||||
|
||||
@@ -41,7 +41,7 @@ def update_conversation_id_in_job_state(apps, schema_editor):
|
||||
job.save()
|
||||
except Conversation.DoesNotExist:
|
||||
pass
|
||||
except LookupError as e:
|
||||
except LookupError:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Made manually by sabaimran for use by Django 5.0.9 on 2024-12-01 16:59
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db import migrations
|
||||
|
||||
# This script was written alongside when Pydantic validation was added to the Conversation conversation_log field.
|
||||
|
||||
|
||||
@@ -551,12 +551,12 @@ class TextToImageModelConfig(DbBaseModel):
|
||||
error = {}
|
||||
if self.model_type == self.ModelType.OPENAI:
|
||||
if self.api_key and self.ai_model_api:
|
||||
error[
|
||||
"api_key"
|
||||
] = "Both API key and AI Model API cannot be set for OpenAI models. Please set only one of them."
|
||||
error[
|
||||
"ai_model_api"
|
||||
] = "Both API key and OpenAI config cannot be set for OpenAI models. Please set only one of them."
|
||||
error["api_key"] = (
|
||||
"Both API key and AI Model API cannot be set for OpenAI models. Please set only one of them."
|
||||
)
|
||||
error["ai_model_api"] = (
|
||||
"Both API key and OpenAI config cannot be set for OpenAI models. Please set only one of them."
|
||||
)
|
||||
if self.model_type != self.ModelType.OPENAI and self.model_type != self.ModelType.GOOGLE:
|
||||
if not self.api_key:
|
||||
error["api_key"] = "The API key field must be set for non OpenAI, non Google models."
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
""" Main module for Khoj
|
||||
isort:skip_file
|
||||
"""Main module for Khoj
|
||||
isort:skip_file
|
||||
"""
|
||||
|
||||
from contextlib import redirect_stdout
|
||||
@@ -189,7 +189,7 @@ def run(should_start_server=True):
|
||||
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||
if not os.path.exists(static_dir):
|
||||
os.mkdir(static_dir)
|
||||
app.mount(f"/static", StaticFiles(directory=static_dir), name=static_dir)
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name=static_dir)
|
||||
|
||||
# Configure Middleware
|
||||
configure_middleware(app, state.ssl_config)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class GithubToEntries(TextToEntries):
|
||||
def process(self, files: dict[str, str], user: KhojUser, regenerate: bool = False) -> Tuple[int, int]:
|
||||
if is_none_or_empty(self.config.pat_token):
|
||||
logger.warning(
|
||||
f"Github PAT token is not set. Private repositories cannot be indexed and lower rate limits apply."
|
||||
"Github PAT token is not set. Private repositories cannot be indexed and lower rate limits apply."
|
||||
)
|
||||
current_entries = []
|
||||
for repo in self.config.repos:
|
||||
@@ -137,7 +137,7 @@ class GithubToEntries(TextToEntries):
|
||||
# Find all markdown files in the repository
|
||||
if item["type"] == "blob" and item["path"].endswith(".md"):
|
||||
# Create URL for each markdown file on Github
|
||||
url_path = f'https://github.com/{repo.owner}/{repo.name}/blob/{repo.branch}/{item["path"]}'
|
||||
url_path = f"https://github.com/{repo.owner}/{repo.name}/blob/{repo.branch}/{item['path']}"
|
||||
|
||||
# Add markdown file contents and URL to list
|
||||
markdown_files += [{"content": self.get_file_contents(item["url"]), "path": url_path}]
|
||||
@@ -145,19 +145,19 @@ class GithubToEntries(TextToEntries):
|
||||
# Find all org files in the repository
|
||||
elif item["type"] == "blob" and item["path"].endswith(".org"):
|
||||
# Create URL for each org file on Github
|
||||
url_path = f'https://github.com/{repo.owner}/{repo.name}/blob/{repo.branch}/{item["path"]}'
|
||||
url_path = f"https://github.com/{repo.owner}/{repo.name}/blob/{repo.branch}/{item['path']}"
|
||||
|
||||
# Add org file contents and URL to list
|
||||
org_files += [{"content": self.get_file_contents(item["url"]), "path": url_path}]
|
||||
|
||||
# Find, index remaining non-binary files in the repository
|
||||
elif item["type"] == "blob":
|
||||
url_path = f'https://github.com/{repo.owner}/{repo.name}/blob/{repo.branch}/{item["path"]}'
|
||||
url_path = f"https://github.com/{repo.owner}/{repo.name}/blob/{repo.branch}/{item['path']}"
|
||||
content_bytes = self.get_file_contents(item["url"], decode=False)
|
||||
content_type, content_str = None, None
|
||||
try:
|
||||
content_type = magika.identify_bytes(content_bytes).output.group
|
||||
except:
|
||||
except Exception:
|
||||
logger.error(f"Unable to identify content type of file at {url_path}. Skip indexing it")
|
||||
continue
|
||||
|
||||
@@ -165,7 +165,7 @@ class GithubToEntries(TextToEntries):
|
||||
if content_type in ["text", "code"]:
|
||||
try:
|
||||
content_str = content_bytes.decode("utf-8")
|
||||
except:
|
||||
except Exception:
|
||||
logger.error(f"Unable to decode content of file at {url_path}. Skip indexing it")
|
||||
continue
|
||||
plaintext_files += [{"content": content_str, "path": url_path}]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import urllib3.util
|
||||
@@ -86,7 +85,7 @@ class MarkdownToEntries(TextToEntries):
|
||||
|
||||
# If content is small or content has no children headings, save it as a single entry
|
||||
if len(TextToEntries.tokenizer(markdown_content_with_ancestry)) <= max_tokens or not re.search(
|
||||
rf"^#{{{len(ancestry)+1},}}\s", markdown_content, flags=re.MULTILINE
|
||||
rf"^#{{{len(ancestry) + 1},}}\s", markdown_content, flags=re.MULTILINE
|
||||
):
|
||||
# Create entry with line number information
|
||||
entry_with_line_info = (markdown_content_with_ancestry, markdown_file, start_line)
|
||||
@@ -160,7 +159,7 @@ class MarkdownToEntries(TextToEntries):
|
||||
calculated_line = start_line if start_line > 0 else 1
|
||||
|
||||
# Check if raw_filename is a URL. If so, save it as is. If not, convert it to a Path.
|
||||
if type(raw_filename) == str and re.search(r"^https?://", raw_filename):
|
||||
if isinstance(raw_filename, str) and re.search(r"^https?://", raw_filename):
|
||||
# Escape the URL to avoid issues with special characters
|
||||
entry_filename = urllib3.util.parse_url(raw_filename).url
|
||||
uri = entry_filename
|
||||
|
||||
@@ -91,7 +91,7 @@ class NotionToEntries(TextToEntries):
|
||||
json=self.body_params,
|
||||
).json()
|
||||
responses.append(result)
|
||||
if result.get("has_more", False) == False:
|
||||
if not result.get("has_more", False):
|
||||
break
|
||||
else:
|
||||
self.body_params.update({"start_cursor": result["next_cursor"]})
|
||||
@@ -118,7 +118,7 @@ class NotionToEntries(TextToEntries):
|
||||
page_id = page["id"]
|
||||
title, content = self.get_page_content(page_id)
|
||||
|
||||
if title == None or content == None:
|
||||
if title is None or content is None:
|
||||
return []
|
||||
|
||||
current_entries = []
|
||||
@@ -126,11 +126,11 @@ class NotionToEntries(TextToEntries):
|
||||
for block in content.get("results", []):
|
||||
block_type = block.get("type")
|
||||
|
||||
if block_type == None:
|
||||
if block_type is None:
|
||||
continue
|
||||
block_data = block[block_type]
|
||||
|
||||
if block_data.get("rich_text") == None or len(block_data["rich_text"]) == 0:
|
||||
if block_data.get("rich_text") is None or len(block_data["rich_text"]) == 0:
|
||||
# There's no text to handle here.
|
||||
continue
|
||||
|
||||
@@ -179,7 +179,7 @@ class NotionToEntries(TextToEntries):
|
||||
results = children.get("results", [])
|
||||
for child in results:
|
||||
child_type = child.get("type")
|
||||
if child_type == None:
|
||||
if child_type is None:
|
||||
continue
|
||||
child_data = child[child_type]
|
||||
if child_data.get("rich_text") and len(child_data["rich_text"]) > 0:
|
||||
|
||||
@@ -8,7 +8,6 @@ from khoj.database.models import KhojUser
|
||||
from khoj.processor.content.org_mode import orgnode
|
||||
from khoj.processor.content.org_mode.orgnode import Orgnode
|
||||
from khoj.processor.content.text_to_entries import TextToEntries
|
||||
from khoj.utils import state
|
||||
from khoj.utils.helpers import timer
|
||||
from khoj.utils.rawconfig import Entry
|
||||
|
||||
@@ -103,7 +102,7 @@ class OrgToEntries(TextToEntries):
|
||||
# If content is small or content has no children headings, save it as a single entry
|
||||
# Note: This is the terminating condition for this recursive function
|
||||
if len(TextToEntries.tokenizer(org_content_with_ancestry)) <= max_tokens or not re.search(
|
||||
rf"^\*{{{len(ancestry)+1},}}\s", org_content, re.MULTILINE
|
||||
rf"^\*{{{len(ancestry) + 1},}}\s", org_content, re.MULTILINE
|
||||
):
|
||||
orgnode_content_with_ancestry = orgnode.makelist(
|
||||
org_content_with_ancestry, org_file, start_line=start_line, ancestry_lines=len(ancestry)
|
||||
@@ -195,7 +194,7 @@ class OrgToEntries(TextToEntries):
|
||||
if not entry_heading and parsed_entry.level > 0:
|
||||
base_level = parsed_entry.level
|
||||
# Indent entry by 1 heading level as ancestry is prepended as top level heading
|
||||
heading = f"{'*' * (parsed_entry.level-base_level+2)} {todo_str}" if parsed_entry.level > 0 else ""
|
||||
heading = f"{'*' * (parsed_entry.level - base_level + 2)} {todo_str}" if parsed_entry.level > 0 else ""
|
||||
if parsed_entry.heading:
|
||||
heading += f"{parsed_entry.heading}."
|
||||
|
||||
@@ -212,10 +211,10 @@ class OrgToEntries(TextToEntries):
|
||||
compiled += f"\t {tags_str}."
|
||||
|
||||
if parsed_entry.closed:
|
||||
compiled += f'\n Closed on {parsed_entry.closed.strftime("%Y-%m-%d")}.'
|
||||
compiled += f"\n Closed on {parsed_entry.closed.strftime('%Y-%m-%d')}."
|
||||
|
||||
if parsed_entry.scheduled:
|
||||
compiled += f'\n Scheduled for {parsed_entry.scheduled.strftime("%Y-%m-%d")}.'
|
||||
compiled += f"\n Scheduled for {parsed_entry.scheduled.strftime('%Y-%m-%d')}."
|
||||
|
||||
if parsed_entry.hasBody:
|
||||
compiled += f"\n {parsed_entry.body}"
|
||||
|
||||
@@ -65,7 +65,7 @@ def makelist(file, filename, start_line: int = 1, ancestry_lines: int = 0) -> Li
|
||||
"""
|
||||
ctr = 0
|
||||
|
||||
if type(file) == str:
|
||||
if isinstance(file, str):
|
||||
f = file.splitlines()
|
||||
else:
|
||||
f = file
|
||||
@@ -512,11 +512,11 @@ class Orgnode(object):
|
||||
if self._closed or self._scheduled or self._deadline:
|
||||
n = n + indent
|
||||
if self._closed:
|
||||
n = n + f'CLOSED: [{self._closed.strftime("%Y-%m-%d %a")}] '
|
||||
n = n + f"CLOSED: [{self._closed.strftime('%Y-%m-%d %a')}] "
|
||||
if self._scheduled:
|
||||
n = n + f'SCHEDULED: <{self._scheduled.strftime("%Y-%m-%d %a")}> '
|
||||
n = n + f"SCHEDULED: <{self._scheduled.strftime('%Y-%m-%d %a')}> "
|
||||
if self._deadline:
|
||||
n = n + f'DEADLINE: <{self._deadline.strftime("%Y-%m-%d %a")}> '
|
||||
n = n + f"DEADLINE: <{self._deadline.strftime('%Y-%m-%d %a')}> "
|
||||
if self._closed or self._scheduled or self._deadline:
|
||||
n = n + "\n"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import urllib3
|
||||
@@ -97,7 +96,7 @@ class PlaintextToEntries(TextToEntries):
|
||||
for parsed_entry in parsed_entries:
|
||||
raw_filename = entry_to_file_map[parsed_entry]
|
||||
# Check if raw_filename is a URL. If so, save it as is. If not, convert it to a Path.
|
||||
if type(raw_filename) == str and re.search(r"^https?://", raw_filename):
|
||||
if isinstance(raw_filename, str) and re.search(r"^https?://", raw_filename):
|
||||
# Escape the URL to avoid issues with special characters
|
||||
entry_filename = urllib3.util.parse_url(raw_filename).url
|
||||
else:
|
||||
|
||||
@@ -30,8 +30,7 @@ class TextToEntries(ABC):
|
||||
self.date_filter = DateFilter()
|
||||
|
||||
@abstractmethod
|
||||
def process(self, files: dict[str, str], user: KhojUser, regenerate: bool = False) -> Tuple[int, int]:
|
||||
...
|
||||
def process(self, files: dict[str, str], user: KhojUser, regenerate: bool = False) -> Tuple[int, int]: ...
|
||||
|
||||
@staticmethod
|
||||
def hash_func(key: str) -> Callable:
|
||||
|
||||
@@ -194,7 +194,7 @@ def gemini_completion_with_backoff(
|
||||
or not response.candidates[0].content
|
||||
or response.candidates[0].content.parts is None
|
||||
):
|
||||
raise ValueError(f"Failed to get response from model.")
|
||||
raise ValueError("Failed to get response from model.")
|
||||
raw_content = [part.model_dump() for part in response.candidates[0].content.parts]
|
||||
if response.function_calls:
|
||||
function_calls = [
|
||||
@@ -212,7 +212,7 @@ def gemini_completion_with_backoff(
|
||||
response = None
|
||||
# Handle 429 rate limit errors directly
|
||||
if e.code == 429:
|
||||
response_text = f"My brain is exhausted. Can you please try again in a bit?"
|
||||
response_text = "My brain is exhausted. Can you please try again in a bit?"
|
||||
# Log the full error details for debugging
|
||||
logger.error(f"Gemini ClientError: {e.code} {e.status}. Details: {e.details}")
|
||||
# Handle other errors
|
||||
@@ -361,7 +361,7 @@ def handle_gemini_response(
|
||||
|
||||
# Ensure we have a proper list of candidates
|
||||
if not isinstance(candidates, list):
|
||||
message = f"\nUnexpected response format. Try again."
|
||||
message = "\nUnexpected response format. Try again."
|
||||
stopped = True
|
||||
return message, stopped
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from functools import partial
|
||||
from time import perf_counter
|
||||
from typing import AsyncGenerator, Dict, Generator, List, Literal, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
@@ -284,9 +283,9 @@ async def chat_completion_with_backoff(
|
||||
if len(system_messages) > 0:
|
||||
first_system_message_index, first_system_message = system_messages[0]
|
||||
first_system_message_content = first_system_message["content"]
|
||||
formatted_messages[first_system_message_index][
|
||||
"content"
|
||||
] = f"{first_system_message_content}\nFormatting re-enabled"
|
||||
formatted_messages[first_system_message_index]["content"] = (
|
||||
f"{first_system_message_content}\nFormatting re-enabled"
|
||||
)
|
||||
elif is_twitter_reasoning_model(model_name, api_base_url):
|
||||
reasoning_effort = "high" if deepthought else "low"
|
||||
# Grok-4 models do not support reasoning_effort parameter
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
@@ -18,7 +17,7 @@ import requests
|
||||
import tiktoken
|
||||
import yaml
|
||||
from langchain_core.messages.chat import ChatMessage
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError, create_model
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from transformers import AutoTokenizer, PreTrainedTokenizer, PreTrainedTokenizerFast
|
||||
|
||||
from khoj.database.adapters import ConversationAdapters
|
||||
@@ -47,7 +46,11 @@ from khoj.utils.yaml import yaml_dump
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from git import Repo
|
||||
import importlib.util
|
||||
|
||||
git_spec = importlib.util.find_spec("git")
|
||||
if git_spec is None:
|
||||
raise ImportError
|
||||
except ImportError:
|
||||
if is_promptrace_enabled():
|
||||
logger.warning("GitPython not installed. `pip install gitpython` to use prompt tracer.")
|
||||
@@ -294,7 +297,7 @@ def construct_chat_history_for_operator(conversation_history: List[ChatMessageMo
|
||||
if chat.by == "you" and chat.message:
|
||||
content = [{"type": "text", "text": chat.message}]
|
||||
for file in chat.queryFiles or []:
|
||||
content += [{"type": "text", "text": f'## File: {file["name"]}\n\n{file["content"]}'}]
|
||||
content += [{"type": "text", "text": f"## File: {file['name']}\n\n{file['content']}"}]
|
||||
user_message = AgentMessage(role="user", content=content)
|
||||
elif chat.by == "khoj" and chat.message:
|
||||
chat_history += [user_message, AgentMessage(role="assistant", content=chat.message)]
|
||||
@@ -311,7 +314,10 @@ def construct_tool_chat_history(
|
||||
If no tool is provided inferred query for all tools used are added.
|
||||
"""
|
||||
chat_history: list = []
|
||||
base_extractor: Callable[[ResearchIteration], List[str]] = lambda iteration: []
|
||||
|
||||
def base_extractor(iteration: ResearchIteration) -> List[str]:
|
||||
return []
|
||||
|
||||
extract_inferred_query_map: Dict[ConversationCommand, Callable[[ResearchIteration], List[str]]] = {
|
||||
ConversationCommand.SemanticSearchFiles: (
|
||||
lambda iteration: [c["query"] for c in iteration.context] if iteration.context else []
|
||||
@@ -498,7 +504,7 @@ async def save_to_conversation_log(
|
||||
|
||||
logger.info(
|
||||
f"""
|
||||
Saved Conversation Turn ({db_conversation.id if db_conversation else 'N/A'}):
|
||||
Saved Conversation Turn ({db_conversation.id if db_conversation else "N/A"}):
|
||||
You ({user.username}): "{q}"
|
||||
|
||||
Khoj: "{chat_response}"
|
||||
@@ -625,7 +631,7 @@ def generate_chatml_messages_with_context(
|
||||
|
||||
if not is_none_or_empty(chat.operatorContext):
|
||||
operator_context = chat.operatorContext
|
||||
operator_content = "\n\n".join([f'## Task: {oc["query"]}\n{oc["response"]}\n' for oc in operator_context])
|
||||
operator_content = "\n\n".join([f"## Task: {oc['query']}\n{oc['response']}\n" for oc in operator_context])
|
||||
message_context += [
|
||||
{
|
||||
"type": "text",
|
||||
@@ -744,7 +750,7 @@ def get_encoder(
|
||||
else:
|
||||
# as tiktoken doesn't recognize o1 model series yet
|
||||
encoder = tiktoken.encoding_for_model("gpt-4o" if model_name.startswith("o1") else model_name)
|
||||
except:
|
||||
except Exception:
|
||||
encoder = tiktoken.encoding_for_model(default_tokenizer)
|
||||
if state.verbose > 2:
|
||||
logger.debug(
|
||||
@@ -846,9 +852,9 @@ def truncate_messages(
|
||||
total_tokens, _ = count_total_tokens(messages, encoder, system_message)
|
||||
if total_tokens > max_prompt_size:
|
||||
# At this point, a single message with a single content part of type dict should remain
|
||||
assert (
|
||||
len(messages) == 1 and len(messages[0].content) == 1 and isinstance(messages[0].content[0], dict)
|
||||
), "Expected a single message with a single content part remaining at this point in truncation"
|
||||
assert len(messages) == 1 and len(messages[0].content) == 1 and isinstance(messages[0].content[0], dict), (
|
||||
"Expected a single message with a single content part remaining at this point in truncation"
|
||||
)
|
||||
|
||||
# Collate message content into single string to ease truncation
|
||||
part = messages[0].content[0]
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import logging
|
||||
from typing import List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import openai
|
||||
import requests
|
||||
import tqdm
|
||||
from sentence_transformers import CrossEncoder, SentenceTransformer
|
||||
|
||||
@@ -108,12 +108,12 @@ async def text_to_image(
|
||||
if "content_policy_violation" in e.message:
|
||||
logger.error(f"Image Generation blocked by OpenAI: {e}")
|
||||
status_code = e.status_code # type: ignore
|
||||
message = f"Image generation blocked by OpenAI due to policy violation" # type: ignore
|
||||
message = "Image generation blocked by OpenAI due to policy violation" # type: ignore
|
||||
yield image_url or image, status_code, message
|
||||
return
|
||||
else:
|
||||
logger.error(f"Image Generation failed with {e}", exc_info=True)
|
||||
message = f"Image generation failed using OpenAI" # type: ignore
|
||||
message = "Image generation failed using OpenAI" # type: ignore
|
||||
status_code = e.status_code # type: ignore
|
||||
yield image_url or image, status_code, message
|
||||
return
|
||||
@@ -199,7 +199,7 @@ def generate_image_with_stability(
|
||||
|
||||
# Call Stability AI API to generate image
|
||||
response = requests.post(
|
||||
f"https://api.stability.ai/v2beta/stable-image/generate/sd3",
|
||||
"https://api.stability.ai/v2beta/stable-image/generate/sd3",
|
||||
headers={"authorization": f"Bearer {text_to_image_config.api_key}", "accept": "image/*"},
|
||||
files={"none": ""},
|
||||
data={
|
||||
|
||||
@@ -11,7 +11,7 @@ from khoj.processor.conversation.utils import (
|
||||
OperatorRun,
|
||||
construct_chat_history_for_operator,
|
||||
)
|
||||
from khoj.processor.operator.operator_actions import *
|
||||
from khoj.processor.operator.operator_actions import RequestUserAction
|
||||
from khoj.processor.operator.operator_agent_anthropic import AnthropicOperatorAgent
|
||||
from khoj.processor.operator.operator_agent_base import OperatorAgent
|
||||
from khoj.processor.operator.operator_agent_binary import BinaryOperatorAgent
|
||||
@@ -59,7 +59,7 @@ async def operate_environment(
|
||||
if not reasoning_model or not reasoning_model.vision_enabled:
|
||||
reasoning_model = await ConversationAdapters.aget_vision_enabled_config()
|
||||
if not reasoning_model:
|
||||
raise ValueError(f"No vision enabled chat model found. Configure a vision chat model to operate environment.")
|
||||
raise ValueError("No vision enabled chat model found. Configure a vision chat model to operate environment.")
|
||||
|
||||
# Create conversation history from conversation log
|
||||
chat_history = construct_chat_history_for_operator(conversation_log)
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import json
|
||||
import logging
|
||||
from textwrap import dedent
|
||||
from typing import List, Optional
|
||||
|
||||
from openai import AzureOpenAI, OpenAI
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
|
||||
from khoj.database.models import ChatModel
|
||||
from khoj.processor.conversation.utils import construct_structured_message
|
||||
from khoj.processor.operator.operator_actions import *
|
||||
from khoj.processor.operator.operator_agent_base import AgentActResult
|
||||
from khoj.processor.operator.operator_actions import (
|
||||
BackAction,
|
||||
ClickAction,
|
||||
DoubleClickAction,
|
||||
DragAction,
|
||||
GotoAction,
|
||||
KeypressAction,
|
||||
OperatorAction,
|
||||
Point,
|
||||
ScreenshotAction,
|
||||
ScrollAction,
|
||||
TypeAction,
|
||||
WaitAction,
|
||||
)
|
||||
from khoj.processor.operator.operator_environment_base import EnvironmentType, EnvState
|
||||
from khoj.utils.helpers import get_chat_usage_metrics
|
||||
|
||||
|
||||
@@ -18,7 +18,22 @@ from openai import AsyncAzureOpenAI, AsyncOpenAI
|
||||
from openai.types.chat import ChatCompletion
|
||||
from PIL import Image
|
||||
|
||||
from khoj.processor.operator.operator_actions import *
|
||||
from khoj.processor.operator.operator_actions import (
|
||||
BackAction,
|
||||
ClickAction,
|
||||
DoubleClickAction,
|
||||
DragAction,
|
||||
GotoAction,
|
||||
KeyDownAction,
|
||||
KeypressAction,
|
||||
KeyUpAction,
|
||||
MoveAction,
|
||||
OperatorAction,
|
||||
RequestUserAction,
|
||||
ScrollAction,
|
||||
TypeAction,
|
||||
WaitAction,
|
||||
)
|
||||
from khoj.processor.operator.operator_environment_base import EnvironmentType, EnvState
|
||||
from khoj.utils.helpers import get_chat_usage_metrics
|
||||
|
||||
@@ -122,11 +137,10 @@ class GroundingAgentUitars:
|
||||
)
|
||||
|
||||
temperature = self.temperature
|
||||
top_k = self.top_k
|
||||
try_times = 3
|
||||
while not parsed_responses:
|
||||
if try_times <= 0:
|
||||
logger.warning(f"Reach max retry times to fetch response from client, as error flag.")
|
||||
logger.warning("Reach max retry times to fetch response from client, as error flag.")
|
||||
return "client error\nFAIL", []
|
||||
try:
|
||||
message_content = "\n".join([msg["content"][0].get("text") or "[image]" for msg in messages])
|
||||
@@ -163,7 +177,6 @@ class GroundingAgentUitars:
|
||||
prediction = None
|
||||
try_times -= 1
|
||||
temperature = 1
|
||||
top_k = -1
|
||||
|
||||
if prediction is None:
|
||||
return "client error\nFAIL", []
|
||||
@@ -264,9 +277,9 @@ class GroundingAgentUitars:
|
||||
raise ValueError(f"Unsupported environment type: {environment_type}")
|
||||
|
||||
def _format_messages_for_api(self, instruction: str, current_state: EnvState):
|
||||
assert len(self.observations) == len(self.actions) and len(self.actions) == len(
|
||||
self.thoughts
|
||||
), "The number of observations and actions should be the same."
|
||||
assert len(self.observations) == len(self.actions) and len(self.actions) == len(self.thoughts), (
|
||||
"The number of observations and actions should be the same."
|
||||
)
|
||||
|
||||
self.history_images.append(base64.b64decode(current_state.screenshot))
|
||||
self.observations.append({"screenshot": current_state.screenshot, "accessibility_tree": None})
|
||||
@@ -524,7 +537,7 @@ class GroundingAgentUitars:
|
||||
parsed_actions = [self.parse_action_string(action.replace("\n", "\\n").lstrip()) for action in all_action]
|
||||
actions: list[dict] = []
|
||||
for action_instance, raw_str in zip(parsed_actions, all_action):
|
||||
if action_instance == None:
|
||||
if action_instance is None:
|
||||
print(f"Action can't parse: {raw_str}")
|
||||
raise ValueError(f"Action can't parse: {raw_str}")
|
||||
action_type = action_instance["function"]
|
||||
@@ -756,7 +769,7 @@ class GroundingAgentUitars:
|
||||
The pyautogui code string
|
||||
"""
|
||||
|
||||
pyautogui_code = f"import pyautogui\nimport time\n"
|
||||
pyautogui_code = "import pyautogui\nimport time\n"
|
||||
actions = []
|
||||
if isinstance(responses, dict):
|
||||
responses = [responses]
|
||||
@@ -774,7 +787,7 @@ class GroundingAgentUitars:
|
||||
if response_id == 0:
|
||||
pyautogui_code += f"'''\nObservation:\n{observation}\n\nThought:\n{thought}\n'''\n"
|
||||
else:
|
||||
pyautogui_code += f"\ntime.sleep(1)\n"
|
||||
pyautogui_code += "\ntime.sleep(1)\n"
|
||||
|
||||
action_dict = response
|
||||
action_type = action_dict.get("action_type")
|
||||
@@ -846,17 +859,17 @@ class GroundingAgentUitars:
|
||||
if content:
|
||||
if input_swap:
|
||||
actions += TypeAction()
|
||||
pyautogui_code += f"\nimport pyperclip"
|
||||
pyautogui_code += "\nimport pyperclip"
|
||||
pyautogui_code += f"\npyperclip.copy('{stripped_content}')"
|
||||
pyautogui_code += f"\npyautogui.hotkey('ctrl', 'v')"
|
||||
pyautogui_code += f"\ntime.sleep(0.5)\n"
|
||||
pyautogui_code += "\npyautogui.hotkey('ctrl', 'v')"
|
||||
pyautogui_code += "\ntime.sleep(0.5)\n"
|
||||
if content.endswith("\n") or content.endswith("\\n"):
|
||||
pyautogui_code += f"\npyautogui.press('enter')"
|
||||
pyautogui_code += "\npyautogui.press('enter')"
|
||||
else:
|
||||
pyautogui_code += f"\npyautogui.write('{stripped_content}', interval=0.1)"
|
||||
pyautogui_code += f"\ntime.sleep(0.5)\n"
|
||||
pyautogui_code += "\ntime.sleep(0.5)\n"
|
||||
if content.endswith("\n") or content.endswith("\\n"):
|
||||
pyautogui_code += f"\npyautogui.press('enter')"
|
||||
pyautogui_code += "\npyautogui.press('enter')"
|
||||
|
||||
elif action_type in ["drag", "select"]:
|
||||
# Parsing drag or select action based on start and end_boxes
|
||||
@@ -869,9 +882,7 @@ class GroundingAgentUitars:
|
||||
x1, y1, x2, y2 = eval(end_box) # Assuming box is in [x1, y1, x2, y2]
|
||||
ex = round(float((x1 + x2) / 2) * image_width, 3)
|
||||
ey = round(float((y1 + y2) / 2) * image_height, 3)
|
||||
pyautogui_code += (
|
||||
f"\npyautogui.moveTo({sx}, {sy})\n" f"\npyautogui.dragTo({ex}, {ey}, duration=1.0)\n"
|
||||
)
|
||||
pyautogui_code += f"\npyautogui.moveTo({sx}, {sy})\n\npyautogui.dragTo({ex}, {ey}, duration=1.0)\n"
|
||||
|
||||
elif action_type == "scroll":
|
||||
# Parsing scroll action
|
||||
@@ -888,11 +899,11 @@ class GroundingAgentUitars:
|
||||
y = None
|
||||
direction = action_inputs.get("direction", "")
|
||||
|
||||
if x == None:
|
||||
if x is None:
|
||||
if "up" in direction.lower():
|
||||
pyautogui_code += f"\npyautogui.scroll(5)"
|
||||
pyautogui_code += "\npyautogui.scroll(5)"
|
||||
elif "down" in direction.lower():
|
||||
pyautogui_code += f"\npyautogui.scroll(-5)"
|
||||
pyautogui_code += "\npyautogui.scroll(-5)"
|
||||
else:
|
||||
if "up" in direction.lower():
|
||||
pyautogui_code += f"\npyautogui.scroll(5, x={x}, y={y})"
|
||||
@@ -923,7 +934,7 @@ class GroundingAgentUitars:
|
||||
pyautogui_code += f"\npyautogui.moveTo({x}, {y})"
|
||||
|
||||
elif action_type in ["finished"]:
|
||||
pyautogui_code = f"DONE"
|
||||
pyautogui_code = "DONE"
|
||||
|
||||
else:
|
||||
pyautogui_code += f"\n# Unrecognized action type: {action_type}"
|
||||
|
||||
@@ -11,7 +11,32 @@ from anthropic.types.beta import BetaContentBlock, BetaTextBlock, BetaToolUseBlo
|
||||
from khoj.database.models import ChatModel
|
||||
from khoj.processor.conversation.anthropic.utils import is_reasoning_model
|
||||
from khoj.processor.conversation.utils import AgentMessage
|
||||
from khoj.processor.operator.operator_actions import *
|
||||
from khoj.processor.operator.operator_actions import (
|
||||
BackAction,
|
||||
ClickAction,
|
||||
CursorPositionAction,
|
||||
DoubleClickAction,
|
||||
DragAction,
|
||||
GotoAction,
|
||||
HoldKeyAction,
|
||||
KeypressAction,
|
||||
MouseDownAction,
|
||||
MouseUpAction,
|
||||
MoveAction,
|
||||
NoopAction,
|
||||
OperatorAction,
|
||||
Point,
|
||||
ScreenshotAction,
|
||||
ScrollAction,
|
||||
TerminalAction,
|
||||
TextEditorCreateAction,
|
||||
TextEditorInsertAction,
|
||||
TextEditorStrReplaceAction,
|
||||
TextEditorViewAction,
|
||||
TripleClickAction,
|
||||
TypeAction,
|
||||
WaitAction,
|
||||
)
|
||||
from khoj.processor.operator.operator_agent_base import AgentActResult, OperatorAgent
|
||||
from khoj.processor.operator.operator_environment_base import (
|
||||
EnvironmentType,
|
||||
@@ -518,7 +543,7 @@ class AnthropicOperatorAgent(OperatorAgent):
|
||||
def model_default_headers(self) -> list[str]:
|
||||
"""Get the default computer use headers for the given model."""
|
||||
if self.vision_model.name.startswith("claude-3-7-sonnet"):
|
||||
return [f"computer-use-2025-01-24", "token-efficient-tools-2025-02-19"]
|
||||
return ["computer-use-2025-01-24", "token-efficient-tools-2025-02-19"]
|
||||
elif self.vision_model.name.startswith("claude-sonnet-4") or self.vision_model.name.startswith("claude-opus-4"):
|
||||
return ["computer-use-2025-01-24"]
|
||||
else:
|
||||
@@ -538,7 +563,7 @@ class AnthropicOperatorAgent(OperatorAgent):
|
||||
* When viewing a webpage it can be helpful to zoom out so that you can see everything on the page. Either that, or make sure you scroll down to see everything before deciding something isn't available.
|
||||
* When using your computer function calls, they take a while to run and send back to you. Where possible/feasible, try to chain multiple of these calls all into one function calls request.
|
||||
* Perform web searches using DuckDuckGo. Don't use Google even if requested as the query will fail.
|
||||
* The current date is {datetime.today().strftime('%A, %B %-d, %Y')}.
|
||||
* The current date is {datetime.today().strftime("%A, %B %-d, %Y")}.
|
||||
* The current URL is {current_state.url}.
|
||||
</SYSTEM_CAPABILITY>
|
||||
|
||||
@@ -563,7 +588,7 @@ class AnthropicOperatorAgent(OperatorAgent):
|
||||
</SYSTEM_CAPABILITY>
|
||||
|
||||
<CONTEXT>
|
||||
* The current date is {datetime.today().strftime('%A, %B %-d, %Y')}.
|
||||
* The current date is {datetime.today().strftime("%A, %B %-d, %Y")}.
|
||||
</CONTEXT>
|
||||
"""
|
||||
).lstrip()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Literal, Optional, Union
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from khoj.processor.conversation.utils import (
|
||||
)
|
||||
from khoj.processor.operator.grounding_agent import GroundingAgent
|
||||
from khoj.processor.operator.grounding_agent_uitars import GroundingAgentUitars
|
||||
from khoj.processor.operator.operator_actions import *
|
||||
from khoj.processor.operator.operator_actions import OperatorAction, WaitAction
|
||||
from khoj.processor.operator.operator_agent_base import AgentActResult, OperatorAgent
|
||||
from khoj.processor.operator.operator_environment_base import (
|
||||
EnvironmentType,
|
||||
@@ -181,7 +181,7 @@ class BinaryOperatorAgent(OperatorAgent):
|
||||
elif action.type == "key_down":
|
||||
rendered_parts += [f'**Action**: Press Key "{action.key}"']
|
||||
elif action.type == "screenshot" and not current_state.screenshot:
|
||||
rendered_parts += [f"**Error**: Failed to take screenshot"]
|
||||
rendered_parts += ["**Error**: Failed to take screenshot"]
|
||||
elif action.type == "goto":
|
||||
rendered_parts += [f"**Action**: Open URL {action.url}"]
|
||||
else:
|
||||
@@ -317,7 +317,7 @@ class BinaryOperatorAgent(OperatorAgent):
|
||||
# Introduction
|
||||
* You are Khoj, a smart and resourceful web browsing assistant. You help the user accomplish their task using a web browser.
|
||||
* You are given the user's query and screenshots of the browser's state transitions.
|
||||
* The current date is {datetime.today().strftime('%A, %B %-d, %Y')}.
|
||||
* The current date is {datetime.today().strftime("%A, %B %-d, %Y")}.
|
||||
* The current URL is {env_state.url}.
|
||||
|
||||
# Your Task
|
||||
@@ -362,7 +362,7 @@ class BinaryOperatorAgent(OperatorAgent):
|
||||
# Introduction
|
||||
* You are Khoj, a smart and resourceful computer assistant. You help the user accomplish their task using a computer.
|
||||
* You are given the user's query and screenshots of the computer's state transitions.
|
||||
* The current date is {datetime.today().strftime('%A, %B %-d, %Y')}.
|
||||
* The current date is {datetime.today().strftime("%A, %B %-d, %Y")}.
|
||||
|
||||
# Your Task
|
||||
* First look at the screenshots carefully to notice all pertinent information.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import platform
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from textwrap import dedent
|
||||
@@ -10,7 +9,23 @@ from openai.types.responses import Response, ResponseOutputItem
|
||||
|
||||
from khoj.database.models import ChatModel
|
||||
from khoj.processor.conversation.utils import AgentMessage
|
||||
from khoj.processor.operator.operator_actions import *
|
||||
from khoj.processor.operator.operator_actions import (
|
||||
BackAction,
|
||||
ClickAction,
|
||||
DoubleClickAction,
|
||||
DragAction,
|
||||
GotoAction,
|
||||
KeypressAction,
|
||||
MoveAction,
|
||||
NoopAction,
|
||||
OperatorAction,
|
||||
Point,
|
||||
RequestUserAction,
|
||||
ScreenshotAction,
|
||||
ScrollAction,
|
||||
TypeAction,
|
||||
WaitAction,
|
||||
)
|
||||
from khoj.processor.operator.operator_agent_base import AgentActResult, OperatorAgent
|
||||
from khoj.processor.operator.operator_environment_base import (
|
||||
EnvironmentType,
|
||||
@@ -152,7 +167,7 @@ class OpenAIOperatorAgent(OperatorAgent):
|
||||
# Add screenshot data in openai message format
|
||||
action_result["output"] = {
|
||||
"type": "input_image",
|
||||
"image_url": f'data:image/webp;base64,{result_content["image"]}',
|
||||
"image_url": f"data:image/webp;base64,{result_content['image']}",
|
||||
"current_url": result_content["url"],
|
||||
}
|
||||
elif action_result["type"] == "computer_call_output" and idx == len(env_steps) - 1:
|
||||
@@ -311,7 +326,7 @@ class OpenAIOperatorAgent(OperatorAgent):
|
||||
elif block.type == "function_call":
|
||||
if block.name == "goto":
|
||||
args = json.loads(block.arguments)
|
||||
render_texts = [f'Open URL: {args.get("url", "[Missing URL]")}']
|
||||
render_texts = [f"Open URL: {args.get('url', '[Missing URL]')}"]
|
||||
else:
|
||||
render_texts += [block.name]
|
||||
elif block.type == "computer_call":
|
||||
@@ -351,7 +366,7 @@ class OpenAIOperatorAgent(OperatorAgent):
|
||||
* When viewing a webpage it can be helpful to zoom out so that you can see everything on the page. Either that, or make sure you scroll down to see everything before deciding something isn't available.
|
||||
* When using your computer function calls, they take a while to run and send back to you. Where possible/feasible, try to chain multiple of these calls all into one function calls request.
|
||||
* Perform web searches using DuckDuckGo. Don't use Google even if requested as the query will fail.
|
||||
* The current date is {datetime.today().strftime('%A, %B %-d, %Y')}.
|
||||
* The current date is {datetime.today().strftime("%A, %B %-d, %Y")}.
|
||||
* The current URL is {current_state.url}.
|
||||
</SYSTEM_CAPABILITY>
|
||||
|
||||
@@ -374,7 +389,7 @@ class OpenAIOperatorAgent(OperatorAgent):
|
||||
</SYSTEM_CAPABILITY>
|
||||
|
||||
<CONTEXT>
|
||||
* The current date is {datetime.today().strftime('%A, %B %-d, %Y')}.
|
||||
* The current date is {datetime.today().strftime("%A, %B %-d, %Y")}.
|
||||
</CONTEXT>
|
||||
"""
|
||||
).lstrip()
|
||||
|
||||
@@ -247,7 +247,7 @@ class BrowserEnvironment(Environment):
|
||||
|
||||
case "drag":
|
||||
if not isinstance(action, DragAction):
|
||||
raise TypeError(f"Invalid action type for drag")
|
||||
raise TypeError("Invalid action type for drag")
|
||||
path = action.path
|
||||
if not path:
|
||||
error = "Missing path for drag action"
|
||||
|
||||
@@ -532,7 +532,7 @@ class ComputerEnvironment(Environment):
|
||||
else:
|
||||
return {"success": False, "output": process.stdout, "error": process.stderr}
|
||||
except asyncio.TimeoutError:
|
||||
return {"success": False, "output": "", "error": f"Command timed out after 120 seconds."}
|
||||
return {"success": False, "output": "", "error": "Command timed out after 120 seconds."}
|
||||
except Exception as e:
|
||||
return {"success": False, "output": "", "error": str(e)}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json # Used for working with JSON data
|
||||
import os
|
||||
|
||||
import requests # Used for making HTTP requests
|
||||
|
||||
@@ -385,7 +385,7 @@ async def read_webpages(
|
||||
tracer: dict = {},
|
||||
):
|
||||
"Infer web pages to read from the query and extract relevant information from them"
|
||||
logger.info(f"Inferring web pages to read")
|
||||
logger.info("Inferring web pages to read")
|
||||
urls = await infer_webpage_urls(
|
||||
query,
|
||||
max_webpages_to_read,
|
||||
|
||||
@@ -93,7 +93,7 @@ async def run_code(
|
||||
|
||||
# Run Code
|
||||
if send_status_func:
|
||||
async for event in send_status_func(f"**Running code snippet**"):
|
||||
async for event in send_status_func("**Running code snippet**"):
|
||||
yield {ChatEvent.STATUS: event}
|
||||
try:
|
||||
with timer("Chat actor: Execute generated program", logger, log_level=logging.INFO):
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import List, Optional, Union
|
||||
|
||||
import openai
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from fastapi.requests import Request
|
||||
from fastapi.responses import Response
|
||||
from starlette.authentication import has_required_scope, requires
|
||||
|
||||
@@ -94,7 +93,7 @@ def update(
|
||||
logger.error(error_msg, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=error_msg)
|
||||
else:
|
||||
logger.info(f"📪 Server indexed content updated via API")
|
||||
logger.info("📪 Server indexed content updated via API")
|
||||
|
||||
update_telemetry_state(
|
||||
request=request,
|
||||
|
||||
@@ -6,12 +6,11 @@ from typing import Dict, List, Optional
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.requests import Request
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from starlette.authentication import has_required_scope, requires
|
||||
|
||||
from khoj.database.adapters import AgentAdapters, ConversationAdapters, EntryAdapters
|
||||
from khoj.database.adapters import AgentAdapters, ConversationAdapters
|
||||
from khoj.database.models import Agent, Conversation, KhojUser, PriceTier
|
||||
from khoj.routers.helpers import CommonQueryParams, acheck_if_safe_prompt
|
||||
from khoj.utils.helpers import (
|
||||
|
||||
@@ -109,7 +109,7 @@ def post_automation(
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating automation {q} for {user.email}: {e}", exc_info=True)
|
||||
return Response(
|
||||
content=f"Unable to create automation. Ensure the automation doesn't already exist.",
|
||||
content="Unable to create automation. Ensure the automation doesn't already exist.",
|
||||
media_type="text/plain",
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ from functools import partial
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import unquote
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
@@ -32,10 +31,10 @@ from khoj.database.adapters import (
|
||||
PublicConversationAdapters,
|
||||
aget_user_name,
|
||||
)
|
||||
from khoj.database.models import Agent, ChatMessageModel, KhojUser
|
||||
from khoj.database.models import Agent, KhojUser
|
||||
from khoj.processor.conversation import prompts
|
||||
from khoj.processor.conversation.openai.utils import is_local_api
|
||||
from khoj.processor.conversation.prompts import help_message, no_entries_found
|
||||
from khoj.processor.conversation.prompts import no_entries_found
|
||||
from khoj.processor.conversation.utils import (
|
||||
OperatorRun,
|
||||
ResponseWithThought,
|
||||
@@ -65,11 +64,8 @@ from khoj.routers.helpers import (
|
||||
acreate_title_from_history,
|
||||
agenerate_chat_response,
|
||||
aget_data_sources_and_output_format,
|
||||
construct_automation_created_message,
|
||||
create_automation,
|
||||
gather_raw_query_files,
|
||||
generate_mermaidjs_diagram,
|
||||
generate_summary_from_files,
|
||||
get_conversation_command,
|
||||
get_message_from_queue,
|
||||
is_query_empty,
|
||||
@@ -89,13 +85,11 @@ from khoj.utils.helpers import (
|
||||
convert_image_to_webp,
|
||||
get_country_code_from_timezone,
|
||||
get_country_name_from_timezone,
|
||||
get_device,
|
||||
is_env_var_true,
|
||||
is_none_or_empty,
|
||||
is_operator_enabled,
|
||||
)
|
||||
from khoj.utils.rawconfig import (
|
||||
ChatRequestBody,
|
||||
FileAttachment,
|
||||
FileFilterRequest,
|
||||
FilesFilterRequest,
|
||||
@@ -689,7 +683,6 @@ async def event_generator(
|
||||
region = body.region
|
||||
country = body.country or get_country_name_from_timezone(body.timezone)
|
||||
country_code = body.country_code or get_country_code_from_timezone(body.timezone)
|
||||
timezone = body.timezone
|
||||
raw_images = body.images
|
||||
raw_query_files = body.files
|
||||
|
||||
@@ -853,7 +846,8 @@ async def event_generator(
|
||||
if (
|
||||
len(train_of_thought) > 0
|
||||
and train_of_thought[-1]["type"] == ChatEvent.THOUGHT.value
|
||||
and type(train_of_thought[-1]["data"]) == type(data) == str
|
||||
and isinstance(train_of_thought[-1]["data"], str)
|
||||
and isinstance(data, str)
|
||||
):
|
||||
train_of_thought[-1]["data"] += data
|
||||
else:
|
||||
@@ -1075,11 +1069,11 @@ async def event_generator(
|
||||
|
||||
# researched_results = await extract_relevant_info(q, researched_results, agent)
|
||||
if state.verbose > 1:
|
||||
logger.debug(f'Researched Results: {"".join(r.summarizedResult or "" for r in research_results)}')
|
||||
logger.debug(f"Researched Results: {''.join(r.summarizedResult or '' for r in research_results)}")
|
||||
|
||||
# Gather Context
|
||||
## Extract Document References
|
||||
if not ConversationCommand.Research in conversation_commands:
|
||||
if ConversationCommand.Research not in conversation_commands:
|
||||
try:
|
||||
async for result in search_documents(
|
||||
q,
|
||||
@@ -1218,7 +1212,7 @@ async def event_generator(
|
||||
else:
|
||||
code_results = result
|
||||
except ValueError as e:
|
||||
program_execution_context.append(f"Failed to run code")
|
||||
program_execution_context.append("Failed to run code")
|
||||
logger.warning(
|
||||
f"Failed to use code tool: {e}. Attempting to respond without code results",
|
||||
exc_info=True,
|
||||
@@ -1297,7 +1291,7 @@ async def event_generator(
|
||||
inferred_queries.append(improved_image_prompt)
|
||||
if generated_image is None or status_code != 200:
|
||||
program_execution_context.append(f"Failed to generate image with {improved_image_prompt}")
|
||||
async for result in send_event(ChatEvent.STATUS, f"Failed to generate image"):
|
||||
async for result in send_event(ChatEvent.STATUS, "Failed to generate image"):
|
||||
yield result
|
||||
else:
|
||||
generated_images.append(generated_image)
|
||||
@@ -1315,7 +1309,7 @@ async def event_generator(
|
||||
yield result
|
||||
|
||||
if ConversationCommand.Diagram in conversation_commands:
|
||||
async for result in send_event(ChatEvent.STATUS, f"Creating diagram"):
|
||||
async for result in send_event(ChatEvent.STATUS, "Creating diagram"):
|
||||
yield result
|
||||
|
||||
inferred_queries = []
|
||||
@@ -1372,7 +1366,7 @@ async def event_generator(
|
||||
return
|
||||
|
||||
## Generate Text Output
|
||||
async for result in send_event(ChatEvent.STATUS, f"**Generating a well-informed response**"):
|
||||
async for result in send_event(ChatEvent.STATUS, "**Generating a well-informed response**"):
|
||||
yield result
|
||||
|
||||
llm_response, chat_metadata = await agenerate_chat_response(
|
||||
|
||||
@@ -3,7 +3,6 @@ import logging
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.requests import Request
|
||||
from fastapi.responses import Response
|
||||
from starlette.authentication import has_required_scope, requires
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ async def subscribe(request: Request):
|
||||
)
|
||||
logger.log(logging.INFO, f"🥳 New User Created: {user.user.uuid}")
|
||||
|
||||
logger.info(f'Stripe subscription {event["type"]} for {customer_email}')
|
||||
logger.info(f"Stripe subscription {event['type']} for {customer_email}")
|
||||
return {"success": success}
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ async def send_magic_link_email(email, unique_id, host):
|
||||
{
|
||||
"sender": os.environ.get("RESEND_EMAIL", "noreply@khoj.dev"),
|
||||
"to": email,
|
||||
"subject": f"Your login code to Khoj",
|
||||
"subject": "Your login code to Khoj",
|
||||
"html": html_content,
|
||||
}
|
||||
)
|
||||
@@ -98,11 +98,11 @@ async def send_query_feedback(uquery, kquery, sentiment, user_email):
|
||||
user_email=user_email if not is_none_or_empty(user_email) else "N/A",
|
||||
)
|
||||
# send feedback to fixed account
|
||||
r = resend.Emails.send(
|
||||
resend.Emails.send(
|
||||
{
|
||||
"sender": os.environ.get("RESEND_EMAIL", "noreply@khoj.dev"),
|
||||
"to": "team@khoj.dev",
|
||||
"subject": f"User Feedback",
|
||||
"subject": "User Feedback",
|
||||
"html": html_content,
|
||||
}
|
||||
)
|
||||
@@ -127,7 +127,7 @@ def send_task_email(name, email, query, result, subject, is_image=False):
|
||||
|
||||
r = resend.Emails.send(
|
||||
{
|
||||
"sender": f'Khoj <{os.environ.get("RESEND_EMAIL", "khoj@khoj.dev")}>',
|
||||
"sender": f"Khoj <{os.environ.get('RESEND_EMAIL', 'khoj@khoj.dev')}>",
|
||||
"to": email,
|
||||
"subject": f"✨ {subject}",
|
||||
"html": html_content,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
@@ -47,14 +46,12 @@ from khoj.database.adapters import (
|
||||
EntryAdapters,
|
||||
FileObjectAdapters,
|
||||
aget_user_by_email,
|
||||
ais_user_subscribed,
|
||||
create_khoj_token,
|
||||
get_default_search_model,
|
||||
get_khoj_tokens,
|
||||
get_user_name,
|
||||
get_user_notion_config,
|
||||
get_user_subscription_state,
|
||||
is_user_subscribed,
|
||||
run_with_process_lock,
|
||||
)
|
||||
from khoj.database.models import (
|
||||
@@ -160,7 +157,7 @@ def validate_chat_model(user: KhojUser):
|
||||
|
||||
async def is_ready_to_chat(user: KhojUser):
|
||||
user_chat_model = await ConversationAdapters.aget_user_chat_model(user)
|
||||
if user_chat_model == None:
|
||||
if user_chat_model is None:
|
||||
user_chat_model = await ConversationAdapters.aget_default_chat_model(user)
|
||||
|
||||
if (
|
||||
@@ -581,7 +578,7 @@ async def generate_online_subqueries(
|
||||
)
|
||||
return {q}
|
||||
return response
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.error(f"Invalid response for constructing online subqueries: {response}. Returning original query: {q}")
|
||||
return {q}
|
||||
|
||||
@@ -1172,8 +1169,8 @@ async def search_documents(
|
||||
agent_has_entries = await sync_to_async(EntryAdapters.agent_has_entries)(agent=agent)
|
||||
|
||||
if (
|
||||
not ConversationCommand.Notes in conversation_commands
|
||||
and not ConversationCommand.Default in conversation_commands
|
||||
ConversationCommand.Notes not in conversation_commands
|
||||
and ConversationCommand.Default not in conversation_commands
|
||||
and not agent_has_entries
|
||||
):
|
||||
yield compiled_references, inferred_queries, q
|
||||
@@ -1325,8 +1322,8 @@ async def extract_questions(
|
||||
logger.error(f"Invalid response for constructing subqueries: {response}")
|
||||
return [query]
|
||||
return queries
|
||||
except:
|
||||
logger.warning(f"LLM returned invalid JSON. Falling back to using user message as search query.")
|
||||
except Exception:
|
||||
logger.warning("LLM returned invalid JSON. Falling back to using user message as search query.")
|
||||
return [query]
|
||||
|
||||
|
||||
@@ -1351,7 +1348,7 @@ async def execute_search(
|
||||
return results
|
||||
|
||||
if q is None or q == "":
|
||||
logger.warning(f"No query param (q) passed in API call to initiate search")
|
||||
logger.warning("No query param (q) passed in API call to initiate search")
|
||||
return results
|
||||
|
||||
# initialize variables
|
||||
@@ -1364,7 +1361,7 @@ async def execute_search(
|
||||
if user:
|
||||
query_cache_key = f"{user_query}-{n}-{t}-{r}-{max_distance}-{dedupe}"
|
||||
if query_cache_key in state.query_cache[user.uuid]:
|
||||
logger.debug(f"Return response from query cache")
|
||||
logger.debug("Return response from query cache")
|
||||
return state.query_cache[user.uuid][query_cache_key]
|
||||
|
||||
# Encode query with filter terms removed
|
||||
@@ -1875,8 +1872,8 @@ class ApiUserRateLimiter:
|
||||
|
||||
user: KhojUser = websocket.scope["user"].object
|
||||
subscribed = has_required_scope(websocket, ["premium"])
|
||||
current_window = "today" if self.window == 60 * 60 * 24 else f"now"
|
||||
next_window = "tomorrow" if self.window == 60 * 60 * 24 else f"in a bit"
|
||||
current_window = "today" if self.window == 60 * 60 * 24 else "now"
|
||||
next_window = "tomorrow" if self.window == 60 * 60 * 24 else "in a bit"
|
||||
common_message_prefix = f"I'm glad you're enjoying interacting with me! You've unfortunately exceeded your usage limit for {current_window}."
|
||||
|
||||
# Remove requests outside of the time window
|
||||
@@ -2219,7 +2216,7 @@ def should_notify(original_query: str, executed_query: str, ai_response: str, us
|
||||
should_notify_result = response["decision"] == "Yes"
|
||||
reason = response.get("reason", "unknown")
|
||||
logger.info(
|
||||
f'Decided to {"not " if not should_notify_result else ""}notify user of automation response because of reason: {reason}.'
|
||||
f"Decided to {'not ' if not should_notify_result else ''}notify user of automation response because of reason: {reason}."
|
||||
)
|
||||
return should_notify_result
|
||||
except Exception as e:
|
||||
@@ -2313,7 +2310,7 @@ def scheduled_chat(
|
||||
response_map = raw_response.json()
|
||||
ai_response = response_map.get("response") or response_map.get("image")
|
||||
is_image = False
|
||||
if type(ai_response) == dict:
|
||||
if isinstance(ai_response, dict):
|
||||
is_image = ai_response.get("image") is not None
|
||||
else:
|
||||
ai_response = raw_response.text
|
||||
@@ -2460,12 +2457,12 @@ async def aschedule_automation(
|
||||
|
||||
def construct_automation_created_message(automation: Job, crontime: str, query_to_run: str, subject: str):
|
||||
# Display next run time in user timezone instead of UTC
|
||||
schedule = f'{cron_descriptor.get_description(crontime)} {automation.next_run_time.strftime("%Z")}'
|
||||
schedule = f"{cron_descriptor.get_description(crontime)} {automation.next_run_time.strftime('%Z')}"
|
||||
next_run_time = automation.next_run_time.strftime("%Y-%m-%d %I:%M %p %Z")
|
||||
# Remove /automated_task prefix from inferred_query
|
||||
unprefixed_query_to_run = re.sub(r"^\/automated_task\s*", "", query_to_run)
|
||||
# Create the automation response
|
||||
automation_icon_url = f"/static/assets/icons/automation.svg"
|
||||
automation_icon_url = "/static/assets/icons/automation.svg"
|
||||
return f"""
|
||||
###  Created Automation
|
||||
- Subject: **{subject}**
|
||||
@@ -2713,13 +2710,13 @@ def configure_content(
|
||||
t: Optional[state.SearchType] = state.SearchType.All,
|
||||
) -> bool:
|
||||
success = True
|
||||
if t == None:
|
||||
if t is None:
|
||||
t = state.SearchType.All
|
||||
|
||||
if t is not None and t in [type.value for type in state.SearchType]:
|
||||
t = state.SearchType(t)
|
||||
|
||||
if t is not None and not t.value in [type.value for type in state.SearchType]:
|
||||
if t is not None and t.value not in [type.value for type in state.SearchType]:
|
||||
logger.warning(f"🚨 Invalid search type: {t}")
|
||||
return False
|
||||
|
||||
@@ -2988,7 +2985,7 @@ async def grep_files(
|
||||
query += f" {' and '.join(context_info)}"
|
||||
if line_count > max_results:
|
||||
if lines_before or lines_after:
|
||||
query += f" for"
|
||||
query += " for"
|
||||
query += f" first {max_results} results"
|
||||
return query
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from khoj.processor.conversation.utils import (
|
||||
ResearchIteration,
|
||||
ToolCall,
|
||||
construct_iteration_history,
|
||||
construct_structured_message,
|
||||
construct_tool_chat_history,
|
||||
load_complex_json,
|
||||
)
|
||||
@@ -24,7 +23,6 @@ from khoj.processor.tools.online_search import read_webpages_content, search_onl
|
||||
from khoj.processor.tools.run_code import run_code
|
||||
from khoj.routers.helpers import (
|
||||
ChatEvent,
|
||||
generate_summary_from_files,
|
||||
get_message_from_queue,
|
||||
grep_files,
|
||||
list_files,
|
||||
@@ -184,7 +182,7 @@ async def apick_next_tool(
|
||||
# TODO: Handle multiple tool calls.
|
||||
response_text = response.text
|
||||
parsed_response = [ToolCall(**item) for item in load_complex_json(response_text)][0]
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Otherwise assume the model has decided to end the research run and respond to the user.
|
||||
parsed_response = ToolCall(name=ConversationCommand.Text, args={"response": response_text}, id=None)
|
||||
|
||||
@@ -199,7 +197,7 @@ async def apick_next_tool(
|
||||
if i.warning is None and isinstance(i.query, ToolCall)
|
||||
}
|
||||
if (parsed_response.name, dict_to_tuple(parsed_response.args)) in previous_tool_query_combinations:
|
||||
warning = f"Repeated tool, query combination detected. Skipping iteration. Try something different."
|
||||
warning = "Repeated tool, query combination detected. Skipping iteration. Try something different."
|
||||
# Only send client status updates if we'll execute this iteration and model has thoughts to share.
|
||||
elif send_status_func and not is_none_or_empty(response.thought):
|
||||
async for event in send_status_func(response.thought):
|
||||
|
||||
@@ -4,12 +4,10 @@ from typing import List
|
||||
|
||||
class BaseFilter(ABC):
|
||||
@abstractmethod
|
||||
def get_filter_terms(self, query: str) -> List[str]:
|
||||
...
|
||||
def get_filter_terms(self, query: str) -> List[str]: ...
|
||||
|
||||
def can_filter(self, raw_query: str) -> bool:
|
||||
return len(self.get_filter_terms(raw_query)) > 0
|
||||
|
||||
@abstractmethod
|
||||
def defilter(self, query: str) -> str:
|
||||
...
|
||||
def defilter(self, query: str) -> str: ...
|
||||
|
||||
@@ -9,9 +9,8 @@ from asgiref.sync import sync_to_async
|
||||
from sentence_transformers import util
|
||||
|
||||
from khoj.database.adapters import EntryAdapters, get_default_search_model
|
||||
from khoj.database.models import Agent
|
||||
from khoj.database.models import Agent, KhojUser
|
||||
from khoj.database.models import Entry as DbEntry
|
||||
from khoj.database.models import KhojUser
|
||||
from khoj.processor.content.text_to_entries import TextToEntries
|
||||
from khoj.utils import state
|
||||
from khoj.utils.helpers import get_absolute_path, timer
|
||||
|
||||
@@ -77,7 +77,7 @@ class AsyncIteratorWrapper:
|
||||
|
||||
|
||||
def is_none_or_empty(item):
|
||||
return item == None or (hasattr(item, "__iter__") and len(item) == 0) or item == ""
|
||||
return item is None or (hasattr(item, "__iter__") and len(item) == 0) or item == ""
|
||||
|
||||
|
||||
def to_snake_case_from_dash(item: str):
|
||||
@@ -97,7 +97,7 @@ def get_from_dict(dictionary, *args):
|
||||
Returns: dictionary[args[0]][args[1]]... or None if any keys missing"""
|
||||
current = dictionary
|
||||
for arg in args:
|
||||
if not hasattr(current, "__iter__") or not arg in current:
|
||||
if not hasattr(current, "__iter__") or arg not in current:
|
||||
return None
|
||||
current = current[arg]
|
||||
return current
|
||||
@@ -751,7 +751,7 @@ def is_valid_url(url: str) -> bool:
|
||||
try:
|
||||
result = urlparse(url.strip())
|
||||
return all([result.scheme, result.netloc])
|
||||
except:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -759,7 +759,7 @@ def is_internet_connected():
|
||||
try:
|
||||
response = requests.head("https://www.google.com")
|
||||
return response.status_code == 200
|
||||
except:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -60,9 +60,7 @@ def initialization(interactive: bool = True):
|
||||
]
|
||||
default_chat_models = known_available_models + other_available_models
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"⚠️ Failed to fetch {provider} chat models. Fallback to default models. Error: {str(e)}"
|
||||
)
|
||||
logger.warning(f"⚠️ Failed to fetch {provider} chat models. Fallback to default models. Error: {str(e)}")
|
||||
|
||||
# Set up OpenAI's online chat models
|
||||
openai_configured, openai_provider = _setup_chat_model_provider(
|
||||
|
||||
@@ -8,12 +8,10 @@ from tqdm import trange
|
||||
|
||||
class BaseEncoder(ABC):
|
||||
@abstractmethod
|
||||
def __init__(self, model_name: str, device: torch.device = None, **kwargs):
|
||||
...
|
||||
def __init__(self, model_name: str, device: torch.device = None, **kwargs): ...
|
||||
|
||||
@abstractmethod
|
||||
def encode(self, entries: List[str], device: torch.device = None, **kwargs) -> torch.Tensor:
|
||||
...
|
||||
def encode(self, entries: List[str], device: torch.device = None, **kwargs) -> torch.Tensor: ...
|
||||
|
||||
|
||||
class OpenAI(BaseEncoder):
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# System Packages
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Dict, List
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from openai import OpenAI
|
||||
|
||||
@@ -30,7 +30,7 @@ def v1_telemetry(telemetry_data: List[Dict[str, str]]):
|
||||
try:
|
||||
for row in telemetry_data:
|
||||
posthog.capture(row["server_id"], "api_request", row)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Could not POST equest to new khoj telemetry server. Contact developer to get this fixed.",
|
||||
|
||||
@@ -326,7 +326,7 @@ File statistics:
|
||||
- Code examples: Yes
|
||||
- Purpose: Stress testing atomic agent updates
|
||||
|
||||
{'Additional padding content. ' * 20}
|
||||
{"Additional padding content. " * 20}
|
||||
|
||||
End of file {i}.
|
||||
"""
|
||||
|
||||
@@ -462,7 +462,7 @@ def evaluate_response_with_gemini(
|
||||
Ground Truth: {ground_truth}
|
||||
|
||||
Provide your evaluation in the following json format:
|
||||
{"explanation:" "[How you made the decision?)", "decision:" "(TRUE if response contains key information, FALSE otherwise)"}
|
||||
{"explanation:[How you made the decision?)", "decision:(TRUE if response contains key information, FALSE otherwise)"}
|
||||
"""
|
||||
gemini_api_url = (
|
||||
f"https://generativelanguage.googleapis.com/v1beta/models/{eval_model}:generateContent?key={GEMINI_API_KEY}"
|
||||
@@ -557,7 +557,7 @@ def process_batch(batch, batch_start, results, dataset_length, response_evaluato
|
||||
---------
|
||||
Decision: {colored_decision}
|
||||
Accuracy: {running_accuracy:.2%}
|
||||
Progress: {running_total_count.get()/dataset_length:.2%}
|
||||
Progress: {running_total_count.get() / dataset_length:.2%}
|
||||
Index: {current_index}
|
||||
Question: {prompt}
|
||||
Expected Answer: {answer}
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_create_default_agent(default_user: KhojUser):
|
||||
assert agent.input_tools == []
|
||||
assert agent.output_modes == []
|
||||
assert agent.privacy_level == Agent.PrivacyLevel.PUBLIC
|
||||
assert agent.managed_by_admin == True
|
||||
assert agent.managed_by_admin
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -178,7 +178,7 @@ async def test_multiple_agents_with_knowledge_base_and_users(
|
||||
default_user2: KhojUser, default_openai_chat_model_option: ChatModel, chat_client, default_user3: KhojUser
|
||||
):
|
||||
full_filename = get_absolute_path("tests/data/markdown/having_kids.markdown")
|
||||
new_agent = await AgentAdapters.aupdate_agent(
|
||||
await AgentAdapters.aupdate_agent(
|
||||
default_user2,
|
||||
"Test Agent",
|
||||
"Test Personality",
|
||||
@@ -290,17 +290,17 @@ async def test_large_knowledge_base_atomic_update(
|
||||
assert len(final_entries) > initial_entries_count, "Should have more entries after update"
|
||||
|
||||
# With 180 files, we should have many entries (each file creates multiple entries)
|
||||
assert (
|
||||
len(final_entries) >= expected_file_count
|
||||
), f"Expected at least {expected_file_count} entries, got {len(final_entries)}"
|
||||
assert len(final_entries) >= expected_file_count, (
|
||||
f"Expected at least {expected_file_count} entries, got {len(final_entries)}"
|
||||
)
|
||||
|
||||
# Verify no partial state - all entries should correspond to the final file set
|
||||
entry_file_paths = {entry.file_path for entry in final_entries}
|
||||
|
||||
# All file objects should have corresponding entries
|
||||
assert file_paths_in_db.issubset(
|
||||
entry_file_paths
|
||||
), "All file objects should have corresponding entries - atomic update verification"
|
||||
assert file_paths_in_db.issubset(entry_file_paths), (
|
||||
"All file objects should have corresponding entries - atomic update verification"
|
||||
)
|
||||
|
||||
# Additional stress test: verify referential integrity
|
||||
# Count entries per file to ensure no partial file processing
|
||||
@@ -333,7 +333,7 @@ async def test_concurrent_agent_updates_atomicity(
|
||||
test_files = available_files # Use all available files for the stress test
|
||||
|
||||
# Create initial agent
|
||||
agent = await AgentAdapters.aupdate_agent(
|
||||
await AgentAdapters.aupdate_agent(
|
||||
default_user2,
|
||||
"Concurrent Test Agent",
|
||||
"Test concurrent updates",
|
||||
@@ -391,14 +391,14 @@ async def test_concurrent_agent_updates_atomicity(
|
||||
file_object_paths = {fo.file_name for fo in final_file_objects}
|
||||
|
||||
# All entries should have corresponding file objects
|
||||
assert entry_file_paths.issubset(
|
||||
file_object_paths
|
||||
), "All entries should have corresponding file objects - indicates atomic update worked"
|
||||
assert entry_file_paths.issubset(file_object_paths), (
|
||||
"All entries should have corresponding file objects - indicates atomic update worked"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# If we get database integrity errors, that's actually expected behavior
|
||||
# with proper atomic transactions - they should fail cleanly rather than
|
||||
# allowing partial updates
|
||||
assert (
|
||||
"database" in str(e).lower() or "integrity" in str(e).lower()
|
||||
), f"Expected database/integrity error with concurrent updates, got: {e}"
|
||||
assert "database" in str(e).lower() or "integrity" in str(e).lower(), (
|
||||
f"Expected database/integrity error with concurrent updates, got: {e}"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ from urllib.parse import quote
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from khoj.configure import configure_routes, configure_search_types
|
||||
from khoj.database.adapters import EntryAdapters
|
||||
@@ -101,7 +100,7 @@ def test_update_with_invalid_content_type(client):
|
||||
headers = {"Authorization": "Bearer kk-secret"}
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/update?t=invalid_content_type", headers=headers)
|
||||
response = client.get("/api/update?t=invalid_content_type", headers=headers)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 422
|
||||
@@ -114,7 +113,7 @@ def test_regenerate_with_invalid_content_type(client):
|
||||
headers = {"Authorization": "Bearer kk-secret"}
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/update?force=true&t=invalid_content_type", headers=headers)
|
||||
response = client.get("/api/update?force=true&t=invalid_content_type", headers=headers)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 422
|
||||
@@ -238,13 +237,13 @@ def test_regenerate_with_valid_content_type(client):
|
||||
def test_regenerate_with_github_fails_without_pat(client):
|
||||
# Act
|
||||
headers = {"Authorization": "Bearer kk-secret"}
|
||||
response = client.get(f"/api/update?force=true&t=github", headers=headers)
|
||||
response = client.get("/api/update?force=true&t=github", headers=headers)
|
||||
|
||||
# Arrange
|
||||
files = get_sample_files_data()
|
||||
|
||||
# Act
|
||||
response = client.patch(f"/api/content?t=github", files=files, headers=headers)
|
||||
response = client.patch("/api/content?t=github", files=files, headers=headers)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200, f"Returned status: {response.status_code} for content type: github"
|
||||
@@ -270,7 +269,7 @@ def test_get_api_config_types(client, sample_org_data, default_user: KhojUser):
|
||||
text_search.setup(OrgToEntries, sample_org_data, regenerate=False, user=default_user)
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/content/types", headers=headers)
|
||||
response = client.get("/api/content/types", headers=headers)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
@@ -286,7 +285,7 @@ def test_get_configured_types_with_no_content_config(fastapi_app: FastAPI):
|
||||
client = TestClient(fastapi_app)
|
||||
|
||||
# Act
|
||||
response = client.get(f"/api/content/types")
|
||||
response = client.get("/api/content/types")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
@@ -454,8 +453,8 @@ def test_chat_with_unauthenticated_user(chat_client_with_auth, api_user2: KhojAp
|
||||
headers = {"Authorization": f"Bearer {api_user2.token}"}
|
||||
|
||||
# Act
|
||||
auth_response = chat_client_with_auth.post(f"/api/chat", json={"q": query}, headers=headers)
|
||||
no_auth_response = chat_client_with_auth.post(f"/api/chat", json={"q": query})
|
||||
auth_response = chat_client_with_auth.post("/api/chat", json={"q": query}, headers=headers)
|
||||
no_auth_response = chat_client_with_auth.post("/api/chat", json={"q": query})
|
||||
|
||||
# Assert
|
||||
assert auth_response.status_code == 200
|
||||
|
||||
@@ -77,12 +77,12 @@ class TestTruncateMessage:
|
||||
|
||||
# Assert
|
||||
# The original object has been modified. Verify certain properties
|
||||
assert (
|
||||
len(chat_history) == 1
|
||||
), "Only most recent message should be present as it itself is larger than context size"
|
||||
assert len(truncated_chat_history[0].content) < len(
|
||||
copy_big_chat_message.content
|
||||
), "message content list should be modified"
|
||||
assert len(chat_history) == 1, (
|
||||
"Only most recent message should be present as it itself is larger than context size"
|
||||
)
|
||||
assert len(truncated_chat_history[0].content) < len(copy_big_chat_message.content), (
|
||||
"message content list should be modified"
|
||||
)
|
||||
assert truncated_chat_history[0].content[-1]["text"] == "Question?", "Query should be preserved"
|
||||
assert initial_tokens > self.max_prompt_size, "Initial tokens should be greater than max prompt size"
|
||||
assert final_tokens <= self.max_prompt_size, "Truncated message should be within max prompt size"
|
||||
@@ -101,9 +101,9 @@ class TestTruncateMessage:
|
||||
|
||||
# Assert
|
||||
# The original object has been modified. Verify certain properties
|
||||
assert (
|
||||
len(chat_history) == 1
|
||||
), "Only most recent message should be present as it itself is larger than context size"
|
||||
assert len(chat_history) == 1, (
|
||||
"Only most recent message should be present as it itself is larger than context size"
|
||||
)
|
||||
assert truncated_chat_history[0] != copy_big_chat_message, "Original message should be modified"
|
||||
assert truncated_chat_history[0].content[0]["text"].endswith("\nQuestion?"), "Query should be preserved"
|
||||
assert initial_tokens > self.max_prompt_size, "Initial tokens should be greater than max prompt size"
|
||||
@@ -150,9 +150,9 @@ class TestTruncateMessage:
|
||||
# The original object has been modified. Verify certain properties
|
||||
assert initial_tokens > self.max_prompt_size, "Initial tokens should be greater than max prompt size"
|
||||
assert final_tokens <= self.max_prompt_size, "Final tokens should be within max prompt size"
|
||||
assert (
|
||||
len(chat_messages) == 1
|
||||
), "Only most recent message should be present as it itself is larger than context size"
|
||||
assert len(chat_messages) == 1, (
|
||||
"Only most recent message should be present as it itself is larger than context size"
|
||||
)
|
||||
assert truncated_chat_history[0] != copy_big_chat_message, "Original message should be modified"
|
||||
assert truncated_chat_history[0].content[0]["text"].endswith("\nQuestion?"), "Query should be preserved"
|
||||
|
||||
@@ -172,9 +172,9 @@ class TestTruncateMessage:
|
||||
# The original object has been modified. Verify certain properties
|
||||
assert initial_tokens > self.max_prompt_size, "Initial tokens should be greater than max prompt size"
|
||||
assert final_tokens <= self.max_prompt_size, "Final tokens should be within max prompt size"
|
||||
assert (
|
||||
len(chat_messages) == 1
|
||||
), "Only most recent message should be present as it itself is larger than context size"
|
||||
assert len(chat_messages) == 1, (
|
||||
"Only most recent message should be present as it itself is larger than context size"
|
||||
)
|
||||
assert truncated_chat_history[0] != copy_big_chat_message, "Original message should be modified"
|
||||
|
||||
|
||||
|
||||
@@ -162,15 +162,15 @@ def test_date_extraction():
|
||||
assert extracted_dates == [datetime(1984, 4, 1, 0, 0, 0)], "Expected d.m.Y structured date to be extracted"
|
||||
|
||||
extracted_dates = DateFilter().extract_dates("CLOCK: [1984-04-01 Sun 09:50]--[1984-04-01 Sun 10:10] => 24:20")
|
||||
assert extracted_dates == [
|
||||
datetime(1984, 4, 1, 0, 0, 0)
|
||||
], "Expected single deduplicated date extracted from logbook entry"
|
||||
assert extracted_dates == [datetime(1984, 4, 1, 0, 0, 0)], (
|
||||
"Expected single deduplicated date extracted from logbook entry"
|
||||
)
|
||||
|
||||
extracted_dates = DateFilter().extract_dates("CLOCK: [1984/03/31 mer 09:50]--[1984/04/01 mer 10:10] => 24:20")
|
||||
expected_dates = [datetime(1984, 4, 1, 0, 0, 0), datetime(1984, 3, 31, 0, 0, 0)]
|
||||
assert all(
|
||||
[dt in extracted_dates for dt in expected_dates]
|
||||
), "Expected multiple different dates extracted from logbook entry"
|
||||
assert all([dt in extracted_dates for dt in expected_dates]), (
|
||||
"Expected multiple different dates extracted from logbook entry"
|
||||
)
|
||||
|
||||
|
||||
def test_natual_date_extraction():
|
||||
@@ -187,9 +187,9 @@ def test_natual_date_extraction():
|
||||
assert datetime(1984, 4, 4, 0, 0, 0) in extracted_dates, "Expected natural date to be extracted"
|
||||
|
||||
extracted_dates = DateFilter().extract_dates("head 11th april 1984 tail")
|
||||
assert (
|
||||
datetime(1984, 4, 11, 0, 0, 0) in extracted_dates
|
||||
), "Expected natural date with lowercase month to be extracted"
|
||||
assert datetime(1984, 4, 11, 0, 0, 0) in extracted_dates, (
|
||||
"Expected natural date with lowercase month to be extracted"
|
||||
)
|
||||
|
||||
extracted_dates = DateFilter().extract_dates("head 23rd april 84 tail")
|
||||
assert datetime(1984, 4, 23, 0, 0, 0) in extracted_dates, "Expected natural date with 2-digit year to be extracted"
|
||||
@@ -201,16 +201,16 @@ def test_natual_date_extraction():
|
||||
assert extracted_dates == [datetime(1984, 4, 1, 0, 0, 0)], "Expected partial natural date to be extracted"
|
||||
|
||||
extracted_dates = DateFilter().extract_dates("head Apr 1984 tail")
|
||||
assert extracted_dates == [
|
||||
datetime(1984, 4, 1, 0, 0, 0)
|
||||
], "Expected partial natural date with short month to be extracted"
|
||||
assert extracted_dates == [datetime(1984, 4, 1, 0, 0, 0)], (
|
||||
"Expected partial natural date with short month to be extracted"
|
||||
)
|
||||
|
||||
extracted_dates = DateFilter().extract_dates("head apr 1984 tail")
|
||||
assert extracted_dates == [
|
||||
datetime(1984, 4, 1, 0, 0, 0)
|
||||
], "Expected partial natural date with lowercase month to be extracted"
|
||||
assert extracted_dates == [datetime(1984, 4, 1, 0, 0, 0)], (
|
||||
"Expected partial natural date with lowercase month to be extracted"
|
||||
)
|
||||
|
||||
extracted_dates = DateFilter().extract_dates("head apr 84 tail")
|
||||
assert extracted_dates == [
|
||||
datetime(1984, 4, 1, 0, 0, 0)
|
||||
], "Expected partial natural date with 2-digit year to be extracted"
|
||||
assert extracted_dates == [datetime(1984, 4, 1, 0, 0, 0)], (
|
||||
"Expected partial natural date with 2-digit year to be extracted"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import os
|
||||
|
||||
from khoj.processor.content.images.image_to_entries import ImageToEntries
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from khoj.processor.content.markdown.markdown_to_entries import MarkdownToEntrie
|
||||
def test_extract_markdown_with_no_headings(tmp_path):
|
||||
"Convert markdown file with no heading to entry format."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
- Bullet point 1
|
||||
- Bullet point 2
|
||||
"""
|
||||
@@ -35,7 +35,7 @@ def test_extract_markdown_with_no_headings(tmp_path):
|
||||
def test_extract_single_markdown_entry(tmp_path):
|
||||
"Convert markdown from single file to entry format."
|
||||
# Arrange
|
||||
entry = f"""### Heading
|
||||
entry = """### Heading
|
||||
\t\r
|
||||
Body Line 1
|
||||
"""
|
||||
@@ -55,7 +55,7 @@ def test_extract_single_markdown_entry(tmp_path):
|
||||
def test_extract_multiple_markdown_entries(tmp_path):
|
||||
"Convert multiple markdown from single file to entry format."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
### Heading 1
|
||||
\t\r
|
||||
Heading 1 Body Line 1
|
||||
@@ -81,7 +81,7 @@ def test_extract_multiple_markdown_entries(tmp_path):
|
||||
def test_extract_entries_with_different_level_headings(tmp_path):
|
||||
"Extract markdown entries with different level headings."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
# Heading 1
|
||||
## Sub-Heading 1.1
|
||||
# Heading 2
|
||||
@@ -104,7 +104,7 @@ def test_extract_entries_with_different_level_headings(tmp_path):
|
||||
def test_extract_entries_with_non_incremental_heading_levels(tmp_path):
|
||||
"Extract markdown entries when deeper child level before shallower child level."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
# Heading 1
|
||||
#### Sub-Heading 1.1
|
||||
## Sub-Heading 1.2
|
||||
@@ -129,7 +129,7 @@ def test_extract_entries_with_non_incremental_heading_levels(tmp_path):
|
||||
def test_extract_entries_with_text_before_headings(tmp_path):
|
||||
"Extract markdown entries with some text before any headings."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
Text before headings
|
||||
# Heading 1
|
||||
body line 1
|
||||
@@ -149,15 +149,15 @@ body line 2
|
||||
assert len(entries[1]) == 3
|
||||
assert entries[1][0].raw == "\nText before headings"
|
||||
assert entries[1][1].raw == "# Heading 1\nbody line 1"
|
||||
assert (
|
||||
entries[1][2].raw == "# Heading 1\n## Heading 2\nbody line 2\n"
|
||||
), "Ensure raw entry includes heading ancestory"
|
||||
assert entries[1][2].raw == "# Heading 1\n## Heading 2\nbody line 2\n", (
|
||||
"Ensure raw entry includes heading ancestory"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_markdown_file_into_single_entry_if_small(tmp_path):
|
||||
"Parse markdown file into single entry if it fits within the token limits."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
# Heading 1
|
||||
body line 1
|
||||
## Subheading 1.1
|
||||
@@ -180,7 +180,7 @@ body line 1.1
|
||||
def test_parse_markdown_entry_with_children_as_single_entry_if_small(tmp_path):
|
||||
"Parse markdown entry with child headings as single entry if it fits within the tokens limits."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
# Heading 1
|
||||
body line 1
|
||||
## Subheading 1.1
|
||||
@@ -201,13 +201,13 @@ longer body line 2.1
|
||||
# Assert
|
||||
assert len(entries) == 2
|
||||
assert len(entries[1]) == 3
|
||||
assert (
|
||||
entries[1][0].raw == "# Heading 1\nbody line 1\n## Subheading 1.1\nbody line 1.1"
|
||||
), "First entry includes children headings"
|
||||
assert entries[1][0].raw == "# Heading 1\nbody line 1\n## Subheading 1.1\nbody line 1.1", (
|
||||
"First entry includes children headings"
|
||||
)
|
||||
assert entries[1][1].raw == "# Heading 2\nbody line 2", "Second entry does not include children headings"
|
||||
assert (
|
||||
entries[1][2].raw == "# Heading 2\n## Subheading 2.1\nlonger body line 2.1\n"
|
||||
), "Third entry is second entries child heading"
|
||||
assert entries[1][2].raw == "# Heading 2\n## Subheading 2.1\nlonger body line 2.1\n", (
|
||||
"Third entry is second entries child heading"
|
||||
)
|
||||
|
||||
|
||||
def test_line_number_tracking_in_recursive_split():
|
||||
@@ -252,14 +252,16 @@ def test_line_number_tracking_in_recursive_split():
|
||||
|
||||
assert entry.uri is not None, f"Entry '{entry}' has a None URI."
|
||||
assert match is not None, f"URI format is incorrect: {entry.uri}"
|
||||
assert (
|
||||
filepath_from_uri == markdown_file_path
|
||||
), f"File path in URI '{filepath_from_uri}' does not match expected '{markdown_file_path}'"
|
||||
assert filepath_from_uri == markdown_file_path, (
|
||||
f"File path in URI '{filepath_from_uri}' does not match expected '{markdown_file_path}'"
|
||||
)
|
||||
|
||||
# Ensure the first non-heading line in the compiled entry matches the line in the file
|
||||
assert (
|
||||
cleaned_first_entry_line in line_in_file.strip() or cleaned_first_entry_line in next_line_in_file.strip()
|
||||
), f"First non-heading line '{cleaned_first_entry_line}' in {entry.raw} does not match line {line_number_from_uri} in file: '{line_in_file}' or next line '{next_line_in_file}'"
|
||||
), (
|
||||
f"First non-heading line '{cleaned_first_entry_line}' in {entry.raw} does not match line {line_number_from_uri} in file: '{line_in_file}' or next line '{next_line_in_file}'"
|
||||
)
|
||||
|
||||
|
||||
# Helper Functions
|
||||
|
||||
@@ -343,12 +343,12 @@ Expenses:Food:Dining 10.00 USD""",
|
||||
"file": "Ledger.org",
|
||||
},
|
||||
{
|
||||
"compiled": f"""2020-04-01 "SuperMercado" "Bananas"
|
||||
"compiled": """2020-04-01 "SuperMercado" "Bananas"
|
||||
Expenses:Food:Groceries 10.00 USD""",
|
||||
"file": "Ledger.org",
|
||||
},
|
||||
{
|
||||
"compiled": f"""2020-01-01 "Naco Taco" "Burittos for Dinner"
|
||||
"compiled": """2020-01-01 "Naco Taco" "Burittos for Dinner"
|
||||
Expenses:Food:Dining 10.00 USD""",
|
||||
"file": "Ledger.org",
|
||||
},
|
||||
@@ -389,12 +389,12 @@ Expenses:Food:Dining 10.00 USD""",
|
||||
"file": "Ledger.md",
|
||||
},
|
||||
{
|
||||
"compiled": f"""2020-04-01 "SuperMercado" "Bananas"
|
||||
"compiled": """2020-04-01 "SuperMercado" "Bananas"
|
||||
Expenses:Food:Groceries 10.00 USD""",
|
||||
"file": "Ledger.md",
|
||||
},
|
||||
{
|
||||
"compiled": f"""2020-01-01 "Naco Taco" "Burittos for Dinner"
|
||||
"compiled": """2020-01-01 "Naco Taco" "Burittos for Dinner"
|
||||
Expenses:Food:Dining 10.00 USD""",
|
||||
"file": "Ledger.md",
|
||||
},
|
||||
@@ -452,17 +452,17 @@ async def test_ask_for_clarification_if_not_enough_context_in_question():
|
||||
# Arrange
|
||||
context = [
|
||||
{
|
||||
"compiled": f"""# Ramya
|
||||
"compiled": """# Ramya
|
||||
My sister, Ramya, is married to Kali Devi. They have 2 kids, Ravi and Rani.""",
|
||||
"file": "Family.md",
|
||||
},
|
||||
{
|
||||
"compiled": f"""# Fang
|
||||
"compiled": """# Fang
|
||||
My sister, Fang Liu is married to Xi Li. They have 1 kid, Xiao Li.""",
|
||||
"file": "Family.md",
|
||||
},
|
||||
{
|
||||
"compiled": f"""# Aiyla
|
||||
"compiled": """# Aiyla
|
||||
My sister, Aiyla is married to Tolga. They have 3 kids, Yildiz, Ali and Ahmet.""",
|
||||
"file": "Family.md",
|
||||
},
|
||||
@@ -497,9 +497,9 @@ async def test_agent_prompt_should_be_used(openai_agent):
|
||||
"Chat actor should ask be tuned to think like an accountant based on the agent definition"
|
||||
# Arrange
|
||||
context = [
|
||||
{"compiled": f"""I went to the store and bought some bananas for 2.20""", "file": "Ledger.md"},
|
||||
{"compiled": f"""I went to the store and bought some apples for 1.30""", "file": "Ledger.md"},
|
||||
{"compiled": f"""I went to the store and bought some oranges for 6.00""", "file": "Ledger.md"},
|
||||
{"compiled": """I went to the store and bought some bananas for 2.20""", "file": "Ledger.md"},
|
||||
{"compiled": """I went to the store and bought some apples for 1.30""", "file": "Ledger.md"},
|
||||
{"compiled": """I went to the store and bought some oranges for 6.00""", "file": "Ledger.md"},
|
||||
]
|
||||
expected_responses = ["9.50", "9.5"]
|
||||
|
||||
@@ -539,13 +539,13 @@ async def test_websearch_with_operators(chat_client, default_user2):
|
||||
responses = await generate_online_subqueries(user_query, [], None, default_user2)
|
||||
|
||||
# Assert
|
||||
assert any(
|
||||
["reddit.com/r/worldnews" in response for response in responses]
|
||||
), "Expected a search query to include site:reddit.com but got: " + str(responses)
|
||||
assert any(["reddit.com/r/worldnews" in response for response in responses]), (
|
||||
"Expected a search query to include site:reddit.com but got: " + str(responses)
|
||||
)
|
||||
|
||||
assert any(
|
||||
["site:reddit.com" in response for response in responses]
|
||||
), "Expected a search query to include site:reddit.com but got: " + str(responses)
|
||||
assert any(["site:reddit.com" in response for response in responses]), (
|
||||
"Expected a search query to include site:reddit.com but got: " + str(responses)
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------------
|
||||
@@ -559,9 +559,9 @@ async def test_websearch_khoj_website_for_info_about_khoj(chat_client, default_u
|
||||
responses = await generate_online_subqueries(user_query, [], None, default_user2)
|
||||
|
||||
# Assert
|
||||
assert any(
|
||||
["site:khoj.dev" in response for response in responses]
|
||||
), "Expected search query to include site:khoj.dev but got: " + str(responses)
|
||||
assert any(["site:khoj.dev" in response for response in responses]), (
|
||||
"Expected search query to include site:khoj.dev but got: " + str(responses)
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------------
|
||||
@@ -693,9 +693,9 @@ def test_infer_task_scheduling_request(
|
||||
for expected_q in expected_qs:
|
||||
assert expected_q in inferred_query, f"Expected fragment {expected_q} in query: {inferred_query}"
|
||||
for unexpected_q in unexpected_qs:
|
||||
assert (
|
||||
unexpected_q not in inferred_query
|
||||
), f"Did not expect fragment '{unexpected_q}' in query: '{inferred_query}'"
|
||||
assert unexpected_q not in inferred_query, (
|
||||
f"Did not expect fragment '{unexpected_q}' in query: '{inferred_query}'"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -33,7 +33,7 @@ def create_conversation(message_list, user, agent=None):
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_chat_with_no_chat_history_or_retrieved_content(chat_client):
|
||||
# Act
|
||||
response = chat_client.post(f"/api/chat", json={"q": "Hello, my name is Testatron. Who are you?"})
|
||||
response = chat_client.post("/api/chat", json={"q": "Hello, my name is Testatron. Who are you?"})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -50,7 +50,7 @@ def test_chat_with_no_chat_history_or_retrieved_content(chat_client):
|
||||
def test_chat_with_online_content(chat_client):
|
||||
# Act
|
||||
q = "/online give me the link to paul graham's essay how to do great work"
|
||||
response = chat_client.post(f"/api/chat?", json={"q": q})
|
||||
response = chat_client.post("/api/chat?", json={"q": q})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -59,9 +59,9 @@ def test_chat_with_online_content(chat_client):
|
||||
"paulgraham.com/hwh.html",
|
||||
]
|
||||
assert response.status_code == 200
|
||||
assert any(
|
||||
[expected_response in response_message for expected_response in expected_responses]
|
||||
), f"Expected links: {expected_responses}. Actual response: {response_message}"
|
||||
assert any([expected_response in response_message for expected_response in expected_responses]), (
|
||||
f"Expected links: {expected_responses}. Actual response: {response_message}"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------------
|
||||
@@ -70,15 +70,15 @@ def test_chat_with_online_content(chat_client):
|
||||
def test_chat_with_online_webpage_content(chat_client):
|
||||
# Act
|
||||
q = "/online how many firefighters were involved in the great chicago fire and which year did it take place?"
|
||||
response = chat_client.post(f"/api/chat", json={"q": q})
|
||||
response = chat_client.post("/api/chat", json={"q": q})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
expected_responses = ["185", "1871", "horse"]
|
||||
assert response.status_code == 200
|
||||
assert any(
|
||||
[expected_response in response_message for expected_response in expected_responses]
|
||||
), f"Expected links: {expected_responses}. Actual response: {response_message}"
|
||||
assert any([expected_response in response_message for expected_response in expected_responses]), (
|
||||
f"Expected links: {expected_responses}. Actual response: {response_message}"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------------
|
||||
@@ -93,7 +93,7 @@ def test_answer_from_chat_history(chat_client, default_user2: KhojUser):
|
||||
create_conversation(message_list, default_user2)
|
||||
|
||||
# Act
|
||||
response = chat_client.post(f"/api/chat", json={"q": "What is my name?"})
|
||||
response = chat_client.post("/api/chat", json={"q": "What is my name?"})
|
||||
response_message = response.content.decode("utf-8")
|
||||
|
||||
# Assert
|
||||
@@ -120,7 +120,7 @@ def test_answer_from_currently_retrieved_content(chat_client, default_user2: Kho
|
||||
create_conversation(message_list, default_user2)
|
||||
|
||||
# Act
|
||||
response = chat_client.post(f"/api/chat", json={"q": "Where was Xi Li born?"})
|
||||
response = chat_client.post("/api/chat", json={"q": "Where was Xi Li born?"})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -144,7 +144,7 @@ def test_answer_from_chat_history_and_previously_retrieved_content(chat_client_n
|
||||
create_conversation(message_list, default_user2)
|
||||
|
||||
# Act
|
||||
response = chat_client_no_background.post(f"/api/chat", json={"q": "Where was I born?"})
|
||||
response = chat_client_no_background.post("/api/chat", json={"q": "Where was I born?"})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -167,7 +167,7 @@ def test_answer_from_chat_history_and_currently_retrieved_content(chat_client, d
|
||||
create_conversation(message_list, default_user2)
|
||||
|
||||
# Act
|
||||
response = chat_client.post(f"/api/chat", json={"q": "Where was I born?"})
|
||||
response = chat_client.post("/api/chat", json={"q": "Where was I born?"})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -192,7 +192,7 @@ def test_no_answer_in_chat_history_or_retrieved_content(chat_client, default_use
|
||||
create_conversation(message_list, default_user2)
|
||||
|
||||
# Act
|
||||
response = chat_client.post(f"/api/chat", json={"q": "Where was I born?"})
|
||||
response = chat_client.post("/api/chat", json={"q": "Where was I born?"})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -222,7 +222,7 @@ def test_answer_using_general_command(chat_client, default_user2: KhojUser):
|
||||
create_conversation(message_list, default_user2)
|
||||
|
||||
# Act
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "stream": True})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "stream": True})
|
||||
response_message = response.content.decode("utf-8")
|
||||
|
||||
# Assert
|
||||
@@ -240,7 +240,7 @@ def test_answer_from_retrieved_content_using_notes_command(chat_client, default_
|
||||
create_conversation(message_list, default_user2)
|
||||
|
||||
# Act
|
||||
response = chat_client.post(f"/api/chat", json={"q": query})
|
||||
response = chat_client.post("/api/chat", json={"q": query})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -258,7 +258,7 @@ def test_answer_not_known_using_notes_command(chat_client_no_background, default
|
||||
create_conversation(message_list, default_user2)
|
||||
|
||||
# Act
|
||||
response = chat_client_no_background.post(f"/api/chat", json={"q": query})
|
||||
response = chat_client_no_background.post("/api/chat", json={"q": query})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -291,7 +291,7 @@ def test_summarize_one_file(chat_client, default_user2: KhojUser):
|
||||
json={"filename": summarization_file, "conversation_id": str(conversation.id)},
|
||||
)
|
||||
query = "/summarize"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response_message = response.json()["response"]
|
||||
# Assert
|
||||
assert response_message != ""
|
||||
@@ -322,7 +322,7 @@ def test_summarize_extra_text(chat_client, default_user2: KhojUser):
|
||||
json={"filename": summarization_file, "conversation_id": str(conversation.id)},
|
||||
)
|
||||
query = "/summarize tell me about Xiu"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response_message = response.json()["response"]
|
||||
# Assert
|
||||
assert response_message != ""
|
||||
@@ -349,7 +349,7 @@ def test_summarize_multiple_files(chat_client, default_user2: KhojUser):
|
||||
)
|
||||
|
||||
query = "/summarize"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -365,7 +365,7 @@ def test_summarize_no_files(chat_client, default_user2: KhojUser):
|
||||
|
||||
# Act
|
||||
query = "/summarize"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -400,11 +400,11 @@ def test_summarize_different_conversation(chat_client, default_user2: KhojUser):
|
||||
|
||||
# Act
|
||||
query = "/summarize"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "conversation_id": str(conversation2.id)})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation2.id)})
|
||||
response_message_conv2 = response.json()["response"]
|
||||
|
||||
# now make sure that the file filter is still in conversation 1
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "conversation_id": str(conversation1.id)})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation1.id)})
|
||||
response_message_conv1 = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -430,7 +430,7 @@ def test_summarize_nonexistant_file(chat_client, default_user2: KhojUser):
|
||||
json={"filename": "imaginary.markdown", "conversation_id": str(conversation.id)},
|
||||
)
|
||||
query = urllib.parse.quote("/summarize")
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response_message = response.json()["response"]
|
||||
# Assert
|
||||
assert response_message == "No files selected for summarization. Please add files using the section on the left."
|
||||
@@ -462,7 +462,7 @@ def test_summarize_diff_user_file(chat_client, default_user: KhojUser, pdf_confi
|
||||
|
||||
# Act
|
||||
query = "/summarize"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -477,7 +477,7 @@ def test_summarize_diff_user_file(chat_client, default_user: KhojUser, pdf_confi
|
||||
def test_answer_requires_current_date_awareness(chat_client):
|
||||
"Chat actor should be able to answer questions relative to current date using provided notes"
|
||||
# Act
|
||||
response = chat_client.post(f"/api/chat", json={"q": "Where did I have lunch today?", "stream": True})
|
||||
response = chat_client.post("/api/chat", json={"q": "Where did I have lunch today?", "stream": True})
|
||||
response_message = response.content.decode("utf-8")
|
||||
|
||||
# Assert
|
||||
@@ -496,7 +496,7 @@ def test_answer_requires_date_aware_aggregation_across_provided_notes(chat_clien
|
||||
"Chat director should be able to answer questions that require date aware aggregation across multiple notes"
|
||||
# Act
|
||||
query = "How much did I spend on dining this year?"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query})
|
||||
response = chat_client.post("/api/chat", json={"q": query})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -518,7 +518,7 @@ def test_answer_general_question_not_in_chat_history_or_retrieved_content(chat_c
|
||||
|
||||
# Act
|
||||
query = "Write a haiku about unit testing. Do not say anything else."
|
||||
response = chat_client.post(f"/api/chat", json={"q": query})
|
||||
response = chat_client.post("/api/chat", json={"q": query})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -536,7 +536,7 @@ def test_answer_general_question_not_in_chat_history_or_retrieved_content(chat_c
|
||||
def test_ask_for_clarification_if_not_enough_context_in_question(chat_client_no_background):
|
||||
# Act
|
||||
query = "What is the name of Namitas older son?"
|
||||
response = chat_client_no_background.post(f"/api/chat", json={"q": query})
|
||||
response = chat_client_no_background.post("/api/chat", json={"q": query})
|
||||
response_message = response.json()["response"].lower()
|
||||
|
||||
# Assert
|
||||
@@ -571,7 +571,7 @@ def test_answer_in_chat_history_beyond_lookback_window(chat_client, default_user
|
||||
|
||||
# Act
|
||||
query = "What is my name?"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query})
|
||||
response = chat_client.post("/api/chat", json={"q": query})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert
|
||||
@@ -604,9 +604,7 @@ def test_answer_in_chat_history_by_conversation_id(chat_client, default_user2: K
|
||||
|
||||
# Act
|
||||
query = "/general What is my favorite color?"
|
||||
response = chat_client.post(
|
||||
f"/api/chat", json={"q": query, "conversation_id": str(conversation.id), "stream": True}
|
||||
)
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation.id), "stream": True})
|
||||
response_message = response.content.decode("utf-8")
|
||||
|
||||
# Assert
|
||||
@@ -639,7 +637,7 @@ def test_answer_in_chat_history_by_conversation_id_with_agent(
|
||||
|
||||
# Act
|
||||
query = "/general What did I buy for breakfast?"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response = chat_client.post("/api/chat", json={"q": query, "conversation_id": str(conversation.id)})
|
||||
response_message = response.json()["response"]
|
||||
|
||||
# Assert that agent only responds with the summary of spending
|
||||
@@ -657,7 +655,7 @@ def test_answer_requires_multiple_independent_searches(chat_client):
|
||||
"Chat director should be able to answer by doing multiple independent searches for required information"
|
||||
# Act
|
||||
query = "Is Xi Li older than Namita? Just say the older persons full name"
|
||||
response = chat_client.post(f"/api/chat", json={"q": query})
|
||||
response = chat_client.post("/api/chat", json={"q": query})
|
||||
response_message = response.json()["response"].lower()
|
||||
|
||||
# Assert
|
||||
@@ -681,7 +679,7 @@ def test_answer_using_file_filter(chat_client):
|
||||
query = (
|
||||
'Is Xi Li older than Namita? Just say the older persons full name. file:"Namita.markdown" file:"Xi Li.markdown"'
|
||||
)
|
||||
response = chat_client.post(f"/api/chat", json={"q": query})
|
||||
response = chat_client.post("/api/chat", json={"q": query})
|
||||
response_message = response.json()["response"].lower()
|
||||
|
||||
# Assert
|
||||
|
||||
@@ -12,7 +12,7 @@ def test_configure_indexing_heading_only_entries(tmp_path):
|
||||
"""Ensure entries with empty body are ignored, unless explicitly configured to index heading entries.
|
||||
Property drawers not considered Body. Ignore control characters for evaluating if Body empty."""
|
||||
# Arrange
|
||||
entry = f"""*** Heading
|
||||
entry = """*** Heading
|
||||
:PROPERTIES:
|
||||
:ID: 42-42-42
|
||||
:END:
|
||||
@@ -74,7 +74,7 @@ def test_entry_split_when_exceeds_max_tokens():
|
||||
"Ensure entries with compiled words exceeding max_tokens are split."
|
||||
# Arrange
|
||||
tmp_path = "/tmp/test.org"
|
||||
entry = f"""*** Heading
|
||||
entry = """*** Heading
|
||||
\t\r
|
||||
Body Line
|
||||
"""
|
||||
@@ -99,7 +99,7 @@ def test_entry_split_when_exceeds_max_tokens():
|
||||
def test_entry_split_drops_large_words():
|
||||
"Ensure entries drops words larger than specified max word length from compiled version."
|
||||
# Arrange
|
||||
entry_text = f"""First Line
|
||||
entry_text = """First Line
|
||||
dog=1\n\r\t
|
||||
cat=10
|
||||
car=4
|
||||
@@ -124,7 +124,7 @@ book=2
|
||||
def test_parse_org_file_into_single_entry_if_small(tmp_path):
|
||||
"Parse org file into single entry if it fits within the token limits."
|
||||
# Arrange
|
||||
original_entry = f"""
|
||||
original_entry = """
|
||||
* Heading 1
|
||||
body line 1
|
||||
** Subheading 1.1
|
||||
@@ -133,7 +133,7 @@ body line 1.1
|
||||
data = {
|
||||
f"{tmp_path}": original_entry,
|
||||
}
|
||||
expected_entry = f"""
|
||||
expected_entry = """
|
||||
* Heading 1
|
||||
body line 1
|
||||
|
||||
@@ -155,7 +155,7 @@ body line 1.1
|
||||
def test_parse_org_entry_with_children_as_single_entry_if_small(tmp_path):
|
||||
"Parse org entry with child headings as single entry only if it fits within the tokens limits."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
* Heading 1
|
||||
body line 1
|
||||
** Subheading 1.1
|
||||
@@ -205,7 +205,7 @@ longer body line 2.1
|
||||
def test_separate_sibling_org_entries_if_all_cannot_fit_in_token_limit(tmp_path):
|
||||
"Parse org sibling entries as separate entries only if it fits within the tokens limits."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
* Heading 1
|
||||
body line 1
|
||||
** Subheading 1.1
|
||||
@@ -267,7 +267,7 @@ body line 3.1
|
||||
def test_entry_with_body_to_entry(tmp_path):
|
||||
"Ensure entries with valid body text are loaded."
|
||||
# Arrange
|
||||
entry = f"""*** Heading
|
||||
entry = """*** Heading
|
||||
:PROPERTIES:
|
||||
:ID: 42-42-42
|
||||
:END:
|
||||
@@ -290,7 +290,7 @@ def test_entry_with_body_to_entry(tmp_path):
|
||||
def test_file_with_entry_after_intro_text_to_entry(tmp_path):
|
||||
"Ensure intro text before any headings is indexed."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
Intro text
|
||||
|
||||
* Entry Heading
|
||||
@@ -312,7 +312,7 @@ Intro text
|
||||
def test_file_with_no_headings_to_entry(tmp_path):
|
||||
"Ensure files with no heading, only body text are loaded."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
- Bullet point 1
|
||||
- Bullet point 2
|
||||
"""
|
||||
@@ -332,7 +332,7 @@ def test_file_with_no_headings_to_entry(tmp_path):
|
||||
def test_extract_entries_with_different_level_headings(tmp_path):
|
||||
"Extract org entries with different level headings."
|
||||
# Arrange
|
||||
entry = f"""
|
||||
entry = """
|
||||
* Heading 1
|
||||
** Sub-Heading 1.1
|
||||
* Heading 2
|
||||
@@ -396,14 +396,16 @@ def test_line_number_tracking_in_recursive_split():
|
||||
|
||||
assert entry.uri is not None, f"Entry '{entry}' has a None URI."
|
||||
assert match is not None, f"URI format is incorrect: {entry.uri}"
|
||||
assert (
|
||||
filepath_from_uri == org_file_path
|
||||
), f"File path in URI '{filepath_from_uri}' does not match expected '{org_file_path}'"
|
||||
assert filepath_from_uri == org_file_path, (
|
||||
f"File path in URI '{filepath_from_uri}' does not match expected '{org_file_path}'"
|
||||
)
|
||||
|
||||
# Ensure the first non-heading line in the compiled entry matches the line in the file
|
||||
assert (
|
||||
cleaned_first_entry_line in line_in_file.strip() or cleaned_first_entry_line in next_line_in_file.strip()
|
||||
), f"First non-heading line '{cleaned_first_entry_line}' in {entry.raw} does not match line {line_number_from_uri} in file: '{line_in_file}' or next line '{next_line_in_file}'"
|
||||
), (
|
||||
f"First non-heading line '{cleaned_first_entry_line}' in {entry.raw} does not match line {line_number_from_uri} in file: '{line_in_file}' or next line '{next_line_in_file}'"
|
||||
)
|
||||
|
||||
|
||||
# Helper Functions
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user