mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
25
Commits
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "128,077",
|
||||
"message": "137,874",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 128077,
|
||||
"last_updated": "2026-06-22T08:05:29Z",
|
||||
"total_clones": 137874,
|
||||
"last_updated": "2026-06-30T07:21:14Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -88,6 +88,13 @@
|
||||
"2026-06-18": 1408,
|
||||
"2026-06-19": 1350,
|
||||
"2026-06-20": 1437,
|
||||
"2026-06-21": 1426
|
||||
"2026-06-21": 1426,
|
||||
"2026-06-22": 1350,
|
||||
"2026-06-23": 1468,
|
||||
"2026-06-24": 1635,
|
||||
"2026-06-25": 1640,
|
||||
"2026-06-26": 1338,
|
||||
"2026-06-27": 1338,
|
||||
"2026-06-28": 1028
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ jobs:
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
libxdo-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -130,7 +131,8 @@ jobs:
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
libxdo-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -252,6 +254,10 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_CONFIG: '{"version":"${{ steps.release-info.outputs.tauri_version }}","bundle":{"externalBin":["binaries/ollama"]}}'
|
||||
# tauri-action runs beforeBuildCommand (npm run build:tauri -> vite
|
||||
# build), which requires this at build time (#587). Strict for
|
||||
# releases: a missing/empty secret fails the build by design.
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
with:
|
||||
projectPath: frontend
|
||||
tauriScript: npx tauri
|
||||
|
||||
@@ -41,6 +41,26 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra docs
|
||||
|
||||
# Inject the public Supabase anon key so the savings leaderboard works on
|
||||
# the published docs site. Missing/empty (e.g. fork PRs) leaves the
|
||||
# leaderboard gracefully disabled. The key is read from env (not inlined)
|
||||
# and JSON-encoded into a JS string literal to avoid any injection.
|
||||
- name: Inject leaderboard Supabase anon key
|
||||
env:
|
||||
OPENJARVIS_LEADERBOARD_ANON: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, pathlib
|
||||
|
||||
key = os.environ.get("OPENJARVIS_LEADERBOARD_ANON", "")
|
||||
pathlib.Path("docs/javascripts/leaderboard-config.js").write_text(
|
||||
"// Generated at docs-build time from the VITE_SUPABASE_ANON_KEY secret.\n"
|
||||
"window.OPENJARVIS_SUPABASE_ANON_KEY = " + json.dumps(key) + ";\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print("leaderboard anon key:", "set" if key else "empty (leaderboard disabled)")
|
||||
PY
|
||||
|
||||
- name: Build documentation
|
||||
run: uv run mkdocs build
|
||||
|
||||
|
||||
@@ -35,3 +35,8 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
- run: npm run build
|
||||
env:
|
||||
# Optional: when the secret is unset the build still succeeds and the
|
||||
# leaderboard is disabled (see src/lib/supabase.ts). No placeholder,
|
||||
# so a keyless CI build doesn't bake in a bogus anon key.
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
|
||||
@@ -55,6 +55,8 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend and bundle into package
|
||||
env:
|
||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd frontend
|
||||
|
||||
@@ -1,34 +1,83 @@
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers — reproducible builds and
|
||||
# safe rollbacks (#563).
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). `uv export --frozen`
|
||||
# reads uv.lock as-is (no re-resolution) and emits a fully pinned, hash-verified
|
||||
# requirements set; `--no-deps` then installs exactly that set. This is a
|
||||
# separate layer from the source copy so dependency installs stay cached when
|
||||
# only application code changes.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
# Copy the source and the non-src force-include paths (see pyproject
|
||||
# [tool.hatch.build.targets.wheel.force-include]) before building the project.
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
# Copy built frontend into the server static directory
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM python:3.12-slim-bookworm
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user — the server needs no root privileges, so dropping
|
||||
# them limits the blast radius of a compromise (#565). The app writes only to
|
||||
# $HOME (config/cache/state), which is owned by this user.
|
||||
RUN groupadd --system --gid 10001 openjarvis && \
|
||||
useradd --system --uid 10001 --gid openjarvis \
|
||||
--create-home --home-dir /home/openjarvis openjarvis
|
||||
ENV HOME=/home/openjarvis
|
||||
USER openjarvis
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
|
||||
@@ -1,32 +1,69 @@
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers — reproducible builds and
|
||||
# safe rollbacks (#563).
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 AS builder
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
|
||||
# for the rationale behind the frozen export + --no-deps install.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
@@ -36,6 +73,14 @@ COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user (#565). NVIDIA device nodes (/dev/nvidia*) are
|
||||
# world-accessible, so GPU workloads do not require root.
|
||||
RUN groupadd --system --gid 10001 openjarvis && \
|
||||
useradd --system --uid 10001 --gid openjarvis \
|
||||
--create-home --home-dir /home/openjarvis openjarvis
|
||||
ENV HOME=/home/openjarvis
|
||||
USER openjarvis
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
|
||||
@@ -1,32 +1,69 @@
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers — reproducible builds and
|
||||
# safe rollbacks (#563).
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
# Public Supabase anon key for the savings leaderboard; empty by default so
|
||||
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
|
||||
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --ignore-scripts 2>/dev/null || npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
|
||||
|
||||
# Stage 2: Build Python package (AMD ROCm 7.2)
|
||||
FROM rocm/dev-ubuntu-22.04:7.2 AS builder
|
||||
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
python3 \
|
||||
python3-dev \
|
||||
python3-pip \
|
||||
python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
|
||||
# for the rationale behind the frozen export + --no-deps install.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY src/ src/
|
||||
COPY rust/ rust/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM rocm/dev-ubuntu-22.04:7.2
|
||||
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
@@ -36,6 +73,18 @@ COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user (#565). ROCm GPU access is gated by the `video` and
|
||||
# `render` groups (see group_add in docker-compose.gpu.rocm.yml), so the user is
|
||||
# added to both; root is not required.
|
||||
RUN groupadd --system --gid 10001 openjarvis && \
|
||||
useradd --system --uid 10001 --gid openjarvis \
|
||||
--create-home --home-dir /home/openjarvis openjarvis && \
|
||||
(getent group video >/dev/null || groupadd --system video) && \
|
||||
(getent group render >/dev/null || groupadd --system render) && \
|
||||
usermod -aG video,render openjarvis
|
||||
ENV HOME=/home/openjarvis
|
||||
USER openjarvis
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
|
||||
@@ -1,15 +1,69 @@
|
||||
FROM python:3.12-slim
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers (#563).
|
||||
|
||||
# Install Node.js 22
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
# Node.js is sourced from the official, digest-pinned image rather than piping a
|
||||
# remote setup script into bash (`curl ... | bash -`), which performed no
|
||||
# checksum or signature verification of the downloaded installer (#566). The
|
||||
# image digest is the integrity check, and the copy is architecture-agnostic.
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS node
|
||||
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain none && \
|
||||
rustup toolchain install 1.88 --profile minimal && \
|
||||
rustup default 1.88
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies from the committed lockfile (#567): `uv export --frozen`
|
||||
# reads uv.lock as-is and emits a pinned, hash-verified set installed with
|
||||
# --no-deps (no re-resolution). Copied first so this layer caches independently
|
||||
# of application source.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt && \
|
||||
uv pip install --system --no-deps "maturin>=1.12.6,<2"
|
||||
|
||||
COPY . .
|
||||
RUN pip install --no-cache-dir ".[server]"
|
||||
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps . && \
|
||||
maturin build --release \
|
||||
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
|
||||
--interpreter python3 \
|
||||
--out /tmp/openjarvis-rust-wheel && \
|
||||
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
|
||||
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
|
||||
python3 -m pip uninstall -y maturin && \
|
||||
rm -rf /tmp/openjarvis-rust-wheel rust/target
|
||||
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
|
||||
|
||||
# libstdc++6 + ca-certificates are the only runtime requirements of the Node
|
||||
# binary copied below (the python slim image already provides libc/libgcc).
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# Transplant the Node.js runtime from the official image. Both images are Debian
|
||||
# bookworm, so the glibc/libstdc++ ABI matches.
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
LABEL openjarvis-sandbox=true
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
capabilities: [gpu]
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
# Pinned to a fixed version + digest for reproducible deployments (#563);
|
||||
# must match the tag in docker-compose.yml.
|
||||
image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
# Pinned to a fixed version + digest for reproducible deployments and
|
||||
# predictable rollbacks (#563). Bump deliberately, not implicitly via :latest.
|
||||
image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
|
||||
@@ -14,7 +14,27 @@ Environment=HOME=/opt/openjarvis
|
||||
# OPENJARVIS_API_KEY=<key> (generate one: `jarvis auth generate-key`)
|
||||
# It is not prefixed with "-", so the unit fails to start if the file is
|
||||
# missing — preventing an accidentally unauthenticated public server.
|
||||
# Keep secrets here (mode 0600, owned by root) rather than inline Environment=
|
||||
# lines, which leak into `systemctl show` and the journal.
|
||||
EnvironmentFile=/etc/openjarvis/env
|
||||
|
||||
# --- Sandboxing / hardening (#564) ---
|
||||
# Conservative set: tightens the unit without blocking the server's normal I/O
|
||||
# or local GPU inference. ProtectSystem=strict makes the whole filesystem
|
||||
# read-only except ReadWritePaths, so $HOME (config/cache/state under
|
||||
# /opt/openjarvis) stays writable.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/openjarvis
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Public Supabase config for the savings leaderboard.
|
||||
//
|
||||
// This file is loaded *before* leaderboard.js and supplies the anon key it
|
||||
// reads from `window.OPENJARVIS_SUPABASE_ANON_KEY`. The key is injected at
|
||||
// docs-build time from the VITE_SUPABASE_ANON_KEY repo secret (see
|
||||
// .github/workflows/docs.yml). It is intentionally empty here so that local
|
||||
// `mkdocs build` and fork pull requests — which have no secret — render the
|
||||
// graceful "Leaderboard not configured yet" message instead of failing.
|
||||
//
|
||||
// The anon key is public by design: Supabase Row-Level Security protects the
|
||||
// data, so shipping it in the public docs bundle is expected.
|
||||
window.OPENJARVIS_SUPABASE_ANON_KEY = "";
|
||||
@@ -1,9 +1,9 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var SUPABASE_URL = "https://mtbtgpwzrbostweaanpr.supabase.co";
|
||||
var SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c";
|
||||
var SUPABASE_URL =
|
||||
window.OPENJARVIS_SUPABASE_URL || "https://mtbtgpwzrbostweaanpr.supabase.co";
|
||||
var SUPABASE_ANON_KEY = window.OPENJARVIS_SUPABASE_ANON_KEY || "";
|
||||
|
||||
var PAGE_SIZE = 50;
|
||||
var allRows = [];
|
||||
|
||||
@@ -19,6 +19,71 @@ Agents are the agentic logic layer of OpenJarvis. They determine how a query is
|
||||
|
||||
---
|
||||
|
||||
## Persistent Persona: SOUL.md, MEMORY.md, USER.md
|
||||
|
||||
Every agent's system prompt is assembled at conversation start by the `SystemPromptBuilder`, which injects up to three optional Markdown files -- the **persistent persona**. They are plain text you own and edit, loaded at the start of each conversation. There is no vector database or embedding cache behind them.
|
||||
|
||||
| File | What it holds | Example line |
|
||||
|------|---------------|--------------|
|
||||
| `SOUL.md` | How the agent should behave -- tone, length, what to push back on | `Be concise. Challenge weak assumptions.` |
|
||||
| `MEMORY.md` | Facts about you, your projects, your preferences | `I deploy to Postgres, never MySQL.` |
|
||||
| `USER.md` | Who you are -- role, team, context | `Backend engineer at Acme, on the payments team.` |
|
||||
|
||||
This persona is distinct from the retrieval [memory backend](memory.md): the persona is always-on Markdown context loaded into the prompt, while the memory backend is searchable long-term storage the agent queries on demand.
|
||||
|
||||
### Where they live
|
||||
|
||||
By default the files are read from the config directory:
|
||||
|
||||
```
|
||||
~/.openjarvis/SOUL.md
|
||||
~/.openjarvis/MEMORY.md
|
||||
~/.openjarvis/USER.md
|
||||
```
|
||||
|
||||
(The config directory honors `$OPENJARVIS_HOME` / `$XDG_DATA_HOME` when set.) The paths are configurable under `[memory_files]`:
|
||||
|
||||
```toml
|
||||
[memory_files]
|
||||
soul_path = "~/.openjarvis/SOUL.md"
|
||||
memory_path = "~/.openjarvis/MEMORY.md"
|
||||
user_path = "~/.openjarvis/USER.md"
|
||||
persona_name = "" # optional named persona -- see below
|
||||
```
|
||||
|
||||
### How they're loaded
|
||||
|
||||
At the start of each conversation, `SystemPromptBuilder` reads each file as UTF-8 and adds its contents as a section of the system prompt, after the agent template and before the skill catalog:
|
||||
|
||||
- **All three are optional.** A missing or empty file is skipped, so any subset works and an install with no persona files behaves exactly as before.
|
||||
- **Edits apply to the next conversation.** The files are read once when a conversation's prompt is built, so there is no restart or re-indexing -- edit or delete a line and it takes effect the next time you start a conversation.
|
||||
- **Each section is length-capped.** Files are truncated to a per-section character budget so a large `MEMORY.md` cannot crowd out the rest of the prompt.
|
||||
|
||||
### Named personas
|
||||
|
||||
A single install can answer as different personas without changing global config. A named persona lives in its own directory:
|
||||
|
||||
```
|
||||
~/.openjarvis/personas/<name>/SOUL.md
|
||||
~/.openjarvis/personas/<name>/MEMORY.md
|
||||
~/.openjarvis/personas/<name>/USER.md
|
||||
```
|
||||
|
||||
Select one per invocation, or opt out entirely:
|
||||
|
||||
```bash
|
||||
jarvis ask --persona work "summarize my open PRs"
|
||||
jarvis ask --persona none "what is 2 + 2?" # inject no persona
|
||||
```
|
||||
|
||||
Set `persona_name` under `[memory_files]` to make a named persona the default. `persona_name = "none"` (equivalently `--persona none`) disables persona injection for that run.
|
||||
|
||||
### Editing them
|
||||
|
||||
`SOUL.md`, `MEMORY.md`, and `USER.md` are plain Markdown -- open them in any editor. `MEMORY.md` and `USER.md` can also be updated by the agent itself through the `memory_manage` and `user_profile_manage` tools when those are enabled, so the agent can record a new fact mid-conversation. These tools always target the default `MEMORY.md` and `USER.md` (under `~/.openjarvis/`), never a named persona's copies -- edit those by hand.
|
||||
|
||||
---
|
||||
|
||||
## BaseAgent ABC
|
||||
|
||||
All agents extend the abstract `BaseAgent` class.
|
||||
|
||||
Generated
+67
-52
@@ -11,7 +11,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -42,7 +42,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -3720,9 +3720,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -3730,9 +3730,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
|
||||
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
@@ -3746,23 +3746,23 @@
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.10.1",
|
||||
"@tauri-apps/cli-darwin-x64": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
|
||||
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3777,9 +3777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
|
||||
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3794,9 +3794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
|
||||
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3811,13 +3811,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3828,13 +3831,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3845,13 +3851,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3862,13 +3871,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
|
||||
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3879,13 +3891,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
|
||||
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3896,9 +3911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3913,9 +3928,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3930,9 +3945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
|
||||
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
@@ -49,7 +49,7 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
Generated
+1108
-1007
File diff suppressed because it is too large
Load Diff
@@ -24,9 +24,22 @@ serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Cloud API keys are stored in the OS credential store via `keyring`. keyring v3
|
||||
# enables NO backend by default — without an explicit per-platform feature it
|
||||
# silently falls back to a non-persistent in-memory mock, so keys would not
|
||||
# survive an app restart. Each desktop target opts into its native store.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2"
|
||||
dispatch = "0.2"
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Blocking Secret Service backend (no internal async runtime, so it is safe to
|
||||
# call from the tokio-driven Tauri commands). Needs libdbus-1-dev at build time.
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
|
||||
+567
-97
@@ -9,7 +9,7 @@ use tokio::sync::Mutex;
|
||||
const OLLAMA_PORT: u16 = 11434;
|
||||
const JARVIS_PORT: u16 = 8000;
|
||||
|
||||
/// Small, fast model pulled at startup so the app opens quickly.
|
||||
/// Small, fast model used when startup needs a default Ollama tag.
|
||||
const STARTUP_MODEL: &str = "qwen3.5:4b";
|
||||
|
||||
/// Tiny fallback model if even the startup model can't be pulled.
|
||||
@@ -104,7 +104,7 @@ fn default_local_model(ram_gb: f64) -> &'static str {
|
||||
struct BootPlan {
|
||||
/// Whether to start and wait for the bundled Ollama.
|
||||
launch_ollama: bool,
|
||||
/// The single Ollama model to pull (None for custom endpoints).
|
||||
/// The preferred Ollama model (None for custom endpoints).
|
||||
model_to_pull: Option<String>,
|
||||
/// Optional `(engine_key, bare_host)` override for a custom endpoint,
|
||||
/// e.g. `("lmstudio", "http://localhost:1234")`. Written into
|
||||
@@ -608,6 +608,69 @@ async fn wait_for_jarvis_health(
|
||||
}
|
||||
|
||||
async fn ollama_has_model(model: &str) -> bool {
|
||||
let models = ollama_model_names().await;
|
||||
matching_installed_model(&models, model).is_some()
|
||||
}
|
||||
|
||||
fn parse_ollama_model_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body.get("models")
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|models| {
|
||||
models
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
m.get("name")
|
||||
.or_else(|| m.get("model"))
|
||||
.and_then(|n| n.as_str())
|
||||
})
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.map(|name| name.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn model_names_match(installed: &str, requested: &str) -> bool {
|
||||
installed == requested
|
||||
|| installed.strip_suffix(":latest") == Some(requested)
|
||||
|| requested.strip_suffix(":latest") == Some(installed)
|
||||
}
|
||||
|
||||
fn matching_installed_model(models: &[String], requested: &str) -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| model_names_match(model, requested))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn model_name_looks_embedding_only(model: &str) -> bool {
|
||||
let name = model.to_ascii_lowercase();
|
||||
["embed", "embedding", "rerank", "minilm", "bge-", "bge_", "e5-", "e5_"]
|
||||
.iter()
|
||||
.any(|marker| name.contains(marker))
|
||||
}
|
||||
|
||||
fn preferred_installed_model(models: &[String]) -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.find(|model| !model.trim().is_empty() && !model_name_looks_embedding_only(model))
|
||||
.or_else(|| models.iter().find(|model| !model.trim().is_empty()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn startup_installed_model(requested_model: &str, installed_models: &[String]) -> Option<String> {
|
||||
matching_installed_model(installed_models, requested_model)
|
||||
.or_else(|| preferred_installed_model(installed_models))
|
||||
}
|
||||
|
||||
fn should_persist_resolved_model(cfg: &InferenceConfig) -> bool {
|
||||
cfg.model
|
||||
.as_deref()
|
||||
.map(|model| model.trim().is_empty())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn ollama_model_names() -> Vec<String> {
|
||||
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
@@ -615,21 +678,10 @@ async fn ollama_has_model(model: &str) -> bool {
|
||||
.unwrap();
|
||||
if let Ok(resp) = client.get(&url).send().await {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(models) = body.get("models").and_then(|m| m.as_array()) {
|
||||
return models.iter().any(|m| {
|
||||
m.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|n| {
|
||||
n == model
|
||||
|| n.strip_suffix(":latest") == Some(model)
|
||||
|| model.strip_suffix(":latest") == Some(n)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
return parse_ollama_model_names(&body);
|
||||
}
|
||||
}
|
||||
false
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn pull_model(model: &str) -> Result<(), String> {
|
||||
@@ -679,13 +731,20 @@ fn format_uv_sync_failure(
|
||||
let code = exit_code
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let tail = uv_sync_stderr_tail(stderr, 800);
|
||||
let rust_hint = if looks_like_rust_extension_build_error(stderr) {
|
||||
format!("\n\n{}", rust_toolchain_install_hint())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
|
||||
Try opening a terminal in that directory and running \
|
||||
`uv sync --extra desktop` manually for the full output.",
|
||||
`uv sync --extra desktop` manually for the full output.{}",
|
||||
root.display(),
|
||||
code,
|
||||
uv_sync_stderr_tail(stderr, 800),
|
||||
tail,
|
||||
rust_hint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -734,6 +793,121 @@ fn format_uv_sync_spawn_error(root: &std::path::Path, uv_bin: &str, err: &str) -
|
||||
)
|
||||
}
|
||||
|
||||
fn rust_toolchain_install_hint() -> &'static str {
|
||||
"The desktop app needs the Rust toolchain to build `openjarvis_rust`. \
|
||||
Install Rust from https://rustup.rs. On Windows, also install Visual Studio \
|
||||
Build Tools with the C++ workload, then relaunch."
|
||||
}
|
||||
|
||||
fn looks_like_rust_extension_build_error(stderr: &str) -> bool {
|
||||
let lower = stderr.to_ascii_lowercase();
|
||||
[
|
||||
"openjarvis-rust",
|
||||
"openjarvis_rust",
|
||||
"maturin",
|
||||
"cargo",
|
||||
"rustc",
|
||||
"link.exe",
|
||||
"visual studio",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lower.contains(marker))
|
||||
}
|
||||
|
||||
fn format_missing_rust_toolchain() -> String {
|
||||
format!(
|
||||
"Could not find Rust's `cargo` command. {}\n\n\
|
||||
If Rust is already installed, close and relaunch the desktop app so \
|
||||
PATH includes `~/.cargo/bin`.",
|
||||
rust_toolchain_install_hint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_extension_import_failure(root: &std::path::Path, stderr: &str) -> String {
|
||||
let tail = uv_sync_stderr_tail(stderr, 4000);
|
||||
format!(
|
||||
"`openjarvis_rust` is still not importable after building. Last output:\n\n{}\n\n\
|
||||
Run these manually for the full build log:\n\n\
|
||||
cd {}\n\
|
||||
uv sync --extra desktop\n\
|
||||
uv run python -c \"import openjarvis_rust\"",
|
||||
if tail.is_empty() {
|
||||
"(no stderr output)"
|
||||
} else {
|
||||
&tail
|
||||
},
|
||||
root.display(),
|
||||
)
|
||||
}
|
||||
|
||||
fn add_cargo_bin_to_path(cmd: &mut tokio::process::Command) {
|
||||
let mut paths: Vec<std::path::PathBuf> = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect())
|
||||
.unwrap_or_default();
|
||||
paths.insert(
|
||||
0,
|
||||
std::path::PathBuf::from(home_dir())
|
||||
.join(".cargo")
|
||||
.join("bin"),
|
||||
);
|
||||
if let Ok(joined) = std::env::join_paths(paths) {
|
||||
cmd.env("PATH", joined);
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_openjarvis_rust_extension(
|
||||
root: &std::path::Path,
|
||||
uv_bin: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut cmd = tokio::process::Command::new(uv_bin);
|
||||
cmd.args(["run", "python", "-c", "import openjarvis_rust"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(root);
|
||||
prepare_subprocess_for_appimage(&mut cmd);
|
||||
add_cargo_bin_to_path(&mut cmd);
|
||||
|
||||
match cmd.output().await {
|
||||
Ok(out) if out.status.success() => Ok(()),
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
Err(format_extension_import_failure(root, &stderr))
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"Could not verify `openjarvis_rust`: {}. Verify uv is installed at `{}`.",
|
||||
e, uv_bin
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn port_owner_hint() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
format!("netstat -ano | findstr :{}", JARVIS_PORT)
|
||||
} else {
|
||||
format!("lsof -i :{}", JARVIS_PORT)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_port_unavailable(port: u16, reason: &str) -> String {
|
||||
format!(
|
||||
"Port {} is not available: {}. Stop the process using that port or \
|
||||
change the OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
|
||||
port,
|
||||
reason,
|
||||
port_owner_hint(),
|
||||
)
|
||||
}
|
||||
|
||||
fn check_jarvis_port_available() -> Result<(), String> {
|
||||
match std::net::TcpListener::bind(("127.0.0.1", JARVIS_PORT)) {
|
||||
Ok(listener) => {
|
||||
drop(listener);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(format_port_unavailable(JARVIS_PORT, &err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend boot sequence (runs in background after app launch)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -751,7 +925,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
.into();
|
||||
}
|
||||
|
||||
// For the Ollama path, the model pull may fall back to FALLBACK_MODEL; we
|
||||
// For the Ollama path, model resolution may fall back to FALLBACK_MODEL; we
|
||||
// record what is actually available here so the serve command below uses
|
||||
// it instead of the originally-planned tag. None on the custom path.
|
||||
let mut serve_model_override: Option<String> = None;
|
||||
@@ -798,8 +972,8 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
s.detail = "Inference engine ready.".into();
|
||||
}
|
||||
|
||||
// Phase 2: Pull the single default model (see default_local_model /
|
||||
// boot_plan). We deliberately do NOT pull any others.
|
||||
// Phase 2: Resolve one model to serve. Prefer an installed model on
|
||||
// first run so startup does not depend on a download succeeding.
|
||||
let model = plan
|
||||
.model_to_pull
|
||||
.clone()
|
||||
@@ -810,41 +984,63 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
s.detail = format!("Checking for {}...", model);
|
||||
}
|
||||
|
||||
if !ollama_has_model(&model).await {
|
||||
let installed_models = ollama_model_names().await;
|
||||
let resolved_model = if let Some(installed) = startup_installed_model(&model, &installed_models) {
|
||||
installed
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}... (this may take a minute)", model);
|
||||
}
|
||||
if let Err(e) = pull_model(&model).await {
|
||||
// If the chosen model fails, try the tiny fallback
|
||||
eprintln!("Warning: failed to pull {}: {}", model, e);
|
||||
if !ollama_has_model(FALLBACK_MODEL).await {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
||||
}
|
||||
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!("Failed to download model: {}", e2));
|
||||
return;
|
||||
match pull_model(&model).await {
|
||||
Ok(()) => model.clone(),
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to pull {}: {}", model, e);
|
||||
|
||||
// If a local model appeared while pulling, use it instead of
|
||||
// making startup depend on another network pull.
|
||||
if let Some(installed) = preferred_installed_model(&ollama_model_names().await) {
|
||||
installed
|
||||
} else if ollama_has_model(FALLBACK_MODEL).await {
|
||||
FALLBACK_MODEL.to_string()
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
||||
}
|
||||
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
|
||||
if let Some(installed) =
|
||||
preferred_installed_model(&ollama_model_names().await)
|
||||
{
|
||||
installed
|
||||
} else {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!("Failed to download model: {}", e2));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
FALLBACK_MODEL.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if resolved_model != model {
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Using installed model {}.", resolved_model);
|
||||
}
|
||||
|
||||
// The pull may have fallen back to FALLBACK_MODEL; serve and persist
|
||||
// whatever is actually available now, not the originally-planned tag.
|
||||
let resolved_model = if ollama_has_model(&model).await {
|
||||
model
|
||||
} else {
|
||||
FALLBACK_MODEL.to_string()
|
||||
};
|
||||
serve_model_override = Some(resolved_model.clone());
|
||||
|
||||
// Persist the resolved model so Settings shows it and future boots reuse it.
|
||||
let mut persisted = cfg.clone();
|
||||
persisted.model = Some(resolved_model);
|
||||
let _ = write_inference_config(&persisted);
|
||||
// Persist only first-run/default resolution. If the user explicitly
|
||||
// configured a model, do not overwrite that choice with a temporary
|
||||
// fallback selected just to keep startup nonfatal.
|
||||
if should_persist_resolved_model(&cfg) {
|
||||
let mut persisted = cfg.clone();
|
||||
persisted.model = Some(resolved_model);
|
||||
let _ = write_inference_config(&persisted);
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
@@ -1097,11 +1293,6 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
// Something else (a different web server, a stale process,
|
||||
// a 4xx-returning instance) is on our port. Don't kill it —
|
||||
// give the user actionable info instead.
|
||||
let lsof_hint = if cfg!(target_os = "windows") {
|
||||
format!("netstat -ano | findstr :{}", JARVIS_PORT)
|
||||
} else {
|
||||
format!("lsof -i :{}", JARVIS_PORT)
|
||||
};
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!(
|
||||
"Port {} is already in use by another service (it answered \
|
||||
@@ -1109,7 +1300,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
|
||||
JARVIS_PORT,
|
||||
resp.status(),
|
||||
lsof_hint,
|
||||
port_owner_hint(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
@@ -1119,8 +1310,21 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = check_jarvis_port_available() {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let root = project_root.as_ref().unwrap();
|
||||
|
||||
let cargo_bin = resolve_bin("cargo");
|
||||
if !std::path::Path::new(&cargo_bin).exists() && cargo_bin == "cargo" {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format_missing_rust_toolchain());
|
||||
return;
|
||||
}
|
||||
|
||||
// Install dependencies automatically (handles fresh clones).
|
||||
//
|
||||
// Previously we ran `uv sync` with both stdout AND stderr piped to
|
||||
@@ -1152,6 +1356,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
.current_dir(root);
|
||||
// Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455).
|
||||
prepare_subprocess_for_appimage(&mut sync_cmd);
|
||||
add_cargo_bin_to_path(&mut sync_cmd);
|
||||
let sync_output = sync_cmd.output().await;
|
||||
match sync_output {
|
||||
Ok(out) if !out.status.success() => {
|
||||
@@ -1168,6 +1373,16 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
Ok(_) => {} // success — fall through
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = "Verifying Rust extension (openjarvis_rust)...".into();
|
||||
}
|
||||
if let Err(err) = verify_openjarvis_rust_extension(root, &uv_bin).await {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(err);
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Starting API server from {}...", root.display());
|
||||
@@ -1204,7 +1419,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
// additions aren't accidentally stripped.
|
||||
prepare_subprocess_for_appimage(&mut cmd);
|
||||
|
||||
// Inject cloud API keys from ~/.openjarvis/cloud-keys.env
|
||||
// Inject cloud API keys from secure desktop storage.
|
||||
for (key, value) in read_cloud_keys() {
|
||||
cmd.env(&key, &value);
|
||||
}
|
||||
@@ -1720,17 +1935,111 @@ async fn submit_savings(
|
||||
// Cloud API key management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Path to the cloud keys file (~/.openjarvis/cloud-keys.env).
|
||||
fn cloud_keys_path() -> std::path::PathBuf {
|
||||
const SECURE_KEY_SERVICE: &str = "OpenJarvis Cloud Keys";
|
||||
const MANAGED_CLOUD_KEY_NAMES: &[&str] = &[
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
];
|
||||
|
||||
/// Legacy path used by older desktop builds. New saves never write here.
|
||||
fn legacy_cloud_keys_path() -> std::path::PathBuf {
|
||||
let home = home_dir();
|
||||
std::path::PathBuf::from(home)
|
||||
.join(".openjarvis")
|
||||
.join("cloud-keys.env")
|
||||
}
|
||||
|
||||
/// Read cloud keys from disk and return as key=value pairs.
|
||||
fn read_cloud_keys() -> Vec<(String, String)> {
|
||||
let path = cloud_keys_path();
|
||||
fn validate_cloud_key_name(key_name: &str) -> Result<(), String> {
|
||||
let valid = !key_name.is_empty()
|
||||
&& key_name.len() <= 128
|
||||
&& key_name.ends_with("_API_KEY")
|
||||
&& key_name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_');
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Invalid API key name: {}", key_name))
|
||||
}
|
||||
}
|
||||
|
||||
fn engine_api_key_name(engine: &str) -> String {
|
||||
let normalized: String = engine
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_uppercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = normalized.trim_matches('_');
|
||||
let engine_name = if trimmed.is_empty() {
|
||||
CUSTOM_FALLBACK_ENGINE.to_ascii_uppercase()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
format!("{}_API_KEY", engine_name)
|
||||
}
|
||||
|
||||
fn managed_cloud_key_names() -> Vec<String> {
|
||||
let mut names: Vec<String> = MANAGED_CLOUD_KEY_NAMES
|
||||
.iter()
|
||||
.map(|name| (*name).to_string())
|
||||
.collect();
|
||||
|
||||
let cfg = read_inference_config();
|
||||
if matches!(&cfg.kind, SourceKind::Custom) {
|
||||
let engine = cfg.engine.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
|
||||
let key_name = engine_api_key_name(&engine);
|
||||
if validate_cloud_key_name(&key_name).is_ok() {
|
||||
names.push(key_name);
|
||||
}
|
||||
}
|
||||
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
fn secure_store_get(key_name: &str) -> Result<Option<String>, String> {
|
||||
validate_cloud_key_name(key_name)?;
|
||||
let entry = keyring::Entry::new(SECURE_KEY_SERVICE, key_name)
|
||||
.map_err(|err| format!("Failed to open secure key storage for {}: {}", key_name, err))?;
|
||||
match entry.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(format!("Failed to read {} from secure key storage: {}", key_name, err)),
|
||||
}
|
||||
}
|
||||
|
||||
fn secure_store_set(key_name: &str, key_value: &str) -> Result<(), String> {
|
||||
validate_cloud_key_name(key_name)?;
|
||||
let entry = keyring::Entry::new(SECURE_KEY_SERVICE, key_name)
|
||||
.map_err(|err| format!("Failed to open secure key storage for {}: {}", key_name, err))?;
|
||||
if key_value.is_empty() {
|
||||
return match entry.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(format!(
|
||||
"Failed to remove {} from secure key storage: {}",
|
||||
key_name, err
|
||||
)),
|
||||
};
|
||||
}
|
||||
entry
|
||||
.set_password(key_value)
|
||||
.map_err(|err| format!("Failed to save {} in secure key storage: {}", key_name, err))
|
||||
}
|
||||
|
||||
fn read_legacy_cloud_keys() -> Vec<(String, String)> {
|
||||
let path = legacy_cloud_keys_path();
|
||||
let mut keys = Vec::new();
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
for line in contents.lines() {
|
||||
@@ -1746,47 +2055,68 @@ fn read_cloud_keys() -> Vec<(String, String)> {
|
||||
keys
|
||||
}
|
||||
|
||||
/// Save a single cloud API key to the keys file.
|
||||
#[tauri::command]
|
||||
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
|
||||
let path = cloud_keys_path();
|
||||
// Ensure directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
fn migrate_legacy_cloud_keys() {
|
||||
let path = legacy_cloud_keys_path();
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read existing keys, update/add the one being saved
|
||||
let mut keys: Vec<(String, String)> = read_cloud_keys()
|
||||
let legacy_keys = read_legacy_cloud_keys();
|
||||
if legacy_keys.is_empty() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut migrated_all = true;
|
||||
for (key, value) in legacy_keys {
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if secure_store_set(&key, &value).is_err() {
|
||||
migrated_all = false;
|
||||
}
|
||||
}
|
||||
|
||||
if migrated_all {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read cloud keys from secure desktop storage and return key=value pairs.
|
||||
fn read_cloud_keys() -> Vec<(String, String)> {
|
||||
migrate_legacy_cloud_keys();
|
||||
managed_cloud_key_names()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| k != &key_name)
|
||||
.collect();
|
||||
if !key_value.is_empty() {
|
||||
keys.push((key_name, key_value));
|
||||
}
|
||||
.filter_map(|key| match secure_store_get(&key) {
|
||||
Ok(Some(value)) if !value.is_empty() => Some((key, value)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Write back
|
||||
let content: String = keys
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
std::fs::write(&path, content + "\n").map_err(|e| format!("Failed to save key: {}", e))?;
|
||||
|
||||
// Set permissions to owner-only (chmod 600)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
// Tell the running server to hot-reload its cloud engine so the user
|
||||
// doesn't need to restart the app after entering an API key.
|
||||
async fn reload_cloud_keys(keys: Vec<(String, String)>) {
|
||||
let reload_url = format!("http://127.0.0.1:{}/v1/cloud/reload", JARVIS_PORT);
|
||||
let key_map: serde_json::Map<String, serde_json::Value> = keys
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, serde_json::Value::String(value)))
|
||||
.collect();
|
||||
let _ = reqwest::Client::new()
|
||||
.post(&reload_url)
|
||||
.json(&serde_json::json!({ "keys": key_map }))
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Save a single cloud API key to secure desktop storage.
|
||||
#[tauri::command]
|
||||
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
|
||||
let key_value = key_value.trim().to_string();
|
||||
secure_store_set(&key_name, &key_value)?;
|
||||
|
||||
// Tell the running server to hot-reload its cloud engine so the user
|
||||
// doesn't need to restart the app after entering an API key.
|
||||
reload_cloud_keys(vec![(key_name, key_value)]).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1794,10 +2124,13 @@ async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), Strin
|
||||
/// Get which cloud providers have keys configured (without exposing values).
|
||||
#[tauri::command]
|
||||
async fn get_cloud_key_status() -> Result<serde_json::Value, String> {
|
||||
let keys = read_cloud_keys();
|
||||
let status: Vec<serde_json::Value> = keys
|
||||
.iter()
|
||||
.map(|(k, v)| serde_json::json!({ "key": k, "set": !v.is_empty() }))
|
||||
migrate_legacy_cloud_keys();
|
||||
let status: Vec<serde_json::Value> = managed_cloud_key_names()
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let set = matches!(secure_store_get(&key), Ok(Some(value)) if !value.is_empty());
|
||||
serde_json::json!({ "key": key, "set": set })
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!(status))
|
||||
}
|
||||
@@ -1809,8 +2142,8 @@ async fn get_inference_source() -> Result<InferenceConfig, String> {
|
||||
}
|
||||
|
||||
/// Persist the chosen inference source. `host` is normalized to a bare base
|
||||
/// URL. For custom endpoints, an optional API key is stored in cloud-keys.env
|
||||
/// under `<ENGINE>_API_KEY`. Applies on next app launch.
|
||||
/// URL. For custom endpoints, an optional API key is stored in secure desktop
|
||||
/// storage under `<ENGINE>_API_KEY`. Applies on next app launch.
|
||||
#[tauri::command]
|
||||
async fn set_inference_source(
|
||||
kind: String,
|
||||
@@ -1842,7 +2175,7 @@ async fn set_inference_source(
|
||||
.engine
|
||||
.clone()
|
||||
.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
|
||||
let key_name = format!("{}_API_KEY", engine.to_ascii_uppercase());
|
||||
let key_name = engine_api_key_name(&engine);
|
||||
// Save the key before persisting the config: if the key can't be
|
||||
// written, surface it and DON'T record a custom source whose
|
||||
// credential is missing (which would fail confusingly at runtime).
|
||||
@@ -2528,9 +2861,12 @@ pub fn run() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error,
|
||||
normalize_host, parse_inference_config, upsert_engine_host, uv_sync_stderr_tail,
|
||||
InferenceConfig, SourceKind,
|
||||
boot_plan, default_local_model, format_extension_import_failure,
|
||||
format_missing_rust_toolchain, format_port_unavailable, format_uv_sync_failure,
|
||||
format_uv_sync_spawn_error, matching_installed_model, model_names_match, normalize_host,
|
||||
parse_inference_config, parse_ollama_model_names, preferred_installed_model,
|
||||
should_persist_resolved_model, startup_installed_model, upsert_engine_host,
|
||||
uv_sync_stderr_tail, InferenceConfig, SourceKind,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -2597,6 +2933,49 @@ mod tests {
|
||||
assert!(msg.contains("No such file or directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rust_toolchain_message_names_cargo_and_installer() {
|
||||
let msg = format_missing_rust_toolchain();
|
||||
assert!(msg.contains("cargo"));
|
||||
assert!(msg.contains("https://rustup.rs"));
|
||||
assert!(msg.contains("openjarvis_rust"));
|
||||
assert!(msg.contains("Visual Studio Build Tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uv_sync_rust_failure_mentions_toolchain() {
|
||||
let msg = format_uv_sync_failure(
|
||||
Path::new("C:\\Users\\me\\OpenJarvis"),
|
||||
Some(1),
|
||||
"maturin failed: linker `link.exe` not found while building openjarvis-rust",
|
||||
);
|
||||
assert!(msg.contains("exit 1"));
|
||||
assert!(msg.contains("link.exe"));
|
||||
assert!(msg.contains("https://rustup.rs"));
|
||||
assert!(msg.contains("Visual Studio Build Tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_import_failure_names_verification_command() {
|
||||
let msg = format_extension_import_failure(
|
||||
Path::new("C:\\Users\\me\\OpenJarvis"),
|
||||
"ModuleNotFoundError: No module named 'openjarvis_rust'",
|
||||
);
|
||||
assert!(msg.contains("openjarvis_rust"));
|
||||
assert!(msg.contains("uv sync --extra desktop"));
|
||||
assert!(msg.contains("uv run python -c \"import openjarvis_rust\""));
|
||||
assert!(msg.contains("ModuleNotFoundError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_unavailable_message_names_port_and_owner_hint() {
|
||||
let msg = format_port_unavailable(8000, "address already in use");
|
||||
assert!(msg.contains("Port 8000 is not available"));
|
||||
assert!(msg.contains("address already in use"));
|
||||
assert!(msg.contains("To identify it"));
|
||||
assert!(msg.contains("8000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_local_model_picks_second_largest_that_fits() {
|
||||
// QWEN35_MODELS min_ram ladder: 4,6,8,12,24,32,96 GB
|
||||
@@ -2612,6 +2991,97 @@ mod tests {
|
||||
assert_eq!(default_local_model(1.0), super::FALLBACK_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ollama_model_names_reads_nonempty_names() {
|
||||
let body = serde_json::json!({
|
||||
"models": [
|
||||
{"name": "llama3.2:latest"},
|
||||
{"name": ""},
|
||||
{"name": "qwen3.5:4b"},
|
||||
{"model": "mistral:latest"}
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
parse_ollama_model_names(&body),
|
||||
vec![
|
||||
"llama3.2:latest".to_string(),
|
||||
"qwen3.5:4b".to_string(),
|
||||
"mistral:latest".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_names_match_treats_latest_as_optional() {
|
||||
assert!(model_names_match("llama3.2:latest", "llama3.2"));
|
||||
assert!(model_names_match("llama3.2", "llama3.2:latest"));
|
||||
assert!(model_names_match("qwen3.5:4b", "qwen3.5:4b"));
|
||||
assert!(!model_names_match("llama3.2:latest", "qwen3.5:4b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_model_helpers_pick_matching_or_first_model() {
|
||||
let models = vec!["llama3.2:latest".to_string(), "qwen3.5:4b".to_string()];
|
||||
assert_eq!(
|
||||
matching_installed_model(&models, "llama3.2"),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_installed_model(&models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_installed_model_skips_embedding_names_when_chat_model_exists() {
|
||||
let models = vec![
|
||||
"nomic-embed-text:latest".to_string(),
|
||||
"llama3.2:latest".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
preferred_installed_model(&models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_installed_model_uses_existing_model_for_defaults() {
|
||||
let models = vec!["llama3.2:latest".to_string()];
|
||||
assert_eq!(
|
||||
startup_installed_model("qwen3.5:4b", &models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_installed_model_uses_existing_model_when_configured_model_missing() {
|
||||
let models = vec!["llama3.2:latest".to_string()];
|
||||
assert_eq!(
|
||||
startup_installed_model("qwen3.5:4b", &models),
|
||||
Some("llama3.2:latest".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_model_is_only_persisted_when_no_model_was_configured() {
|
||||
let default_cfg = InferenceConfig { kind: SourceKind::Ollama, ..Default::default() };
|
||||
assert!(should_persist_resolved_model(&default_cfg));
|
||||
|
||||
let empty_cfg = InferenceConfig {
|
||||
kind: SourceKind::Ollama,
|
||||
model: Some(" ".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(should_persist_resolved_model(&empty_cfg));
|
||||
|
||||
let user_cfg = InferenceConfig {
|
||||
kind: SourceKind::Ollama,
|
||||
model: Some("qwen3.5:9b".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!should_persist_resolved_model(&user_cfg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_defaults_to_ollama_when_file_missing_or_garbage() {
|
||||
assert!(matches!(parse_inference_config("").kind, SourceKind::Ollama));
|
||||
|
||||
@@ -243,7 +243,11 @@ export function InputArea() {
|
||||
|
||||
try {
|
||||
if (deepResearch) {
|
||||
for await (const ev of streamResearch(content, controller.signal)) {
|
||||
for await (const ev of streamResearch(
|
||||
content,
|
||||
selectedModel,
|
||||
controller.signal,
|
||||
)) {
|
||||
if (ev.type === 'search_call') {
|
||||
const trace: ResearchSearchTrace = {
|
||||
id: generateId(),
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Search, Cpu, X, Download, Loader2, Trash2, Check, Cloud, Key, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAppStore } from '../lib/store';
|
||||
import { pullModel, deleteModel, fetchModels, preloadModel, isTauri } from '../lib/api';
|
||||
import {
|
||||
pullModel,
|
||||
deleteModel,
|
||||
fetchModels,
|
||||
preloadModel,
|
||||
isTauri,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
} from '../lib/api';
|
||||
|
||||
/** Popular models that users can download from the catalogue. */
|
||||
const CATALOGUE_MODELS = [
|
||||
@@ -23,7 +31,6 @@ const CATALOGUE_MODELS = [
|
||||
interface CloudProvider {
|
||||
name: string;
|
||||
envKey: string;
|
||||
storageKey: string;
|
||||
models: Array<{ id: string; desc: string }>;
|
||||
}
|
||||
|
||||
@@ -31,7 +38,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
storageKey: 'openjarvis-openai-key',
|
||||
models: [
|
||||
{ id: 'gpt-4o', desc: 'GPT-4o — fast, multimodal' },
|
||||
{ id: 'gpt-4o-mini', desc: 'GPT-4o Mini — cheap, fast' },
|
||||
@@ -41,7 +47,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'Anthropic',
|
||||
envKey: 'ANTHROPIC_API_KEY',
|
||||
storageKey: 'openjarvis-anthropic-key',
|
||||
models: [
|
||||
{ id: 'claude-sonnet-4-6', desc: 'Claude Sonnet 4.6 — balanced' },
|
||||
{ id: 'claude-opus-4-6', desc: 'Claude Opus 4.6 — most capable' },
|
||||
@@ -51,7 +56,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'Google',
|
||||
envKey: 'GEMINI_API_KEY',
|
||||
storageKey: 'openjarvis-gemini-key',
|
||||
models: [
|
||||
{ id: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro — flagship' },
|
||||
{ id: 'gemini-2.5-flash', desc: 'Gemini 2.5 Flash — fast' },
|
||||
@@ -61,7 +65,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
envKey: 'OPENROUTER_API_KEY',
|
||||
storageKey: 'openjarvis-openrouter-key',
|
||||
models: [
|
||||
{ id: 'openrouter/auto', desc: 'Auto — best model for the task' },
|
||||
{ id: 'openrouter/anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 via OpenRouter' },
|
||||
@@ -70,16 +73,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function getStoredKey(storageKey: string): string {
|
||||
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
|
||||
}
|
||||
function setStoredKey(storageKey: string, value: string): void {
|
||||
try {
|
||||
if (value) localStorage.setItem(storageKey, value);
|
||||
else localStorage.removeItem(storageKey);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
type Tab = 'installed' | 'catalogue' | 'cloud';
|
||||
|
||||
export function CommandPalette() {
|
||||
@@ -92,11 +85,10 @@ export function CommandPalette() {
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
|
||||
const keys: Record<string, string> = {};
|
||||
for (const p of CLOUD_PROVIDERS) keys[p.storageKey] = getStoredKey(p.storageKey);
|
||||
return keys;
|
||||
});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
const [cloudKeyStatus, setCloudKeyStatus] = useState<Record<string, boolean>>({});
|
||||
const [cloudKeyError, setCloudKeyError] = useState<string | null>(null);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const models = useAppStore((s) => s.models);
|
||||
@@ -106,6 +98,20 @@ export function CommandPalette() {
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
|
||||
const installedIds = new Set(models.map((m) => m.id));
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refreshCloudKeyStatus = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setCloudKeyStatus({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCloudKeyStatus(await getCloudKeyStatus());
|
||||
setCloudKeyError(null);
|
||||
} catch (e: any) {
|
||||
setCloudKeyError(e?.message || 'Failed to read cloud key status');
|
||||
}
|
||||
}, [desktopKeyStorage]);
|
||||
|
||||
const filtered = tab === 'installed'
|
||||
? (query
|
||||
@@ -122,6 +128,10 @@ export function CommandPalette() {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCloudKeyStatus();
|
||||
}, [refreshCloudKeyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIdx(0);
|
||||
}, [query, tab]);
|
||||
@@ -210,24 +220,29 @@ export function CommandPalette() {
|
||||
};
|
||||
|
||||
const handleSaveKey = async (provider: CloudProvider, value: string) => {
|
||||
setStoredKey(provider.storageKey, value);
|
||||
setApiKeys((prev) => ({ ...prev, [provider.storageKey]: value }));
|
||||
const keyValue = value.trim();
|
||||
setSavingKey(provider.envKey);
|
||||
setCloudKeyError(null);
|
||||
|
||||
// Also save to Tauri backend so the server process picks up the key
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('save_cloud_key', { keyName: provider.envKey, keyValue: value });
|
||||
} catch {}
|
||||
try {
|
||||
await saveCloudKey(provider.envKey, keyValue);
|
||||
setApiKeys((prev) => ({ ...prev, [provider.envKey]: '' }));
|
||||
await refreshCloudKeyStatus();
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'model',
|
||||
message: `${provider.name} API key ${keyValue ? 'saved' : 'removed'}. Refreshing model list...`,
|
||||
});
|
||||
await refreshModels();
|
||||
} catch (e: any) {
|
||||
setCloudKeyError(e?.message || `Failed to save ${provider.name} API key`);
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'model',
|
||||
message: `${provider.name} API key ${value ? 'saved' : 'removed'}. Refreshing model list…`,
|
||||
});
|
||||
|
||||
// Refresh the model list so cloud models appear immediately.
|
||||
await refreshModels();
|
||||
const handleKeyBlur = (provider: CloudProvider) => {
|
||||
const draft = apiKeys[provider.envKey] || '';
|
||||
if (draft.trim()) void handleSaveKey(provider, draft);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -323,6 +338,11 @@ export function CommandPalette() {
|
||||
<Check size={12} /> Downloaded {pullSuccess} successfully
|
||||
</div>
|
||||
)}
|
||||
{tab === 'cloud' && cloudKeyError && (
|
||||
<div className="px-4 py-2 text-xs" style={{ color: 'var(--color-error)', background: 'rgba(220,38,38,0.05)' }}>
|
||||
{cloudKeyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[400px] overflow-y-auto py-2">
|
||||
@@ -431,13 +451,17 @@ export function CommandPalette() {
|
||||
/* ── Cloud Models tab ── */
|
||||
<div className="px-4 py-2">
|
||||
<div className="text-[11px] mb-3" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Add your API keys to use cloud models. Keys are stored locally on your device only.
|
||||
{desktopKeyStorage
|
||||
? 'Add your API keys to use cloud models. Keys are stored in secure desktop storage.'
|
||||
: 'Configure cloud provider keys in the server environment to use cloud models.'}
|
||||
</div>
|
||||
|
||||
{CLOUD_PROVIDERS.map((provider) => {
|
||||
const key = apiKeys[provider.storageKey] || '';
|
||||
const hasKey = !!key;
|
||||
const isVisible = showKeys[provider.storageKey];
|
||||
const key = apiKeys[provider.envKey] || '';
|
||||
const hasSavedKey = !!cloudKeyStatus[provider.envKey];
|
||||
const hasKey = hasSavedKey || !!key.trim();
|
||||
const isVisible = showKeys[provider.envKey];
|
||||
const isSaving = savingKey === provider.envKey;
|
||||
|
||||
return (
|
||||
<div key={provider.name} className="mb-4">
|
||||
@@ -458,26 +482,28 @@ export function CommandPalette() {
|
||||
<input
|
||||
type={isVisible ? 'text' : 'password'}
|
||||
value={key}
|
||||
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.storageKey]: e.target.value }))}
|
||||
onBlur={() => handleSaveKey(provider, apiKeys[provider.storageKey] || '')}
|
||||
placeholder={`${provider.envKey}`}
|
||||
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.envKey]: e.target.value }))}
|
||||
onBlur={() => handleKeyBlur(provider)}
|
||||
placeholder={hasSavedKey ? 'Saved in secure storage' : provider.envKey}
|
||||
disabled={!desktopKeyStorage || isSaving}
|
||||
className="flex-1 text-xs px-2 py-1.5 bg-transparent outline-none font-mono"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.storageKey]: !prev[provider.storageKey] }))}
|
||||
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.envKey]: !prev[provider.envKey] }))}
|
||||
className="px-2 cursor-pointer" style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{isVisible ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
{hasKey && (
|
||||
{hasSavedKey && (
|
||||
<button
|
||||
onClick={() => handleSaveKey(provider, '')}
|
||||
disabled={isSaving}
|
||||
className="px-2 py-1 rounded-lg text-[10px] cursor-pointer"
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)', opacity: isSaving ? 0.5 : 1 }}
|
||||
>
|
||||
Remove
|
||||
{isSaving ? 'Saving' : 'Remove'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { LEADERBOARD_ENABLED, SUPABASE_ANON_KEY, SUPABASE_URL } from '../../lib/supabase';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -279,9 +280,6 @@ function getOrCreateAnonId(): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
const SUPABASE_URL = 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
@@ -318,15 +316,16 @@ export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchData]);
|
||||
|
||||
// Share savings to Supabase when opted in and data changes
|
||||
// Share savings to Supabase when opted in and data changes. Skipped entirely
|
||||
// when no anon key was built in (leaderboard disabled).
|
||||
useEffect(() => {
|
||||
if (!optInEnabled || !displayName || !data) return;
|
||||
if (!LEADERBOARD_ENABLED || !optInEnabled || !displayName || !data) return;
|
||||
const dollarSavings = data.per_provider.reduce((s, p) => s + p.total_cost, 0);
|
||||
const energySaved = data.per_provider.reduce((s, p) => s + (p.energy_wh || 0), 0);
|
||||
const flopsSaved = data.per_provider.reduce((s, p) => s + (p.flops || 0), 0);
|
||||
invoke('submit_savings', {
|
||||
supabaseUrl: SUPABASE_URL,
|
||||
supabaseKey: SUPABASE_KEY,
|
||||
supabaseKey: SUPABASE_ANON_KEY,
|
||||
payload: {
|
||||
anon_id: anonId,
|
||||
display_name: displayName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Regression for #266: the frontend must send the local API key as a Bearer
|
||||
// token on /v1 + /api requests, or `jarvis serve` with a key configured 401s
|
||||
@@ -26,11 +26,14 @@ class MemoryStorage {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
+27
-4
@@ -1,12 +1,10 @@
|
||||
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
|
||||
import { SUPABASE_ANON_KEY, SUPABASE_URL } from './supabase';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supabase config — safe to embed (RLS protects writes)
|
||||
// Supabase config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
@@ -15,6 +13,31 @@ declare global {
|
||||
|
||||
export const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI_INTERNALS__;
|
||||
|
||||
export type CloudKeyStatus = Record<string, boolean>;
|
||||
|
||||
export async function getCloudKeyStatus(): Promise<CloudKeyStatus> {
|
||||
if (!isTauri()) return {};
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const rows = await invoke<Array<{ key: string; set: boolean }>>('get_cloud_key_status');
|
||||
return Object.fromEntries(rows.map((row) => [row.key, row.set]));
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message ?? e ?? 'Failed to read cloud key status');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCloudKey(keyName: string, keyValue: string): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Cloud API keys can be saved in the desktop app only.');
|
||||
}
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('save_cloud_key', { keyName, keyValue });
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message ?? e ?? 'Failed to save cloud key');
|
||||
}
|
||||
}
|
||||
|
||||
// Cached API base URL fetched from the Tauri backend at startup.
|
||||
// This avoids hardcoding the port — the Rust backend is the single
|
||||
// source of truth for JARVIS_PORT.
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function* streamChat(
|
||||
|
||||
export async function* streamResearch(
|
||||
query: string,
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<ResearchEvent> {
|
||||
// /api/research is mounted at the server root — strip any trailing /v1
|
||||
@@ -68,7 +69,7 @@ export async function* streamResearch(
|
||||
const response = await fetch(`${base}/api/research`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ query }),
|
||||
body: JSON.stringify({ query, ...(model ? { model } : {}) }),
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -106,4 +107,3 @@ export async function* streamResearch(
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const SUPABASE_URL =
|
||||
import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
|
||||
// The Supabase anon key is optional at build time. When it is unset the public
|
||||
// savings leaderboard is disabled rather than failing the build — this keeps
|
||||
// the `openjarvis` package and desktop app buildable without coupling
|
||||
// publishability to a leaderboard credential. Set VITE_SUPABASE_ANON_KEY at
|
||||
// build time (from a repo secret) to enable the leaderboard.
|
||||
export const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY ?? '';
|
||||
|
||||
export const LEADERBOARD_ENABLED = SUPABASE_ANON_KEY.length > 0;
|
||||
@@ -19,9 +19,21 @@ import {
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore, type ThemeMode } from '../lib/store';
|
||||
import { checkHealth, fetchSpeechHealth, getMemoryStats, getInferenceSource, setInferenceSource, type InferenceSource } from '../lib/api';
|
||||
import {
|
||||
checkHealth,
|
||||
fetchSpeechHealth,
|
||||
getMemoryStats,
|
||||
getInferenceSource,
|
||||
setInferenceSource,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
isTauri,
|
||||
type InferenceSource,
|
||||
} from '../lib/api';
|
||||
import { isAutoUpdateDisabled, setAutoUpdateDisabled } from '../components/Desktop/UpdateChecker';
|
||||
|
||||
const CLOUD_KEY_STATUS_CHANGED = 'openjarvis-cloud-key-status-changed';
|
||||
|
||||
function OllamaModelList() {
|
||||
const [models, setModels] = useState<Array<{ name: string; size: number }>>([]);
|
||||
useEffect(() => {
|
||||
@@ -44,32 +56,111 @@ function OllamaModelList() {
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyInput({ storageKey, placeholder }: { storageKey: string; placeholder: string }) {
|
||||
const [value, setValue] = useState(() => {
|
||||
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
|
||||
});
|
||||
function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: string }) {
|
||||
const [value, setValue] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const save = (v: string) => {
|
||||
setValue(v);
|
||||
try { if (v) localStorage.setItem(storageKey, v); else localStorage.removeItem(storageKey); } catch {}
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
const save = async (v: string) => {
|
||||
const next = v.trim();
|
||||
if (!next) return;
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, next);
|
||||
setValue('');
|
||||
setHasKey(true);
|
||||
setSaved(true);
|
||||
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save API key');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, '');
|
||||
setValue('');
|
||||
setHasKey(false);
|
||||
setSaved(true);
|
||||
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to remove API key');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="password" value={value} onChange={e => save(e.target.value)} placeholder={placeholder}
|
||||
<input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => { if (value.trim()) void save(value); }}
|
||||
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
|
||||
disabled={!desktopKeyStorage}
|
||||
className="w-48 px-2 py-1 rounded text-xs"
|
||||
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }} />
|
||||
{hasKey && (
|
||||
<button
|
||||
onClick={() => void remove()}
|
||||
className="px-2 py-1 rounded text-[10px] cursor-pointer"
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
{saved && <span className="text-[10px]" style={{ color: 'var(--color-success)' }}>Saved</span>}
|
||||
{error && <span className="text-[10px]" style={{ color: 'var(--color-error)' }}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudProviderStatus({ label, storageKey }: { label: string; storageKey: string }) {
|
||||
function CloudProviderStatus({ label, keyName }: { label: string; keyName: string }) {
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
|
||||
useEffect(() => {
|
||||
try { setHasKey(!!localStorage.getItem(storageKey)); } catch { setHasKey(false); }
|
||||
}, [storageKey]);
|
||||
void refresh();
|
||||
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
<span style={{
|
||||
@@ -424,10 +515,10 @@ export function SettingsPage() {
|
||||
</div>
|
||||
<SettingRow label="Cloud providers" description="Green dot means API key is configured">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<CloudProviderStatus label="OpenAI" storageKey="openjarvis-openai-key" />
|
||||
<CloudProviderStatus label="Anthropic" storageKey="openjarvis-anthropic-key" />
|
||||
<CloudProviderStatus label="Google" storageKey="openjarvis-gemini-key" />
|
||||
<CloudProviderStatus label="OpenRouter" storageKey="openjarvis-openrouter-key" />
|
||||
<CloudProviderStatus label="OpenAI" keyName="OPENAI_API_KEY" />
|
||||
<CloudProviderStatus label="Anthropic" keyName="ANTHROPIC_API_KEY" />
|
||||
<CloudProviderStatus label="Google" keyName="GEMINI_API_KEY" />
|
||||
<CloudProviderStatus label="OpenRouter" keyName="OPENROUTER_API_KEY" />
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
@@ -435,23 +526,23 @@ export function SettingsPage() {
|
||||
{/* API Keys */}
|
||||
<Section title="API Keys">
|
||||
<SettingRow label="OpenAI" description="GPT-4, GPT-3.5, etc.">
|
||||
<ApiKeyInput storageKey="openjarvis-openai-key" placeholder="sk-..." />
|
||||
<ApiKeyInput keyName="OPENAI_API_KEY" placeholder="sk-..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="Anthropic" description="Claude models">
|
||||
<ApiKeyInput storageKey="openjarvis-anthropic-key" placeholder="sk-ant-..." />
|
||||
<ApiKeyInput keyName="ANTHROPIC_API_KEY" placeholder="sk-ant-..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="Google" description="Gemini models">
|
||||
<ApiKeyInput storageKey="openjarvis-gemini-key" placeholder="AI..." />
|
||||
<ApiKeyInput keyName="GEMINI_API_KEY" placeholder="AI..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="OpenRouter" description="Multi-provider routing">
|
||||
<ApiKeyInput storageKey="openjarvis-openrouter-key" placeholder="sk-or-..." />
|
||||
<ApiKeyInput keyName="OPENROUTER_API_KEY" placeholder="sk-or-..." />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Tools */}
|
||||
<Section title="Tools">
|
||||
<SettingRow label="Web Search" description="SerpAPI or Tavily key for web search tool">
|
||||
<ApiKeyInput storageKey="openjarvis-search-key" placeholder="API key..." />
|
||||
<SettingRow label="Web Search" description="Tavily key for web search tool">
|
||||
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
|
||||
Vendored
+3
-1
@@ -1,7 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_SUPABASE_URL?: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
// VITE_SUPABASE_ANON_KEY is intentionally NOT required here: a missing key
|
||||
// disables the savings leaderboard at runtime (see src/lib/supabase.ts) rather
|
||||
// than failing the build, so the package/app stays publishable without it.
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -127,6 +127,7 @@ markdown_extensions:
|
||||
- pymdownx.tilde
|
||||
|
||||
extra_javascript:
|
||||
- javascripts/leaderboard-config.js
|
||||
- javascripts/leaderboard.js
|
||||
- https://cdn.jsdelivr.net/npm/@docsearch/js@3
|
||||
- javascripts/docsearch-init.js
|
||||
|
||||
@@ -91,6 +91,7 @@ desktop = [
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"faster-whisper>=1.0",
|
||||
"openjarvis-rust",
|
||||
]
|
||||
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
|
||||
gpu-metrics = ["pynvml>=12.0"]
|
||||
@@ -186,6 +187,9 @@ git_describe_command = [
|
||||
# Such builds can inject the real version via SETUPTOOLS_SCM_PRETEND_VERSION.
|
||||
fallback_version = "0.0.0+unknown"
|
||||
|
||||
[tool.uv.sources]
|
||||
openjarvis-rust = { path = "rust/crates/openjarvis-python" }
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openjarvis"]
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from openjarvis.agents.prompt_loader import (
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
|
||||
from openjarvis.engine._base import estimate_prompt_tokens
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
from openjarvis.tools._stubs import BaseTool, build_tool_descriptions
|
||||
|
||||
@@ -116,8 +117,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
max_prompt_tokens: int = 3000,
|
||||
) -> list[Message]:
|
||||
"""Truncate messages if estimated token count exceeds limit."""
|
||||
total_chars = sum(len(m.content) for m in messages)
|
||||
estimated_tokens = total_chars // 4
|
||||
estimated_tokens = estimate_prompt_tokens(messages)
|
||||
if estimated_tokens <= max_prompt_tokens:
|
||||
return messages
|
||||
# Find the last user message and truncate its content
|
||||
@@ -125,7 +125,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
if messages[i].role == Role.USER:
|
||||
excess_tokens = estimated_tokens - max_prompt_tokens
|
||||
excess_chars = excess_tokens * 4
|
||||
original = messages[i].content
|
||||
original = messages[i].content or ""
|
||||
if len(original) > excess_chars + 200:
|
||||
truncated = original[: len(original) - excess_chars]
|
||||
messages[i] = Message(
|
||||
@@ -258,7 +258,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
# still emitted before re-raising.
|
||||
self._emit_turn_end(turns=1, error=True)
|
||||
raise
|
||||
content = self._strip_think_tags(result.get("content", ""))
|
||||
content = self._strip_think_tags(result.get("content") or "")
|
||||
usage = result.get("usage", {})
|
||||
self._emit_turn_end(turns=1)
|
||||
return AgentResult(
|
||||
@@ -315,7 +315,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
for k in total_usage:
|
||||
total_usage[k] += usage.get(k, 0)
|
||||
|
||||
content = result.get("content", "")
|
||||
content = result.get("content") or ""
|
||||
# Strip think tags so they don't interfere with parsing
|
||||
content = self._strip_think_tags(content)
|
||||
last_content = content
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
A small, self-contained planner-executor loop:
|
||||
|
||||
* the planner is a local Ollama chat model (default ``gemma4:31b``),
|
||||
* the planner is supplied by the caller (the web endpoint resolves it from
|
||||
config, falling back to ``gemma4:31b`` on Ollama for legacy installs),
|
||||
* the only tool it can call is :meth:`HybridSearch.search`,
|
||||
* it gets up to ``max_iterations`` tool calls,
|
||||
* tool results are trimmed before re-entering the context window, and
|
||||
@@ -142,10 +143,11 @@ Strategy:
|
||||
3. The `time_range` argument is a JSON object: `{{"start": "<ISO 8601>", "end": "<ISO 8601>"}}`. Either bound may be omitted, but pass at least one whenever the user gave you a temporal cue.
|
||||
4. When the user names a specific data source — "my Granola notes", "in Slack", "from my email" — you MUST pass `sources=[...]` with the matching connector ID. Only use IDs that appear in the connected-sources list above; do NOT invent or assume sources that are not connected. Common synonyms: "meeting notes"/"meetings"/"transcripts" → granola; "email"/"inbox" → gmail; "DMs"/"channels" → slack. Without this filter the search returns mail/messages ABOUT a tool instead of records FROM that tool.
|
||||
4a. Never apologize about sources that aren't in the connected-sources list — if the user asks about "Notion" but Notion isn't connected, just say "Notion isn't connected, but here's what I found in {available_sources}" and answer from what is available.
|
||||
5. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
6. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
7. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Never send an empty query or a query with no parameters — extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
8. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
5. When the user asks for "next", "upcoming", "future", or "soon" calendar events/meetings/appointments, use `sources=["gcalendar"]` if gcalendar is connected, set `time_range={{"start": "{today}"}}`, and use `query=""` unless the user gave a specific topic such as "dentist" or "music lesson". This returns the nearest upcoming calendar items across calendars instead of keyword-matching only birthdays or event titles.
|
||||
6. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
7. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
8. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Only use an empty query when structured filters carry the request; never send a search with no concrete parameters. Extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
9. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
|
||||
Synthesis rules:
|
||||
- Cite sources as individual numbers in square brackets. Always separate — write [4] [7] [20], never [4, 7, 20]. Never format citations as markdown links. Just the number in brackets: [1]. The `ref` field on each hit is the citation number.
|
||||
|
||||
@@ -11,7 +11,9 @@ from rich.markdown import Markdown
|
||||
|
||||
from openjarvis.cli._tool_names import resolve_tool_names
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.memory import publish_completed_exchange
|
||||
|
||||
|
||||
def _read_input(prompt: str = "You> ") -> Optional[str]:
|
||||
@@ -57,6 +59,7 @@ def chat(
|
||||
console = Console(stderr=True)
|
||||
|
||||
config = load_config()
|
||||
bus = EventBus(record_history=False)
|
||||
|
||||
import dataclasses as _dc
|
||||
|
||||
@@ -97,12 +100,11 @@ def chat(
|
||||
if agent_key and agent_key != "none":
|
||||
try:
|
||||
import openjarvis.agents # noqa: F401 — trigger registration
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
if AgentRegistry.contains(agent_key):
|
||||
agent_cls = AgentRegistry.get(agent_key)
|
||||
kwargs: dict = {"bus": EventBus()}
|
||||
kwargs: dict = {"bus": bus}
|
||||
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
tool_names_list = resolve_tool_names(
|
||||
@@ -184,7 +186,7 @@ def chat(
|
||||
try:
|
||||
from openjarvis.memory import build_memory_service
|
||||
|
||||
memory_service = build_memory_service(config, engine, model)
|
||||
memory_service = build_memory_service(config, engine, model, event_bus=bus)
|
||||
if memory_service is not None:
|
||||
memory_service.start()
|
||||
console.print("[dim] Memory: active[/dim]")
|
||||
@@ -280,9 +282,12 @@ def chat(
|
||||
console.print(Markdown(content))
|
||||
console.print()
|
||||
|
||||
# Hand the exchange to the memory service (non-blocking).
|
||||
if memory_service is not None:
|
||||
memory_service.submit(user_input, content)
|
||||
publish_completed_exchange(
|
||||
bus,
|
||||
user_input,
|
||||
content,
|
||||
source="cli.chat",
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Generation interrupted.[/dim]")
|
||||
except Exception as exc:
|
||||
|
||||
@@ -498,7 +498,12 @@ def serve(
|
||||
try:
|
||||
from openjarvis.memory import build_memory_service
|
||||
|
||||
memory_service = build_memory_service(config, engine, model_name)
|
||||
memory_service = build_memory_service(
|
||||
config,
|
||||
engine,
|
||||
model_name,
|
||||
event_bus=bus,
|
||||
)
|
||||
if memory_service is not None:
|
||||
memory_service.start()
|
||||
console.print(" Memory svc: [cyan]active[/cyan]")
|
||||
|
||||
@@ -213,12 +213,13 @@ def _parse_event_timestamp(event: Dict[str, Any]) -> datetime:
|
||||
"""
|
||||
start = event.get("start", {})
|
||||
date_time_str: str = start.get("dateTime", "")
|
||||
if not date_time_str:
|
||||
date_str: str = start.get("date", "")
|
||||
if not date_time_str and not date_str:
|
||||
return datetime.now()
|
||||
try:
|
||||
# RFC3339 — Python 3.11+ fromisoformat handles the trailing 'Z'.
|
||||
# For older versions we replace 'Z' with '+00:00'.
|
||||
normalized = date_time_str.replace("Z", "+00:00")
|
||||
normalized = (date_time_str or date_str).replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized)
|
||||
except (ValueError, TypeError):
|
||||
return datetime.now()
|
||||
|
||||
@@ -20,8 +20,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
# numpy imported lazily inside _vector_recall (see embeddings.py) so importing
|
||||
@@ -32,6 +33,61 @@ from openjarvis.connectors.store import KnowledgeStore
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_UPCOMING_TERMS = {
|
||||
"next",
|
||||
"upcoming",
|
||||
"future",
|
||||
"forthcoming",
|
||||
"coming",
|
||||
"soon",
|
||||
}
|
||||
_CALENDAR_TERMS = {
|
||||
"calendar",
|
||||
"calendars",
|
||||
"event",
|
||||
"events",
|
||||
}
|
||||
_CALENDAR_REQUEST_TERMS = _CALENDAR_TERMS | {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_GCALENDAR_GENERIC_TERMS = _UPCOMING_TERMS | _CALENDAR_TERMS | {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_QUERY_STOPWORDS = {
|
||||
"a",
|
||||
"all",
|
||||
"am",
|
||||
"are",
|
||||
"do",
|
||||
"for",
|
||||
"have",
|
||||
"i",
|
||||
"in",
|
||||
"is",
|
||||
"list",
|
||||
"me",
|
||||
"my",
|
||||
"on",
|
||||
"s",
|
||||
"show",
|
||||
"tell",
|
||||
"the",
|
||||
"there",
|
||||
"to",
|
||||
"what",
|
||||
"whats",
|
||||
"when",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result types
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -120,6 +176,101 @@ def _snippet(content: str, max_chars: int = 500) -> str:
|
||||
return flat[:max_chars].rstrip() + "…"
|
||||
|
||||
|
||||
def _query_tokens(query: str) -> set[str]:
|
||||
return set(re.findall(r"[a-z0-9_]+", query.lower()))
|
||||
|
||||
|
||||
def _sources_include_gcalendar(sources: Optional[Sequence[str]]) -> bool:
|
||||
return any(str(source).lower() == "gcalendar" for source in sources or [])
|
||||
|
||||
|
||||
def _has_upcoming_calendar_intent(
|
||||
query: str,
|
||||
sources: Optional[Sequence[str]],
|
||||
) -> bool:
|
||||
tokens = _query_tokens(query)
|
||||
if not tokens or not (tokens & _UPCOMING_TERMS):
|
||||
return False
|
||||
if _sources_include_gcalendar(sources):
|
||||
return True
|
||||
if sources:
|
||||
return False
|
||||
return bool(tokens & _CALENDAR_REQUEST_TERMS)
|
||||
|
||||
|
||||
def _is_generic_calendar_timeline_query(query: str) -> bool:
|
||||
tokens = _query_tokens(query)
|
||||
if not tokens:
|
||||
return True
|
||||
topic_tokens = tokens - _GCALENDAR_GENERIC_TERMS - _QUERY_STOPWORDS
|
||||
return not topic_tokens
|
||||
|
||||
|
||||
def _start_is_nowish_or_future(start: Optional[datetime]) -> bool:
|
||||
if start is None:
|
||||
return False
|
||||
now = datetime.now(tz=start.tzinfo) if start.tzinfo else datetime.now()
|
||||
return start >= now - timedelta(days=1)
|
||||
|
||||
|
||||
def _start_of_day(ts: datetime) -> datetime:
|
||||
return ts.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _as_utc(ts: Optional[datetime]) -> Optional[datetime]:
|
||||
if ts is None:
|
||||
return None
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=timezone.utc)
|
||||
return ts.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _parse_timestamp_for_timeline(
|
||||
raw: Any,
|
||||
) -> Tuple[Optional[datetime], Optional[date]]:
|
||||
if raw is None:
|
||||
return None, None
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None, None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None, None
|
||||
is_naive_midnight = (
|
||||
parsed.tzinfo is None
|
||||
and parsed.hour == 0
|
||||
and parsed.minute == 0
|
||||
and parsed.second == 0
|
||||
and parsed.microsecond == 0
|
||||
)
|
||||
return _as_utc(parsed), parsed.date() if is_naive_midnight else None
|
||||
|
||||
|
||||
def _timestamp_in_range(
|
||||
timestamp: Optional[datetime],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
*,
|
||||
all_day_date: Optional[date] = None,
|
||||
) -> bool:
|
||||
if timestamp is None or time_range is None:
|
||||
return timestamp is not None
|
||||
start, end = time_range
|
||||
if all_day_date is not None:
|
||||
if start is not None and all_day_date < start.date():
|
||||
return False
|
||||
if end is not None and all_day_date > end.date():
|
||||
return False
|
||||
return True
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
if start_utc is not None and timestamp < start_utc:
|
||||
return False
|
||||
if end_utc is not None and timestamp > end_utc:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HybridSearch
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -377,6 +528,128 @@ class HybridSearch:
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def _normalise_calendar_timeline_scope(
|
||||
self,
|
||||
query: str,
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
sources: Optional[Sequence[str]],
|
||||
) -> Tuple[
|
||||
Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
Optional[Sequence[str]],
|
||||
bool,
|
||||
bool,
|
||||
]:
|
||||
"""Fill in structured filters for generic upcoming-calendar requests.
|
||||
|
||||
Queries like "what are my next calendar events?" often have no useful
|
||||
lexical terms in the stored event text, so BM25/vector ranking can miss
|
||||
nearby events. Treat that shape as a source-filtered timeline request.
|
||||
"""
|
||||
scoped_sources = list(sources) if sources else None
|
||||
has_upcoming_intent = _has_upcoming_calendar_intent(query, scoped_sources)
|
||||
|
||||
if has_upcoming_intent and (
|
||||
scoped_sources is None or _sources_include_gcalendar(scoped_sources)
|
||||
):
|
||||
scoped_sources = ["gcalendar"]
|
||||
|
||||
if not _sources_include_gcalendar(scoped_sources):
|
||||
return time_range, scoped_sources, False, False
|
||||
|
||||
if has_upcoming_intent:
|
||||
if time_range is None:
|
||||
time_range = (_start_of_day(datetime.now(timezone.utc)), None)
|
||||
else:
|
||||
start, end = time_range
|
||||
if start is None:
|
||||
time_range = (_start_of_day(datetime.now(timezone.utc)), end)
|
||||
else:
|
||||
time_range = (_start_of_day(start), end)
|
||||
|
||||
chronological = has_upcoming_intent or (
|
||||
time_range is not None
|
||||
and time_range[1] is None
|
||||
and _start_is_nowish_or_future(time_range[0])
|
||||
)
|
||||
metadata_only = chronological and _is_generic_calendar_timeline_query(query)
|
||||
return time_range, scoped_sources, chronological, metadata_only
|
||||
|
||||
def _calendar_timeline_ids(
|
||||
self,
|
||||
*,
|
||||
person: Optional[str],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
sources: Optional[Sequence[str]],
|
||||
limit: int,
|
||||
) -> List[str]:
|
||||
"""Return gcalendar rows sorted by normalized event start time."""
|
||||
filter_sql, filter_params = self._build_filters(
|
||||
person=person,
|
||||
time_range=None,
|
||||
sources=sources,
|
||||
)
|
||||
rows = self._store._conn.execute(
|
||||
f"""
|
||||
SELECT id, timestamp, created_at
|
||||
FROM knowledge_chunks
|
||||
WHERE {filter_sql}
|
||||
""",
|
||||
filter_params,
|
||||
).fetchall()
|
||||
|
||||
candidates: List[Tuple[str, datetime, float]] = []
|
||||
for row in rows:
|
||||
timestamp, all_day_date = _parse_timestamp_for_timeline(row["timestamp"])
|
||||
if not _timestamp_in_range(
|
||||
timestamp,
|
||||
time_range,
|
||||
all_day_date=all_day_date,
|
||||
):
|
||||
continue
|
||||
candidates.append(
|
||||
(
|
||||
row["id"],
|
||||
timestamp or datetime.max.replace(tzinfo=timezone.utc),
|
||||
float(row["created_at"] or 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda item: (item[1], item[2]))
|
||||
return [chunk_id for chunk_id, *_ in candidates[:limit]]
|
||||
|
||||
def _filter_calendar_timeline_fused(
|
||||
self,
|
||||
fused: List[Tuple[str, float, float, float]],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
) -> List[Tuple[str, float, float, float]]:
|
||||
"""Apply normalized timestamp filtering to ranked calendar candidates."""
|
||||
if not fused:
|
||||
return fused
|
||||
ids = [chunk_id for chunk_id, *_ in fused]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
rows = self._store._conn.execute(
|
||||
f"""
|
||||
SELECT id, timestamp
|
||||
FROM knowledge_chunks
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
ids,
|
||||
).fetchall()
|
||||
timestamps = {
|
||||
row["id"]: _parse_timestamp_for_timeline(row["timestamp"])
|
||||
for row in rows
|
||||
}
|
||||
|
||||
def _keeps_item(item: Tuple[str, float, float, float]) -> bool:
|
||||
timestamp, all_day_date = timestamps.get(item[0], (None, None))
|
||||
return _timestamp_in_range(
|
||||
timestamp,
|
||||
time_range,
|
||||
all_day_date=all_day_date,
|
||||
)
|
||||
|
||||
return [item for item in fused if _keeps_item(item)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
@@ -396,42 +669,65 @@ class HybridSearch:
|
||||
when callers want a pure metadata filter (e.g. "all mail from X in
|
||||
May") — in that case only the vector leg runs (and only if an
|
||||
embedder is configured); if neither leg yields anything the
|
||||
structured filter is applied directly and the most recent rows are
|
||||
returned.
|
||||
structured filter is applied directly. Upcoming calendar timelines are
|
||||
returned nearest-first; other fallbacks return the most recent rows.
|
||||
"""
|
||||
time_range, sources, chronological_order, metadata_only = (
|
||||
self._normalise_calendar_timeline_scope(query, time_range, sources)
|
||||
)
|
||||
rank_query = "" if metadata_only else query
|
||||
calendar_timeline = chronological_order and _sources_include_gcalendar(sources)
|
||||
recall_time_range = None if calendar_timeline else time_range
|
||||
|
||||
bm25_filter_sql, bm25_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources, alias="kc"
|
||||
person=person, time_range=recall_time_range, sources=sources, alias="kc"
|
||||
)
|
||||
unaliased_filter_sql, unaliased_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources
|
||||
person=person, time_range=recall_time_range, sources=sources
|
||||
)
|
||||
|
||||
bm25 = (
|
||||
self._bm25_recall(query, bm25_filter_sql, bm25_filter_params)
|
||||
if query.strip()
|
||||
self._bm25_recall(rank_query, bm25_filter_sql, bm25_filter_params)
|
||||
if rank_query.strip()
|
||||
else []
|
||||
)
|
||||
vector = (
|
||||
self._vector_recall(query, unaliased_filter_sql, unaliased_filter_params)
|
||||
if query.strip()
|
||||
self._vector_recall(
|
||||
rank_query,
|
||||
unaliased_filter_sql,
|
||||
unaliased_filter_params,
|
||||
)
|
||||
if rank_query.strip()
|
||||
else []
|
||||
)
|
||||
fused = self._fuse(bm25, vector)
|
||||
if calendar_timeline:
|
||||
fused = self._filter_calendar_timeline_fused(fused, time_range)
|
||||
|
||||
# Metadata-only fallback: empty query, or both legs produced nothing
|
||||
# despite a non-empty query. Return the most recent rows matching the
|
||||
# filter so the agent still gets a useful corpus snapshot.
|
||||
# despite a non-empty query. Calendar timeline requests use start-time
|
||||
# ascending; other searches use recency so the agent still gets a
|
||||
# useful corpus snapshot.
|
||||
if not fused:
|
||||
sql = f"""
|
||||
SELECT id FROM knowledge_chunks
|
||||
WHERE {unaliased_filter_sql}
|
||||
ORDER BY timestamp DESC, created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = self._store._conn.execute(
|
||||
sql, [*unaliased_filter_params, limit]
|
||||
).fetchall()
|
||||
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
|
||||
if calendar_timeline:
|
||||
chunk_ids = self._calendar_timeline_ids(
|
||||
person=person,
|
||||
time_range=time_range,
|
||||
sources=sources,
|
||||
limit=limit,
|
||||
)
|
||||
fused = [(chunk_id, 0.0, 0.0, 0.0) for chunk_id in chunk_ids]
|
||||
else:
|
||||
sql = f"""
|
||||
SELECT id FROM knowledge_chunks
|
||||
WHERE {unaliased_filter_sql}
|
||||
ORDER BY timestamp DESC, created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = self._store._conn.execute(
|
||||
sql, [*unaliased_filter_params, limit]
|
||||
).fetchall()
|
||||
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
|
||||
|
||||
# Materialise the top-N rows in one IN-clause round trip.
|
||||
top = fused[:limit]
|
||||
|
||||
@@ -593,6 +593,14 @@ class IntelligenceConfig:
|
||||
stop_sequences: str = "" # Comma-separated stop strings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeepResearchConfig:
|
||||
"""Planner settings for the web Deep Research endpoint."""
|
||||
|
||||
engine: str = "" # Empty means use the active chat engine.
|
||||
model: str = "" # Empty means use the active chat model.
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RoutingLearningConfig:
|
||||
"""Routing sub-policy config within Learning."""
|
||||
@@ -932,7 +940,9 @@ class StorageConfig:
|
||||
backend: str = "local" # fact-store backend ("local" = on-disk JSONL)
|
||||
extraction_model: str = "" # model for fact extraction ("" = active model)
|
||||
max_facts: int = 1000 # cap on stored facts (oldest evicted past the cap)
|
||||
facts_path: str = str(DEFAULT_CONFIG_DIR / "memory_facts.jsonl")
|
||||
facts_path: str = field(
|
||||
default_factory=lambda: str(get_config_dir() / "memory_facts.jsonl")
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatibility alias
|
||||
@@ -1576,6 +1586,7 @@ class JarvisConfig:
|
||||
hardware: HardwareInfo = field(default_factory=HardwareInfo)
|
||||
engine: EngineConfig = field(default_factory=EngineConfig)
|
||||
intelligence: IntelligenceConfig = field(default_factory=IntelligenceConfig)
|
||||
deep_research: DeepResearchConfig = field(default_factory=DeepResearchConfig)
|
||||
learning: LearningConfig = field(default_factory=LearningConfig)
|
||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||
agent: AgentConfig = field(default_factory=AgentConfig)
|
||||
@@ -1837,6 +1848,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
|
||||
top_sections = (
|
||||
"engine",
|
||||
"intelligence",
|
||||
"deep_research",
|
||||
"learning",
|
||||
"agent",
|
||||
"server",
|
||||
@@ -2005,6 +2017,10 @@ max_tokens = 1024
|
||||
# repetition_penalty = 1.0
|
||||
# stop_sequences = ""
|
||||
|
||||
# [deep_research]
|
||||
# engine = "" # empty = use [engine].default
|
||||
# model = "" # empty = use [intelligence].default_model
|
||||
|
||||
[agent]
|
||||
default_agent = "simple"
|
||||
max_turns = 10
|
||||
@@ -2175,6 +2191,7 @@ __all__ = [
|
||||
"DEFAULT_CONFIG_DIR",
|
||||
"DEFAULT_CONFIG_PATH",
|
||||
"DiscordChannelConfig",
|
||||
"DeepResearchConfig",
|
||||
"get_cache_dir",
|
||||
"get_config_dir",
|
||||
"get_config_path",
|
||||
|
||||
@@ -27,6 +27,7 @@ class EventType(str, Enum):
|
||||
TOOL_CALL_END = "tool_call_end"
|
||||
MEMORY_STORE = "memory_store"
|
||||
MEMORY_RETRIEVE = "memory_retrieve"
|
||||
CHAT_EXCHANGE_COMPLETED = "chat_exchange_completed"
|
||||
AGENT_TURN_START = "agent_turn_start"
|
||||
AGENT_TURN_END = "agent_turn_end"
|
||||
TELEMETRY_RECORD = "telemetry_record"
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, Tuple, Type, Typ
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.agents._stubs import BaseAgent
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
from openjarvis.memory.store import FactStore
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -109,6 +110,10 @@ class MemoryRegistry(RegistryBase[Type["MemoryBackend"]]):
|
||||
"""Registry for memory / retrieval backends."""
|
||||
|
||||
|
||||
class FactStoreRegistry(RegistryBase[Type["FactStore"]]):
|
||||
"""Registry for automatic-memory fact store backends."""
|
||||
|
||||
|
||||
class AgentRegistry(RegistryBase[Type["BaseAgent"]]):
|
||||
"""Registry for agent implementations."""
|
||||
|
||||
@@ -170,6 +175,7 @@ __all__ = [
|
||||
"CompressionRegistry",
|
||||
"ConnectorRegistry",
|
||||
"EngineRegistry",
|
||||
"FactStoreRegistry",
|
||||
"LearningRegistry",
|
||||
"MemoryRegistry",
|
||||
"MinerRegistry",
|
||||
|
||||
@@ -63,7 +63,7 @@ class Message:
|
||||
"""A single chat message (OpenAI-compatible structure)."""
|
||||
|
||||
role: Role
|
||||
content: str = ""
|
||||
content: str | None = ""
|
||||
name: Optional[str] = None
|
||||
tool_calls: Optional[List[ToolCall]] = None
|
||||
tool_call_id: Optional[str] = None
|
||||
@@ -73,6 +73,11 @@ class Message:
|
||||
# empty for text-only messages (the common case).
|
||||
images: Optional[List[str]] = None
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Return message content as text, treating ``None`` as empty."""
|
||||
return self.content or ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Conversation:
|
||||
|
||||
@@ -13,6 +13,22 @@ class EngineConnectionError(Exception):
|
||||
"""Raised when an engine is unreachable."""
|
||||
|
||||
|
||||
_REASONING_METADATA_KEYS = ("reasoning_content", "thinking")
|
||||
|
||||
|
||||
def _message_estimated_chars(message: Message) -> int:
|
||||
parts = [message.text]
|
||||
for key in _REASONING_METADATA_KEYS:
|
||||
value = message.metadata.get(key)
|
||||
if isinstance(value, str):
|
||||
parts.append(value)
|
||||
for tc in message.tool_calls or []:
|
||||
parts.extend((tc.id, tc.name, tc.arguments))
|
||||
if message.tool_call_id:
|
||||
parts.append(message.tool_call_id)
|
||||
return sum(len(part) for part in parts)
|
||||
|
||||
|
||||
def messages_to_dicts(messages: Sequence[Message]) -> List[Dict[str, Any]]:
|
||||
"""Convert ``Message`` objects to OpenAI-format dicts."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
@@ -53,9 +69,11 @@ def estimate_prompt_tokens(messages: Sequence[Message]) -> int:
|
||||
provider would charge.
|
||||
|
||||
Uses ~4 characters per token (standard BPE average for English) plus
|
||||
a small per-message overhead for role markers and separators.
|
||||
a small per-message overhead for role markers and separators. Counts
|
||||
content, reasoning metadata, tool-call payloads, and tool result IDs
|
||||
because all are replayed into later prompt turns when present.
|
||||
"""
|
||||
total_chars = sum(len(m.content) for m in messages)
|
||||
total_chars = sum(_message_estimated_chars(m) for m in messages)
|
||||
# ~4 tokens overhead per message for role markers / separators
|
||||
overhead = len(messages) * 4
|
||||
return max(1, total_chars // 4 + overhead)
|
||||
|
||||
@@ -9,7 +9,11 @@ and configured via the ``[memory]`` section of ``config.toml``.
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.memory.extractor import FactExtractor
|
||||
from openjarvis.memory.service import MemoryService, build_memory_service
|
||||
from openjarvis.memory.service import (
|
||||
MemoryService,
|
||||
build_memory_service,
|
||||
publish_completed_exchange,
|
||||
)
|
||||
from openjarvis.memory.store import (
|
||||
Fact,
|
||||
FactStore,
|
||||
@@ -25,4 +29,5 @@ __all__ = [
|
||||
"MemoryService",
|
||||
"build_memory_service",
|
||||
"create_fact_store",
|
||||
"publish_completed_exchange",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ import queue
|
||||
import threading
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.core.events import Event, EventBus, EventType
|
||||
from openjarvis.memory.extractor import FactExtractor
|
||||
from openjarvis.memory.store import Fact, FactStore, create_fact_store
|
||||
|
||||
@@ -37,10 +38,13 @@ class MemoryService:
|
||||
store: FactStore,
|
||||
extractor: FactExtractor,
|
||||
*,
|
||||
event_bus: EventBus | None = None,
|
||||
max_queue: int = 256,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._extractor = extractor
|
||||
self._event_bus = event_bus
|
||||
self._subscribed = False
|
||||
self._queue: "queue.Queue[Any]" = queue.Queue(maxsize=max(1, max_queue))
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._running = threading.Event()
|
||||
@@ -52,6 +56,7 @@ class MemoryService:
|
||||
if self._running.is_set():
|
||||
return
|
||||
self._running.set()
|
||||
self._subscribe_events()
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop,
|
||||
name="memory-service",
|
||||
@@ -73,6 +78,7 @@ class MemoryService:
|
||||
if thread is not None:
|
||||
thread.join(timeout=timeout)
|
||||
self._thread = None
|
||||
self._unsubscribe_events()
|
||||
logger.debug("Memory service stopped")
|
||||
|
||||
@property
|
||||
@@ -99,6 +105,34 @@ class MemoryService:
|
||||
logger.debug("Memory service queue full; dropping exchange")
|
||||
return False
|
||||
|
||||
def _subscribe_events(self) -> None:
|
||||
"""Subscribe to lifecycle events that feed automatic memory."""
|
||||
if self._event_bus is None or self._subscribed:
|
||||
return
|
||||
self._event_bus.subscribe(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
self._on_completed_exchange,
|
||||
)
|
||||
self._subscribed = True
|
||||
|
||||
def _unsubscribe_events(self) -> None:
|
||||
"""Unsubscribe from lifecycle events (idempotent)."""
|
||||
if self._event_bus is None or not self._subscribed:
|
||||
return
|
||||
self._event_bus.unsubscribe(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
self._on_completed_exchange,
|
||||
)
|
||||
self._subscribed = False
|
||||
|
||||
def _on_completed_exchange(self, event: Event) -> None:
|
||||
"""Queue a completed chat exchange published on the event bus."""
|
||||
data = event.data or {}
|
||||
self.submit(
|
||||
str(data.get("user_text", "") or ""),
|
||||
str(data.get("assistant_text", "") or ""),
|
||||
)
|
||||
|
||||
# -- worker -------------------------------------------------------------
|
||||
|
||||
def _loop(self) -> None:
|
||||
@@ -145,6 +179,8 @@ def build_memory_service(
|
||||
config: Any,
|
||||
engine: Any,
|
||||
default_model: str = "",
|
||||
*,
|
||||
event_bus: EventBus | None = None,
|
||||
) -> Optional[MemoryService]:
|
||||
"""Build a :class:`MemoryService` from config, or ``None`` if disabled.
|
||||
|
||||
@@ -170,11 +206,32 @@ def build_memory_service(
|
||||
|
||||
store = create_fact_store(
|
||||
getattr(mem, "backend", "local"),
|
||||
path=getattr(mem, "facts_path", "~/.openjarvis/memory_facts.jsonl"),
|
||||
path=getattr(mem, "facts_path", None),
|
||||
max_facts=getattr(mem, "max_facts", 1000),
|
||||
)
|
||||
extractor = FactExtractor(engine, model)
|
||||
return MemoryService(store, extractor)
|
||||
return MemoryService(store, extractor, event_bus=event_bus)
|
||||
|
||||
|
||||
__all__ = ["MemoryService", "build_memory_service"]
|
||||
def publish_completed_exchange(
|
||||
bus: EventBus | None,
|
||||
user_text: str,
|
||||
assistant_text: str = "",
|
||||
*,
|
||||
source: str = "",
|
||||
) -> bool:
|
||||
"""Publish a completed chat exchange for lifecycle subscribers."""
|
||||
if bus is None or not user_text or not user_text.strip():
|
||||
return False
|
||||
bus.publish(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
{
|
||||
"user_text": user_text,
|
||||
"assistant_text": assistant_text or "",
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["MemoryService", "build_memory_service", "publish_completed_exchange"]
|
||||
|
||||
@@ -18,6 +18,14 @@ from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import FactStoreRegistry
|
||||
|
||||
|
||||
def _default_fact_path() -> Path:
|
||||
"""Return the env-aware default JSONL path for automatic memory facts."""
|
||||
return get_config_dir() / "memory_facts.jsonl"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Fact:
|
||||
@@ -56,6 +64,7 @@ class FactStore(ABC):
|
||||
"""Return the number of stored facts."""
|
||||
|
||||
|
||||
@FactStoreRegistry.register("local")
|
||||
class LocalFactStore(FactStore):
|
||||
"""Append-only JSONL fact store on the local filesystem.
|
||||
|
||||
@@ -67,11 +76,13 @@ class LocalFactStore(FactStore):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
|
||||
path: str | Path | None = None,
|
||||
*,
|
||||
max_facts: int = 1000,
|
||||
) -> None:
|
||||
self._path = Path(path).expanduser()
|
||||
self._path = (
|
||||
Path(path).expanduser() if path is not None else _default_fact_path()
|
||||
)
|
||||
self._max_facts = max(0, int(max_facts))
|
||||
self._lock = threading.Lock()
|
||||
self._facts: List[Fact] = self._load()
|
||||
@@ -116,6 +127,10 @@ class LocalFactStore(FactStore):
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
os.replace(tmp, self._path)
|
||||
|
||||
def _sync_from_disk_locked(self) -> None:
|
||||
"""Refresh in-memory facts from disk while holding ``self._lock``."""
|
||||
self._facts = self._load()
|
||||
|
||||
# -- FactStore API ------------------------------------------------------
|
||||
|
||||
def add(self, text: str, source: str = "") -> bool:
|
||||
@@ -123,6 +138,7 @@ class LocalFactStore(FactStore):
|
||||
if not text:
|
||||
return False
|
||||
with self._lock:
|
||||
self._sync_from_disk_locked()
|
||||
lowered = text.lower()
|
||||
if any(f.text.lower() == lowered for f in self._facts):
|
||||
return False # dedupe
|
||||
@@ -135,10 +151,12 @@ class LocalFactStore(FactStore):
|
||||
|
||||
def list(self) -> List[Fact]:
|
||||
with self._lock:
|
||||
self._sync_from_disk_locked()
|
||||
return list(self._facts)
|
||||
|
||||
def clear(self) -> int:
|
||||
with self._lock:
|
||||
self._sync_from_disk_locked()
|
||||
removed = len(self._facts)
|
||||
self._facts = []
|
||||
if self._path.exists():
|
||||
@@ -150,6 +168,7 @@ class LocalFactStore(FactStore):
|
||||
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
self._sync_from_disk_locked()
|
||||
return len(self._facts)
|
||||
|
||||
@property
|
||||
@@ -158,22 +177,32 @@ class LocalFactStore(FactStore):
|
||||
return self._path
|
||||
|
||||
|
||||
def _ensure_fact_store_backends_registered() -> None:
|
||||
"""Restore built-in fact-store registrations if a test cleared registries."""
|
||||
if not FactStoreRegistry.contains("local"):
|
||||
FactStoreRegistry.register_value("local", LocalFactStore)
|
||||
|
||||
|
||||
def create_fact_store(
|
||||
backend: str = "local",
|
||||
*,
|
||||
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
|
||||
path: str | Path | None = None,
|
||||
max_facts: int = 1000,
|
||||
) -> FactStore:
|
||||
"""Construct a fact store for the configured *backend*.
|
||||
|
||||
Only the ``"local"`` (on-disk JSONL) backend is supported today; the
|
||||
factory exists so additional backends can be added without changing the
|
||||
service or CLI wiring.
|
||||
registry-backed constructor exists so additional backends can be added
|
||||
without changing the service or CLI wiring.
|
||||
"""
|
||||
_ensure_fact_store_backends_registered()
|
||||
key = (backend or "local").strip().lower()
|
||||
if key == "local":
|
||||
return LocalFactStore(path, max_facts=max_facts)
|
||||
raise ValueError(f"Unknown memory backend '{backend}'. Supported backends: local")
|
||||
if not FactStoreRegistry.contains(key):
|
||||
supported = ", ".join(FactStoreRegistry.keys())
|
||||
raise ValueError(
|
||||
f"Unknown memory backend '{backend}'. Supported backends: {supported}"
|
||||
)
|
||||
return FactStoreRegistry.create(key, path, max_facts=max_facts)
|
||||
|
||||
|
||||
__all__ = ["Fact", "FactStore", "LocalFactStore", "create_fact_store"]
|
||||
|
||||
@@ -2263,14 +2263,14 @@ def create_agent_manager_router(
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
"https://api.sendblue.co/api/lines",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
"https://api.sendblue.co/api/lines",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
@@ -2290,12 +2290,16 @@ def create_agent_manager_router(
|
||||
)
|
||||
numbers = []
|
||||
for line in lines:
|
||||
num = (
|
||||
line.get("number")
|
||||
or line.get("phone_number")
|
||||
or line.get("from_number")
|
||||
or (line if isinstance(line, str) else "")
|
||||
)
|
||||
if isinstance(line, str):
|
||||
num = line
|
||||
elif isinstance(line, dict):
|
||||
num = (
|
||||
line.get("number")
|
||||
or line.get("phone_number")
|
||||
or line.get("from_number")
|
||||
)
|
||||
else:
|
||||
num = None
|
||||
if num:
|
||||
numbers.append(num)
|
||||
return {
|
||||
@@ -2327,18 +2331,18 @@ def create_agent_manager_router(
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
"https://api.sendblue.co/api/account/webhooks",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"receive": webhook_url,
|
||||
},
|
||||
timeout=15.0,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.sendblue.co/api/account/webhooks",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"receive": webhook_url,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"registered": resp.status_code < 300,
|
||||
"status": resp.status_code,
|
||||
@@ -2378,16 +2382,16 @@ def create_agent_manager_router(
|
||||
if from_number:
|
||||
payload["from_number"] = from_number
|
||||
|
||||
resp = httpx.post(
|
||||
"https://api.sendblue.co/api/send-message",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=15.0,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.sendblue.co/api/send-message",
|
||||
headers={
|
||||
"sb-api-key-id": api_key_id,
|
||||
"sb-api-secret-key": api_secret_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
return {
|
||||
"sent": resp.status_code < 300,
|
||||
"status": resp.status_code,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
@@ -894,7 +895,12 @@ async def transcribe_speech(request: Request):
|
||||
ext = filename.rsplit(".", 1)[-1] if "." in filename else "wav"
|
||||
|
||||
try:
|
||||
result = backend.transcribe(audio_bytes, format=ext, language=language or None)
|
||||
result = await asyncio.to_thread(
|
||||
backend.transcribe,
|
||||
audio_bytes,
|
||||
format=ext,
|
||||
language=language or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Speech transcription failed")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Direct cloud API router — bypasses the engine system entirely.
|
||||
|
||||
Reads API keys from ~/.openjarvis/cloud-keys.env at request time so
|
||||
it works even when the server was started without cloud keys in its
|
||||
environment. Uses httpx directly so no cloud SDK packages are required.
|
||||
Reads API keys from the process environment, with a legacy
|
||||
~/.openjarvis/cloud-keys.env fallback for non-desktop/manual setups. Uses
|
||||
httpx directly so no cloud SDK packages are required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -38,7 +38,7 @@ _LOCAL_HF_ORGS = (
|
||||
|
||||
|
||||
def _load_keys() -> dict[str, str]:
|
||||
"""Read cloud-keys.env from disk every call so live updates are picked up."""
|
||||
"""Read available cloud keys every call so live updates are picked up."""
|
||||
keys: dict[str, str] = {}
|
||||
# File first, then fall back to process environment
|
||||
if _CLOUD_ENV_FILE.exists():
|
||||
|
||||
@@ -27,7 +27,7 @@ import threading
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -38,9 +38,10 @@ from openjarvis.agents.research_loop import (
|
||||
from openjarvis.connectors.embeddings import OllamaEmbedder
|
||||
from openjarvis.connectors.hybrid_search import HybridSearch
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR, JarvisConfig, load_config
|
||||
from openjarvis.core.types import TelemetryRecord
|
||||
from openjarvis.engine.ollama import OllamaEngine
|
||||
from openjarvis.engine._base import InferenceEngine
|
||||
from openjarvis.engine._discovery import get_engine
|
||||
from openjarvis.telemetry.store import TelemetryStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,13 +49,99 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["research"])
|
||||
|
||||
_WEB_CLARIFY_RESPONSE = "no clarification available in web session"
|
||||
_LEGACY_PLANNER_ENGINE = "ollama"
|
||||
|
||||
# Sentinel placed on the queue when the agent thread terminates.
|
||||
_DONE = object()
|
||||
|
||||
|
||||
def _first_nonempty(*values: str) -> str:
|
||||
for value in values:
|
||||
stripped = value.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_planner_config(
|
||||
config: JarvisConfig,
|
||||
*,
|
||||
active_engine_key: str = "",
|
||||
active_model: str = "",
|
||||
request_model: str = "",
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve the planner engine/model for web Deep Research.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. explicit ``[deep_research]`` overrides,
|
||||
2. the active chat engine/request model,
|
||||
3. server/config defaults,
|
||||
4. legacy Ollama/gemma4 fallback for unconfigured installs.
|
||||
"""
|
||||
engine_key = _first_nonempty(
|
||||
config.deep_research.engine,
|
||||
active_engine_key,
|
||||
config.engine.default,
|
||||
_LEGACY_PLANNER_ENGINE,
|
||||
)
|
||||
model = _first_nonempty(
|
||||
config.deep_research.model,
|
||||
request_model,
|
||||
active_model,
|
||||
config.server.model,
|
||||
config.intelligence.default_model,
|
||||
DEFAULT_PLANNER_MODEL,
|
||||
)
|
||||
return engine_key, model
|
||||
|
||||
|
||||
def _build_planner_engine(
|
||||
config: JarvisConfig,
|
||||
*,
|
||||
active_engine: InferenceEngine | None = None,
|
||||
active_engine_key: str = "",
|
||||
active_model: str = "",
|
||||
request_model: str = "",
|
||||
) -> tuple[str, InferenceEngine, str]:
|
||||
"""Instantiate the exact configured planner engine.
|
||||
|
||||
``get_engine`` intentionally falls back to any healthy engine for general
|
||||
chat routing. Deep Research must not do that here: if the configured chat
|
||||
engine is LM Studio but unavailable, silently falling back to Ollama would
|
||||
recreate the issue this endpoint is fixing.
|
||||
"""
|
||||
engine_key, model = _resolve_planner_config(
|
||||
config,
|
||||
active_engine_key=active_engine_key,
|
||||
active_model=active_model,
|
||||
request_model=request_model,
|
||||
)
|
||||
if active_engine is not None and not config.deep_research.engine.strip():
|
||||
if model and not active_engine.can_serve(model):
|
||||
raise RuntimeError(
|
||||
"Deep Research planner engine "
|
||||
f"{engine_key!r} cannot serve model {model!r}. "
|
||||
"Choose a compatible model or set [deep_research] engine/model "
|
||||
"in config.toml."
|
||||
)
|
||||
return engine_key, active_engine, model
|
||||
|
||||
resolved = get_engine(config, engine_key=engine_key, model=model)
|
||||
if resolved is None or resolved[0] != engine_key:
|
||||
raise RuntimeError(
|
||||
"Deep Research planner engine "
|
||||
f"{engine_key!r} is unavailable or cannot serve model {model!r}. "
|
||||
"Start the configured engine, load the configured model, or set "
|
||||
"[deep_research] engine/model in config.toml."
|
||||
)
|
||||
resolved_key, engine = resolved
|
||||
return resolved_key, engine, model
|
||||
|
||||
|
||||
def _record_research_telemetry(
|
||||
*,
|
||||
engine_key: str,
|
||||
model: str,
|
||||
usage: Dict[str, int],
|
||||
latency_seconds: float,
|
||||
@@ -86,7 +173,7 @@ def _record_research_telemetry(
|
||||
rec = TelemetryRecord(
|
||||
timestamp=time.time(),
|
||||
model_id=model,
|
||||
engine="ollama",
|
||||
engine=engine_key,
|
||||
agent="research",
|
||||
prompt_tokens=int(usage.get("prompt_tokens", 0)),
|
||||
prompt_tokens_evaluated=int(usage.get("prompt_tokens", 0)),
|
||||
@@ -244,12 +331,11 @@ class _LiveGPUSampler:
|
||||
|
||||
class ResearchRequest(BaseModel):
|
||||
query: str = Field(..., description="Natural-language question to research.")
|
||||
# Deep Research has its own model requirements (function-calling support,
|
||||
# sufficient reasoning capability) that the chat-model selector should not
|
||||
# override. We accept the field for forward-compat with older clients but
|
||||
# ignore it — the planner always runs on DEFAULT_PLANNER_MODEL.
|
||||
# Preferred planner model from the active chat selector. Server-side
|
||||
# [deep_research] config can still override it when a dedicated planner is
|
||||
# desired.
|
||||
model: Optional[str] = Field(
|
||||
default=None, description="Ignored; retained for client compatibility."
|
||||
default=None, description="Preferred planner model for this request."
|
||||
)
|
||||
|
||||
|
||||
@@ -290,7 +376,14 @@ def _chunk_synthesis(text: str, window_chars: int = 40) -> list[str]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
async def _stream_research(
|
||||
query: str,
|
||||
*,
|
||||
active_engine: InferenceEngine | None = None,
|
||||
active_engine_key: str = "",
|
||||
active_model: str = "",
|
||||
request_model: str = "",
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Drive ResearchAgent on a worker thread; yield SSE frames as they land.
|
||||
|
||||
Three error envelopes — setup, worker, consumer — all funnel into the
|
||||
@@ -298,7 +391,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
``{"type": "done", "usage": {...}}``. The client can rely on always
|
||||
seeing a ``done`` frame, even when the agent never started.
|
||||
"""
|
||||
# Phase 1: setup. Failures here (Ollama daemon down, DB locked, etc.)
|
||||
# Phase 1: setup. Failures here (planner engine down, DB locked, etc.)
|
||||
# yield error + done and return — nothing has been emitted yet so the
|
||||
# client gets a clean two-frame stream instead of a dangling connection.
|
||||
try:
|
||||
@@ -309,6 +402,15 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
# Called from the agent's worker thread; bounce onto the event loop.
|
||||
loop.call_soon_threadsafe(queue.put_nowait, event)
|
||||
|
||||
config = load_config()
|
||||
engine_key, engine, model = _build_planner_engine(
|
||||
config,
|
||||
active_engine=active_engine,
|
||||
active_engine_key=active_engine_key,
|
||||
active_model=active_model,
|
||||
request_model=request_model,
|
||||
)
|
||||
|
||||
# Each request gets its own thin set of connectors. Constructing them
|
||||
# is cheap (SQLite open + HTTP keepalive) and avoids state leaks
|
||||
# between concurrent requests.
|
||||
@@ -320,7 +422,6 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
)
|
||||
embedder = None
|
||||
|
||||
engine = OllamaEngine()
|
||||
agent = ResearchAgent(
|
||||
engine=engine,
|
||||
search=HybridSearch(store, embedder),
|
||||
@@ -367,6 +468,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
# rolls research into the same Power/Energy numbers as chat —
|
||||
# this is what the launch-video System panel reads.
|
||||
_record_research_telemetry(
|
||||
engine_key=engine_key,
|
||||
model=model,
|
||||
usage=usage_dict,
|
||||
latency_seconds=time.time() - t0,
|
||||
@@ -472,7 +574,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
|
||||
|
||||
|
||||
@router.post("/research")
|
||||
async def research(req: ResearchRequest) -> StreamingResponse:
|
||||
async def research(req: ResearchRequest, request: Request) -> StreamingResponse:
|
||||
"""Run a research query and stream the agent's trace + synthesis via SSE.
|
||||
|
||||
Response is ``text/event-stream`` with one JSON event per frame. See the
|
||||
@@ -480,14 +582,19 @@ async def research(req: ResearchRequest) -> StreamingResponse:
|
||||
terminates the stream so clients can detect end-of-response without
|
||||
parsing the underlying ``[DONE]`` sentinel used by OpenAI-style routes.
|
||||
"""
|
||||
if req.model and req.model != DEFAULT_PLANNER_MODEL:
|
||||
logger.info(
|
||||
"research: ignoring client model=%r; using DEFAULT_PLANNER_MODEL=%r",
|
||||
req.model,
|
||||
DEFAULT_PLANNER_MODEL,
|
||||
)
|
||||
active_engine = getattr(request.app.state, "engine", None)
|
||||
active_model = str(getattr(request.app.state, "model", "") or "")
|
||||
active_engine_key = str(getattr(request.app.state, "engine_name", "") or "")
|
||||
if active_engine is not None and not active_engine_key:
|
||||
active_engine_key = str(getattr(active_engine, "engine_id", "") or "")
|
||||
return StreamingResponse(
|
||||
_stream_research(req.query, DEFAULT_PLANNER_MODEL),
|
||||
_stream_research(
|
||||
req.query,
|
||||
active_engine=active_engine,
|
||||
active_engine_key=active_engine_key,
|
||||
active_model=active_model,
|
||||
request_model=req.model or "",
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
|
||||
+128
-33
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
@@ -195,7 +196,13 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
# from the engine for true real-time output.
|
||||
if request_body.tools:
|
||||
return await _handle_stream_tools(
|
||||
engine, model, request_body, complexity_info, app_config=config
|
||||
engine,
|
||||
model,
|
||||
request_body,
|
||||
complexity_info,
|
||||
app_config=config,
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
memory_service=getattr(request.app.state, "memory_service", None),
|
||||
)
|
||||
return await _handle_stream(
|
||||
engine,
|
||||
@@ -204,6 +211,8 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
complexity_info,
|
||||
trace_store=getattr(request.app.state, "trace_store", None),
|
||||
app_config=config,
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
memory_service=getattr(request.app.state, "memory_service", None),
|
||||
)
|
||||
|
||||
# Non-streaming: use agent if available, otherwise direct engine call.
|
||||
@@ -247,20 +256,44 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
getattr(request.app.state, "memory_service", None),
|
||||
query_text_for_complexity,
|
||||
response,
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
source="server.chat",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _remember_exchange(memory_service, user_text: str, response) -> None:
|
||||
"""Submit a completed exchange to the memory service (non-blocking)."""
|
||||
if memory_service is None or not user_text:
|
||||
def _response_content(response) -> str:
|
||||
"""Extract assistant text from an OpenAI-compatible response object."""
|
||||
content = ""
|
||||
choices = getattr(response, "choices", None)
|
||||
if choices:
|
||||
content = getattr(choices[0].message, "content", "") or ""
|
||||
return content
|
||||
|
||||
|
||||
def _record_completed_exchange(
|
||||
memory_service,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
*,
|
||||
bus=None,
|
||||
source: str = "server.chat",
|
||||
) -> None:
|
||||
"""Publish or submit a completed exchange without blocking a reply."""
|
||||
if not user_text:
|
||||
return
|
||||
try:
|
||||
content = ""
|
||||
choices = getattr(response, "choices", None)
|
||||
if choices:
|
||||
content = getattr(choices[0].message, "content", "") or ""
|
||||
memory_service.submit(user_text, content)
|
||||
if bus is not None:
|
||||
from openjarvis.memory import publish_completed_exchange
|
||||
|
||||
publish_completed_exchange(
|
||||
bus,
|
||||
user_text,
|
||||
assistant_text,
|
||||
source=source,
|
||||
)
|
||||
elif memory_service is not None:
|
||||
memory_service.submit(user_text, assistant_text)
|
||||
except Exception: # noqa: BLE001 — memory is best-effort, never fail a reply
|
||||
logging.getLogger("openjarvis.server").debug(
|
||||
"Memory submit failed",
|
||||
@@ -268,6 +301,24 @@ def _remember_exchange(memory_service, user_text: str, response) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _remember_exchange(
|
||||
memory_service,
|
||||
user_text: str,
|
||||
response,
|
||||
*,
|
||||
bus=None,
|
||||
source: str = "server.chat",
|
||||
) -> None:
|
||||
"""Record a completed non-streaming exchange."""
|
||||
_record_completed_exchange(
|
||||
memory_service,
|
||||
user_text,
|
||||
_response_content(response),
|
||||
bus=bus,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _handle_direct(
|
||||
engine,
|
||||
model: str,
|
||||
@@ -457,6 +508,8 @@ async def _handle_stream_tools(
|
||||
complexity_info=None,
|
||||
*,
|
||||
app_config=None,
|
||||
bus=None,
|
||||
memory_service=None,
|
||||
):
|
||||
"""Stream a raw OpenAI-compat function-calling response via SSE.
|
||||
|
||||
@@ -477,8 +530,14 @@ async def _handle_stream_tools(
|
||||
messages = _ensure_identity_prompt(messages, app_config)
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||
use_cloud = is_cloud_model(model)
|
||||
query_text = ""
|
||||
for _m in reversed(req.messages):
|
||||
if _m.role == "user" and _m.content:
|
||||
query_text = _m.content
|
||||
break
|
||||
|
||||
async def generate():
|
||||
full_content = ""
|
||||
# Send the role chunk first (OpenAI convention).
|
||||
first_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
@@ -497,6 +556,7 @@ async def _handle_stream_tools(
|
||||
tools=req.tools,
|
||||
):
|
||||
if sc.content:
|
||||
full_content += sc.content
|
||||
content_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
@@ -553,6 +613,14 @@ async def _handle_stream_tools(
|
||||
if complexity_info is not None:
|
||||
finish_dict["complexity"] = complexity_info.model_dump()
|
||||
yield f"data: {_json.dumps(finish_dict)}\n\n"
|
||||
if full_content:
|
||||
_record_completed_exchange(
|
||||
memory_service,
|
||||
query_text,
|
||||
full_content,
|
||||
bus=bus,
|
||||
source="server.chat.stream",
|
||||
)
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
@@ -570,6 +638,8 @@ async def _handle_stream(
|
||||
*,
|
||||
trace_store=None,
|
||||
app_config=None,
|
||||
bus=None,
|
||||
memory_service=None,
|
||||
):
|
||||
"""Stream response using SSE format.
|
||||
|
||||
@@ -710,6 +780,15 @@ async def _handle_stream(
|
||||
ended_at=time.time(),
|
||||
)
|
||||
|
||||
if full_content:
|
||||
_record_completed_exchange(
|
||||
memory_service,
|
||||
query_text,
|
||||
full_content,
|
||||
bus=bus,
|
||||
source="server.chat.stream",
|
||||
)
|
||||
|
||||
# Send finish chunk with usage data if available
|
||||
import json as _json
|
||||
|
||||
@@ -757,7 +836,7 @@ async def list_models(request: Request) -> ModelListResponse:
|
||||
# Filter out any cloud model IDs that may appear via MultiEngine.
|
||||
# Fall back to direct Ollama query only when the engine returns nothing.
|
||||
engine = request.app.state.engine
|
||||
all_ids = engine.list_models()
|
||||
all_ids = await asyncio.to_thread(engine.list_models)
|
||||
model_ids = [m for m in all_ids if not is_cloud_model(m)]
|
||||
if not model_ids:
|
||||
model_ids = await list_local_models()
|
||||
@@ -787,12 +866,12 @@ async def pull_model(request: Request):
|
||||
import httpx as _httpx
|
||||
|
||||
host = getattr(engine, "_host", "http://localhost:11434")
|
||||
client = _httpx.Client(base_url=host, timeout=600.0)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/pull",
|
||||
json={"name": model_name, "stream": False},
|
||||
)
|
||||
async with _httpx.AsyncClient(base_url=host, timeout=600.0) as client:
|
||||
resp = await client.post(
|
||||
"/api/pull",
|
||||
json={"name": model_name, "stream": False},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except (_httpx.ConnectError, _httpx.TimeoutException) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {exc}")
|
||||
@@ -801,8 +880,6 @@ async def pull_model(request: Request):
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Ollama error: {exc.response.text[:300]}",
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
return {"status": "ok", "model": model_name}
|
||||
|
||||
@@ -818,13 +895,13 @@ async def delete_model(model_name: str, request: Request):
|
||||
import httpx as _httpx
|
||||
|
||||
host = getattr(engine, "_host", "http://localhost:11434")
|
||||
client = _httpx.Client(base_url=host, timeout=30.0)
|
||||
try:
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
"/api/delete",
|
||||
json={"name": model_name},
|
||||
)
|
||||
async with _httpx.AsyncClient(base_url=host, timeout=30.0) as client:
|
||||
resp = await client.request(
|
||||
"DELETE",
|
||||
"/api/delete",
|
||||
json={"name": model_name},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except (_httpx.ConnectError, _httpx.TimeoutException) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama unreachable: {exc}")
|
||||
@@ -833,8 +910,6 @@ async def delete_model(model_name: str, request: Request):
|
||||
status_code=exc.response.status_code,
|
||||
detail=f"Ollama error: {exc.response.text[:300]}",
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
return {"status": "deleted", "model": model_name}
|
||||
|
||||
@@ -848,14 +923,34 @@ async def reload_cloud_engine(request: Request):
|
||||
"""
|
||||
import os
|
||||
|
||||
# Re-read ~/.openjarvis/cloud-keys.env and update the running process env.
|
||||
keys_path = get_config_dir() / "cloud-keys.env"
|
||||
if keys_path.exists():
|
||||
for raw_line in keys_path.read_text().splitlines():
|
||||
line = raw_line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
os.environ[k.strip()] = v.strip()
|
||||
submitted_keys: dict[str, str] | None = None
|
||||
try:
|
||||
body = await request.json()
|
||||
raw_keys = body.get("keys") if isinstance(body, dict) else None
|
||||
if isinstance(raw_keys, dict):
|
||||
submitted_keys = {
|
||||
str(k): str(v)
|
||||
for k, v in raw_keys.items()
|
||||
if str(k).endswith("_API_KEY")
|
||||
}
|
||||
except Exception:
|
||||
submitted_keys = None
|
||||
|
||||
if submitted_keys is not None:
|
||||
for key, value in submitted_keys.items():
|
||||
if value:
|
||||
os.environ[key] = value
|
||||
else:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
# Compatibility fallback for non-desktop/manual configurations.
|
||||
keys_path = get_config_dir() / "cloud-keys.env"
|
||||
if keys_path.exists():
|
||||
for raw_line in keys_path.read_text().splitlines():
|
||||
line = raw_line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
os.environ[k.strip()] = v.strip()
|
||||
|
||||
# Try to build a fresh CloudEngine.
|
||||
try:
|
||||
|
||||
@@ -90,6 +90,9 @@ class TelemetryAggregator:
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self._db_path = str(db_path)
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
|
||||
def _time_filter(
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -148,6 +149,10 @@ class TelemetryStore:
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self._db_path = str(db_path)
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._lock = threading.Lock()
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._conn.execute(_CREATE_TABLE)
|
||||
self._conn.execute(_CREATE_MINING_STATS_TABLE)
|
||||
self._conn.commit()
|
||||
@@ -166,53 +171,54 @@ class TelemetryStore:
|
||||
|
||||
def record(self, rec: TelemetryRecord) -> None:
|
||||
"""Persist a single telemetry record."""
|
||||
self._conn.execute(
|
||||
_INSERT,
|
||||
(
|
||||
rec.timestamp,
|
||||
rec.model_id,
|
||||
rec.engine,
|
||||
rec.agent,
|
||||
rec.prompt_tokens,
|
||||
rec.prompt_tokens_evaluated,
|
||||
rec.completion_tokens,
|
||||
rec.total_tokens,
|
||||
rec.latency_seconds,
|
||||
rec.ttft,
|
||||
rec.cost_usd,
|
||||
rec.energy_joules,
|
||||
rec.power_watts,
|
||||
rec.gpu_utilization_pct,
|
||||
rec.gpu_memory_used_gb,
|
||||
rec.gpu_temperature_c,
|
||||
rec.throughput_tok_per_sec,
|
||||
rec.prefill_latency_seconds,
|
||||
rec.decode_latency_seconds,
|
||||
rec.energy_method,
|
||||
rec.energy_vendor,
|
||||
rec.batch_id,
|
||||
1 if rec.is_warmup else 0,
|
||||
rec.cpu_energy_joules,
|
||||
rec.gpu_energy_joules,
|
||||
rec.dram_energy_joules,
|
||||
rec.tokens_per_joule,
|
||||
rec.energy_per_output_token_joules,
|
||||
rec.throughput_per_watt,
|
||||
rec.prefill_energy_joules,
|
||||
rec.decode_energy_joules,
|
||||
rec.mean_itl_ms,
|
||||
rec.median_itl_ms,
|
||||
rec.p90_itl_ms,
|
||||
rec.p95_itl_ms,
|
||||
rec.p99_itl_ms,
|
||||
rec.std_itl_ms,
|
||||
1 if rec.is_streaming else 0,
|
||||
rec.token_counting_version,
|
||||
rec.mining_session_id,
|
||||
json.dumps(rec.metadata),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
_INSERT,
|
||||
(
|
||||
rec.timestamp,
|
||||
rec.model_id,
|
||||
rec.engine,
|
||||
rec.agent,
|
||||
rec.prompt_tokens,
|
||||
rec.prompt_tokens_evaluated,
|
||||
rec.completion_tokens,
|
||||
rec.total_tokens,
|
||||
rec.latency_seconds,
|
||||
rec.ttft,
|
||||
rec.cost_usd,
|
||||
rec.energy_joules,
|
||||
rec.power_watts,
|
||||
rec.gpu_utilization_pct,
|
||||
rec.gpu_memory_used_gb,
|
||||
rec.gpu_temperature_c,
|
||||
rec.throughput_tok_per_sec,
|
||||
rec.prefill_latency_seconds,
|
||||
rec.decode_latency_seconds,
|
||||
rec.energy_method,
|
||||
rec.energy_vendor,
|
||||
rec.batch_id,
|
||||
1 if rec.is_warmup else 0,
|
||||
rec.cpu_energy_joules,
|
||||
rec.gpu_energy_joules,
|
||||
rec.dram_energy_joules,
|
||||
rec.tokens_per_joule,
|
||||
rec.energy_per_output_token_joules,
|
||||
rec.throughput_per_watt,
|
||||
rec.prefill_energy_joules,
|
||||
rec.decode_energy_joules,
|
||||
rec.mean_itl_ms,
|
||||
rec.median_itl_ms,
|
||||
rec.p90_itl_ms,
|
||||
rec.p95_itl_ms,
|
||||
rec.p99_itl_ms,
|
||||
rec.std_itl_ms,
|
||||
1 if rec.is_streaming else 0,
|
||||
rec.token_counting_version,
|
||||
rec.mining_session_id,
|
||||
json.dumps(rec.metadata),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def record_mining_stats(self, stats: Any) -> None:
|
||||
"""Persist one mining stats snapshot.
|
||||
@@ -220,28 +226,29 @@ class TelemetryStore:
|
||||
``stats`` is duck-typed to keep telemetry usable without importing the
|
||||
optional mining package at module import time.
|
||||
"""
|
||||
self._conn.execute(
|
||||
"""\
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"""\
|
||||
INSERT INTO mining_stats (
|
||||
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
|
||||
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
time.time(),
|
||||
stats.provider_id,
|
||||
stats.shares_submitted,
|
||||
stats.shares_accepted,
|
||||
stats.blocks_found,
|
||||
stats.hashrate,
|
||||
stats.uptime_seconds,
|
||||
stats.last_share_at,
|
||||
stats.last_error,
|
||||
stats.payout_target,
|
||||
stats.fees_owed,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
(
|
||||
time.time(),
|
||||
stats.provider_id,
|
||||
stats.shares_submitted,
|
||||
stats.shares_accepted,
|
||||
stats.blocks_found,
|
||||
stats.hashrate,
|
||||
stats.uptime_seconds,
|
||||
stats.last_share_at,
|
||||
stats.last_error,
|
||||
stats.payout_target,
|
||||
stats.fees_owed,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
"""Return recent telemetry rows as dictionaries."""
|
||||
|
||||
@@ -8,7 +8,7 @@ from openjarvis.agents._stubs import AgentContext
|
||||
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Conversation, Message, Role, ToolResult
|
||||
from openjarvis.core.types import Conversation, Message, Role, ToolCall, ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -118,6 +118,51 @@ class TestNativeOpenHandsRegistration:
|
||||
|
||||
|
||||
class TestNativeOpenHandsAgent:
|
||||
def test_truncate_handles_none_content_tool_call_turn(self):
|
||||
"""Tool-call assistant turns may carry content=None."""
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
agent = NativeOpenHandsAgent(engine, "test-model")
|
||||
messages = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None, # type: ignore[arg-type]
|
||||
tool_calls=[ToolCall(id="call_1", name="calculator", arguments="{}")],
|
||||
),
|
||||
]
|
||||
|
||||
assert agent._truncate_if_needed(messages) == messages
|
||||
|
||||
def test_native_tool_call_with_none_content_does_not_crash(self):
|
||||
"""Native tool-call responses may omit assistant text content."""
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
None,
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_1",
|
||||
"name": "calculator",
|
||||
"arguments": '{"expression": "2+2"}',
|
||||
}
|
||||
],
|
||||
),
|
||||
_engine_response("The result is 4."),
|
||||
]
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
|
||||
result = agent.run("What is 2+2?")
|
||||
|
||||
assert result.content == "The result is 4."
|
||||
assert result.turns == 2
|
||||
assert [tr.content for tr in result.tool_results] == ["4"]
|
||||
|
||||
def test_simple_response(self):
|
||||
"""No code -> direct answer."""
|
||||
engine = MagicMock()
|
||||
|
||||
@@ -395,6 +395,13 @@ def test_system_prompt_mandates_sources_extraction() -> None:
|
||||
assert "{available_sources}" in SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_routes_upcoming_calendar_as_structured_search() -> None:
|
||||
"""Upcoming calendar requests need source/time filters, not just keywords."""
|
||||
assert 'sources=["gcalendar"]' in SYSTEM_PROMPT
|
||||
assert 'time_range={{"start": "{today}"}}' in SYSTEM_PROMPT
|
||||
assert 'query=""' in SYSTEM_PROMPT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic available_sources — only list what the user actually has connected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,7 @@ from openjarvis.agents._stubs import (
|
||||
)
|
||||
from openjarvis.cli.chat_cmd import _read_input, chat
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.events import Event, EventBus, EventType
|
||||
from openjarvis.core.registry import AgentRegistry, ToolRegistry
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
@@ -121,25 +122,45 @@ class TestChatAgents:
|
||||
assert "failed" not in result.output.lower()
|
||||
|
||||
def test_memory_service_started_fed_and_stopped(self) -> None:
|
||||
"""The REPL starts the memory service, submits each turn, and stops it."""
|
||||
"""The REPL starts memory, publishes each turn, and stops it."""
|
||||
|
||||
class _SpyMemoryService:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, bus: EventBus) -> None:
|
||||
self.bus = bus
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.submissions: list[tuple[str, str]] = []
|
||||
|
||||
def start(self) -> None:
|
||||
self.started = True
|
||||
self.bus.subscribe(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
self._on_completed_exchange,
|
||||
)
|
||||
|
||||
def submit(self, user_text: str, assistant_text: str = "") -> bool:
|
||||
self.submissions.append((user_text, assistant_text))
|
||||
return True
|
||||
def _on_completed_exchange(self, event: Event) -> None:
|
||||
self.submissions.append(
|
||||
(
|
||||
event.data["user_text"],
|
||||
event.data.get("assistant_text", ""),
|
||||
)
|
||||
)
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
self.stopped = True
|
||||
self.bus.unsubscribe(
|
||||
EventType.CHAT_EXCHANGE_COMPLETED,
|
||||
self._on_completed_exchange,
|
||||
)
|
||||
|
||||
spy: _SpyMemoryService | None = None
|
||||
|
||||
def _build_memory_service(*args, event_bus: EventBus | None = None, **kwargs):
|
||||
nonlocal spy
|
||||
assert event_bus is not None
|
||||
spy = _SpyMemoryService(event_bus)
|
||||
return spy
|
||||
|
||||
spy = _SpyMemoryService()
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = {"content": "engine fallback"}
|
||||
@@ -154,7 +175,7 @@ class TestChatAgents:
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
patch(
|
||||
"openjarvis.memory.build_memory_service",
|
||||
return_value=spy,
|
||||
side_effect=_build_memory_service,
|
||||
),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
@@ -164,6 +185,7 @@ class TestChatAgents:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert spy is not None
|
||||
assert spy.started is True
|
||||
assert spy.stopped is True
|
||||
assert spy.submissions == [("hello", "simple ok")]
|
||||
|
||||
@@ -18,6 +18,7 @@ from openjarvis.core.registry import (
|
||||
CompressionRegistry,
|
||||
ConnectorRegistry,
|
||||
EngineRegistry,
|
||||
FactStoreRegistry,
|
||||
MemoryRegistry,
|
||||
MinerRegistry,
|
||||
ModelRegistry,
|
||||
@@ -35,6 +36,7 @@ def _clean_registries() -> None:
|
||||
ModelRegistry.clear()
|
||||
EngineRegistry.clear()
|
||||
MemoryRegistry.clear()
|
||||
FactStoreRegistry.clear()
|
||||
MinerRegistry.clear()
|
||||
AgentRegistry.clear()
|
||||
ToolRegistry.clear()
|
||||
|
||||
@@ -6,6 +6,7 @@ All Calendar API calls are mocked; no network access is required.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from unittest.mock import patch
|
||||
@@ -134,6 +135,15 @@ def test_sync_yields_events(
|
||||
mock_events.assert_called_once()
|
||||
|
||||
|
||||
def test_parse_event_timestamp_handles_all_day_events() -> None:
|
||||
"""All-day events use their calendar date, not the current wall clock."""
|
||||
from openjarvis.connectors.gcalendar import _parse_event_timestamp # noqa: PLC0415
|
||||
|
||||
timestamp = _parse_event_timestamp({"start": {"date": "2024-05-26"}})
|
||||
|
||||
assert timestamp == datetime(2024, 5, 26)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4 — disconnect removes the credentials file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for source-aware HybridSearch behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from openjarvis.connectors.hybrid_search import HybridSearch
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
|
||||
|
||||
def _store_doc(
|
||||
store: KnowledgeStore,
|
||||
*,
|
||||
title: str,
|
||||
source: str,
|
||||
timestamp: datetime | str,
|
||||
) -> None:
|
||||
timestamp_text = (
|
||||
timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp
|
||||
)
|
||||
store.store(
|
||||
content=f"Title: {title}\nWhen: {timestamp_text}",
|
||||
source=source,
|
||||
doc_type="event" if source == "gcalendar" else "email",
|
||||
doc_id=f"{source}:{title.lower().replace(' ', '-')}",
|
||||
title=title,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def test_next_calendar_events_returns_nearest_gcalendar_rows() -> None:
|
||||
"""Generic upcoming-calendar queries should be chronological timelines."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="Calendar Digest Email",
|
||||
source="gmail",
|
||||
timestamp=datetime(2999, 1, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Birthday Reminder",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 12, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Music Lesson",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 5, 26, 18, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Team Sync",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 5, 27, 10, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
search = HybridSearch(store)
|
||||
hits = search.search("what are my next calendar events?", limit=2)
|
||||
contraction_hits = search.search("what's next on my calendar?", limit=2)
|
||||
meetings_hits = search.search("what are my next meetings?", limit=2)
|
||||
mixed_source_hits = search.search(
|
||||
"what are my next calendar events?",
|
||||
sources=["gmail", "gcalendar"],
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in hits)
|
||||
assert [hit.title for hit in contraction_hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in contraction_hits)
|
||||
assert [hit.title for hit in meetings_hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in meetings_hits)
|
||||
assert [hit.title for hit in mixed_source_hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in mixed_source_hits)
|
||||
|
||||
|
||||
def test_empty_upcoming_calendar_filter_uses_ascending_start_time() -> None:
|
||||
"""Planner-emitted structured calendar searches return nearest first."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="Later Event",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 8, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Sooner Event",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 7, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
hits = HybridSearch(store).search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 1, 1, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["Sooner Event", "Later Event"]
|
||||
|
||||
|
||||
def test_upcoming_calendar_timeline_normalizes_timestamp_offsets() -> None:
|
||||
"""Timeline filtering and ordering should compare instants, not ISO text."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="Offset Earlier",
|
||||
source="gcalendar",
|
||||
timestamp="2999-07-01T00:30:00+02:00",
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="UTC Later",
|
||||
source="gcalendar",
|
||||
timestamp="2999-06-30T23:15:00+00:00",
|
||||
)
|
||||
|
||||
search = HybridSearch(store)
|
||||
hits = search.search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 6, 30, 22, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
later_hits = search.search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 6, 30, 23, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["Offset Earlier", "UTC Later"]
|
||||
assert [hit.title for hit in later_hits] == ["UTC Later"]
|
||||
|
||||
|
||||
def test_upcoming_calendar_includes_today_all_day_events() -> None:
|
||||
"""Upcoming calendar intent starts at the day boundary for all-day events."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="All Day Today",
|
||||
source="gcalendar",
|
||||
timestamp="2999-07-01T00:00:00",
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Morning Tomorrow",
|
||||
source="gcalendar",
|
||||
timestamp="2999-07-02T09:00:00+00:00",
|
||||
)
|
||||
|
||||
hits = HybridSearch(store).search(
|
||||
"next calendar events",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 7, 1, 12, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
local_tz_hits = HybridSearch(store).search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(
|
||||
datetime(
|
||||
2999,
|
||||
7,
|
||||
1,
|
||||
12,
|
||||
tzinfo=timezone(timedelta(hours=-7)),
|
||||
),
|
||||
None,
|
||||
),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["All Day Today", "Morning Tomorrow"]
|
||||
assert [hit.title for hit in local_tz_hits] == [
|
||||
"All Day Today",
|
||||
"Morning Tomorrow",
|
||||
]
|
||||
@@ -48,6 +48,7 @@ class TestConfigPhase5:
|
||||
with pytest.raises(KeyError):
|
||||
ModelRegistry.get("iso-test")
|
||||
|
||||
def test_load_config_default(self):
|
||||
cfg = load_config()
|
||||
def test_load_config_default(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
cfg = load_config(tmp_path / "missing-config.toml")
|
||||
assert isinstance(cfg, JarvisConfig)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for Deep Research planner configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.config import (
|
||||
DeepResearchConfig,
|
||||
HardwareInfo,
|
||||
JarvisConfig,
|
||||
generate_default_toml,
|
||||
load_config,
|
||||
validate_config_key,
|
||||
)
|
||||
|
||||
|
||||
def test_deep_research_config_defaults_to_chat_selection() -> None:
|
||||
cfg = JarvisConfig()
|
||||
|
||||
assert isinstance(cfg.deep_research, DeepResearchConfig)
|
||||
assert cfg.deep_research.engine == ""
|
||||
assert cfg.deep_research.model == ""
|
||||
|
||||
|
||||
def test_loads_deep_research_overrides(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[deep_research]",
|
||||
'engine = "lmstudio"',
|
||||
'model = "qwen/qwen3-14b"',
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
cfg = load_config(config_file)
|
||||
|
||||
assert cfg.deep_research.engine == "lmstudio"
|
||||
assert cfg.deep_research.model == "qwen/qwen3-14b"
|
||||
|
||||
|
||||
def test_deep_research_keys_are_settable() -> None:
|
||||
assert validate_config_key("deep_research.engine") is str
|
||||
assert validate_config_key("deep_research.model") is str
|
||||
|
||||
|
||||
def test_default_toml_documents_deep_research_override() -> None:
|
||||
toml = generate_default_toml(HardwareInfo())
|
||||
|
||||
assert "# [deep_research]" in toml
|
||||
assert '# engine = ""' in toml
|
||||
assert '# model = ""' in toml
|
||||
@@ -35,9 +35,15 @@ class TestMessage:
|
||||
msg = Message(role=Role.USER, content="hello")
|
||||
assert msg.role == Role.USER
|
||||
assert msg.content == "hello"
|
||||
assert msg.text == "hello"
|
||||
assert msg.tool_calls is None
|
||||
assert msg.metadata == {}
|
||||
|
||||
def test_none_content_text_helper(self) -> None:
|
||||
msg = Message(role=Role.ASSISTANT, content=None)
|
||||
assert msg.content is None
|
||||
assert msg.text == ""
|
||||
|
||||
def test_tool_calls(self) -> None:
|
||||
tc = ToolCall(id="1", name="calc", arguments='{"x": 1}')
|
||||
msg = Message(role=Role.ASSISTANT, content="", tool_calls=[tc])
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"""Tests for Docker and deployment files."""
|
||||
"""Tests for Docker and deployment files.
|
||||
|
||||
These are static file-content checks (no Docker daemon required) so they run in
|
||||
the default CI lane. They guard the deployment hardening from #228 and its
|
||||
sub-issues (#563 image pinning, #564 systemd hardening, #565 non-root, #566
|
||||
secure Node install, #567 frozen lockfile installs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
@@ -11,6 +18,35 @@ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DOCKER_DIR = ROOT / "deploy" / "docker"
|
||||
SYSTEMD_DIR = ROOT / "deploy" / "systemd"
|
||||
|
||||
|
||||
def _dockerfiles() -> list[Path]:
|
||||
return sorted(DOCKER_DIR.glob("Dockerfile*"))
|
||||
|
||||
|
||||
def _compose_files() -> list[Path]:
|
||||
return sorted(DOCKER_DIR.glob("docker-compose*.yml"))
|
||||
|
||||
|
||||
def _from_lines(content: str) -> list[str]:
|
||||
return [
|
||||
ln.strip()
|
||||
for ln in content.splitlines()
|
||||
if ln.strip().upper().startswith("FROM ")
|
||||
]
|
||||
|
||||
|
||||
def _image_lines(content: str) -> list[str]:
|
||||
"""`image: ...` lines from a compose file, ignoring comments."""
|
||||
out = []
|
||||
for ln in content.splitlines():
|
||||
stripped = ln.strip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
if stripped.startswith("image:"):
|
||||
out.append(stripped)
|
||||
return out
|
||||
|
||||
|
||||
class TestDockerFiles:
|
||||
@@ -37,10 +73,12 @@ class TestDockerFiles:
|
||||
]
|
||||
non_src_includes = [s for s in force_include if not s.startswith("src/")]
|
||||
|
||||
install_marker = 'uv pip install --system ".[server]"'
|
||||
# The project itself is built by the final `--no-deps .` install (#567);
|
||||
# the force-includes must be present before that step.
|
||||
install_marker = "uv pip install --system --no-deps ."
|
||||
wheel_dockerfiles = [
|
||||
p
|
||||
for p in sorted(DOCKER_DIR.glob("Dockerfile*"))
|
||||
for p in _dockerfiles()
|
||||
if install_marker in p.read_text() and "COPY src/ src/" in p.read_text()
|
||||
]
|
||||
# Sanity: we actually found the wheel-building Dockerfiles to guard.
|
||||
@@ -85,5 +123,172 @@ class TestDockerFiles:
|
||||
assert "jarvis:" in content
|
||||
assert "ollama:" in content
|
||||
|
||||
def test_dockerfiles_build_native_rust_extension(self):
|
||||
build_dockerfiles = [
|
||||
"Dockerfile",
|
||||
"Dockerfile.gpu",
|
||||
"Dockerfile.gpu.rocm",
|
||||
"Dockerfile.sandbox",
|
||||
]
|
||||
required_markers = [
|
||||
"rustup toolchain install 1.88",
|
||||
"maturin build --release",
|
||||
"rust/crates/openjarvis-python/Cargo.toml",
|
||||
"/tmp/openjarvis-rust-wheel/*.whl",
|
||||
"import openjarvis_rust",
|
||||
]
|
||||
|
||||
for name in build_dockerfiles:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
for marker in required_markers:
|
||||
assert marker in content, (
|
||||
f"{name}: missing native build marker {marker!r}"
|
||||
)
|
||||
|
||||
for name in ["Dockerfile", "Dockerfile.gpu", "Dockerfile.gpu.rocm"]:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert "COPY rust/ rust/" in content, f"{name}: rust workspace not copied"
|
||||
assert content.index("COPY rust/ rust/") < content.index(
|
||||
"maturin build --release"
|
||||
), f"{name}: rust workspace copied after native build"
|
||||
|
||||
def test_systemd_service_exists(self):
|
||||
assert (ROOT / "deploy" / "systemd" / "openjarvis.service").is_file()
|
||||
assert (SYSTEMD_DIR / "openjarvis.service").is_file()
|
||||
|
||||
|
||||
class TestImagePinning:
|
||||
"""#563 — base images and ollama pinned to fixed versions + digests."""
|
||||
|
||||
def test_no_floating_latest_tag_in_image_directives(self):
|
||||
# `:latest` only acceptable inside comments (rationale text).
|
||||
for path in _dockerfiles() + _compose_files():
|
||||
for ln in path.read_text().splitlines():
|
||||
code = ln.split("#", 1)[0]
|
||||
assert ":latest" not in code, f"{path.name}: floating :latest in {ln!r}"
|
||||
|
||||
def test_every_from_pins_a_digest(self):
|
||||
for path in _dockerfiles():
|
||||
froms = _from_lines(path.read_text())
|
||||
assert froms, f"{path.name}: no FROM lines found"
|
||||
for ln in froms:
|
||||
assert "@sha256:" in ln, (
|
||||
f"{path.name}: FROM is not digest-pinned: {ln!r}"
|
||||
)
|
||||
|
||||
def test_ollama_image_pinned_with_version_and_digest(self):
|
||||
for path in _compose_files():
|
||||
for ln in _image_lines(path.read_text()):
|
||||
if "ollama/ollama" in ln:
|
||||
assert "@sha256:" in ln, f"{path.name}: ollama not digest-pinned"
|
||||
# A concrete version tag (digits) must accompany the digest.
|
||||
assert re.search(r"ollama/ollama:\d", ln), (
|
||||
f"{path.name}: ollama missing a version tag"
|
||||
)
|
||||
|
||||
|
||||
class TestNonRootUser:
|
||||
"""#565 — GPU images (and the base image) drop root."""
|
||||
|
||||
NON_ROOT = ["Dockerfile", "Dockerfile.gpu", "Dockerfile.gpu.rocm"]
|
||||
|
||||
def test_creates_and_switches_to_non_root_user(self):
|
||||
for name in self.NON_ROOT:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert "useradd" in content, f"{name}: no useradd"
|
||||
# A USER directive switching away from root must exist and not be root.
|
||||
user_lines = [
|
||||
ln.strip()
|
||||
for ln in content.splitlines()
|
||||
if ln.strip().upper().startswith("USER ")
|
||||
]
|
||||
assert user_lines, f"{name}: no USER directive"
|
||||
assert all("root" not in ln for ln in user_lines), (
|
||||
f"{name}: USER directive still root"
|
||||
)
|
||||
|
||||
def test_user_directive_is_last_stage(self):
|
||||
# USER must appear after the final FROM so the runtime stage is non-root.
|
||||
for name in self.NON_ROOT:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
last_from = content.rfind("\nFROM ")
|
||||
user_idx = content.rfind("\nUSER ")
|
||||
assert user_idx > last_from, f"{name}: USER not in final runtime stage"
|
||||
|
||||
|
||||
class TestSandboxNodeSecurity:
|
||||
"""#566 — Node installed without an unverified curl|bash pipe."""
|
||||
|
||||
def test_no_curl_pipe_bash(self):
|
||||
content = (DOCKER_DIR / "Dockerfile.sandbox").read_text()
|
||||
for ln in content.splitlines():
|
||||
code = ln.split("#", 1)[0] # ignore the explanatory comment
|
||||
assert "| bash" not in code and "|bash" not in code, (
|
||||
f"unverified pipe-to-bash install remains: {ln!r}"
|
||||
)
|
||||
assert "nodesource.com" not in code, "still using NodeSource setup script"
|
||||
|
||||
def test_node_sourced_from_pinned_official_image(self):
|
||||
content = (DOCKER_DIR / "Dockerfile.sandbox").read_text()
|
||||
assert "COPY --from=node" in content, "Node not copied from pinned image stage"
|
||||
froms = _from_lines(content)
|
||||
assert any("node:" in ln and "@sha256:" in ln for ln in froms), (
|
||||
"no digest-pinned node base stage"
|
||||
)
|
||||
|
||||
|
||||
class TestFrozenInstall:
|
||||
"""#567 — Docker builds install from the committed, frozen uv.lock."""
|
||||
|
||||
BUILD_DOCKERFILES = [
|
||||
"Dockerfile",
|
||||
"Dockerfile.gpu",
|
||||
"Dockerfile.gpu.rocm",
|
||||
"Dockerfile.sandbox",
|
||||
]
|
||||
|
||||
def test_copies_lockfile(self):
|
||||
for name in self.BUILD_DOCKERFILES:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert "uv.lock" in content, f"{name}: uv.lock not referenced"
|
||||
|
||||
def test_uses_frozen_export(self):
|
||||
for name in self.BUILD_DOCKERFILES:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert "uv export --frozen" in content, f"{name}: no frozen export"
|
||||
assert "--no-deps" in content, (
|
||||
f"{name}: deps re-resolved (missing --no-deps)"
|
||||
)
|
||||
|
||||
def test_no_unpinned_pyproject_resolution(self):
|
||||
# The old `uv pip install --system ".[server]"` re-resolved deps on every
|
||||
# build; it must not come back.
|
||||
for name in self.BUILD_DOCKERFILES:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert '".[server]"' not in content, (
|
||||
f"{name}: still re-resolves from pyproject"
|
||||
)
|
||||
|
||||
|
||||
class TestSystemdHardening:
|
||||
"""#564 — systemd unit ships secrets via EnvironmentFile and is sandboxed."""
|
||||
|
||||
def _service(self) -> str:
|
||||
return (SYSTEMD_DIR / "openjarvis.service").read_text()
|
||||
|
||||
def test_environment_file_for_secrets(self):
|
||||
assert "EnvironmentFile=" in self._service()
|
||||
|
||||
def test_core_hardening_directives_present(self):
|
||||
content = self._service()
|
||||
for directive in (
|
||||
"NoNewPrivileges=true",
|
||||
"ProtectSystem=strict",
|
||||
"PrivateTmp=true",
|
||||
):
|
||||
assert directive in content, f"missing hardening directive: {directive}"
|
||||
|
||||
def test_writable_state_path_declared(self):
|
||||
# ProtectSystem=strict makes the FS read-only; the working/home dir must
|
||||
# be re-granted write access or the server cannot persist config/state.
|
||||
content = self._service()
|
||||
assert "ReadWritePaths=" in content
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Static guards for the docs-site savings-leaderboard Supabase wiring.
|
||||
|
||||
`docs/javascripts/leaderboard.js` reads the public Supabase anon key from
|
||||
`window.OPENJARVIS_SUPABASE_ANON_KEY`. That global is set by a generated
|
||||
config file (`leaderboard-config.js`) which must load *before* leaderboard.js,
|
||||
and whose value is injected at docs-build time from the VITE_SUPABASE_ANON_KEY
|
||||
secret (see `.github/workflows/docs.yml`). These are text-only checks — no
|
||||
mkdocs build required — so they run in the default CI lane.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
MKDOCS = ROOT / "mkdocs.yml"
|
||||
DOCS_WORKFLOW = ROOT / ".github" / "workflows" / "docs.yml"
|
||||
CONFIG_JS = ROOT / "docs" / "javascripts" / "leaderboard-config.js"
|
||||
LEADERBOARD_JS = ROOT / "docs" / "javascripts" / "leaderboard.js"
|
||||
|
||||
_ANON_GLOBAL = "window.OPENJARVIS_SUPABASE_ANON_KEY"
|
||||
|
||||
|
||||
def test_config_js_declares_anon_key_global():
|
||||
assert CONFIG_JS.is_file(), "leaderboard-config.js is missing"
|
||||
assert _ANON_GLOBAL in CONFIG_JS.read_text()
|
||||
|
||||
|
||||
def test_leaderboard_reads_the_anon_key_global():
|
||||
# leaderboard.js must consume the global the config file sets.
|
||||
assert _ANON_GLOBAL in LEADERBOARD_JS.read_text()
|
||||
|
||||
|
||||
def test_config_is_loaded_before_leaderboard_in_mkdocs():
|
||||
content = MKDOCS.read_text()
|
||||
cfg = content.index("javascripts/leaderboard-config.js")
|
||||
lb = content.index("javascripts/leaderboard.js")
|
||||
assert cfg < lb, "leaderboard-config.js must be listed before leaderboard.js"
|
||||
|
||||
|
||||
def test_docs_workflow_injects_the_anon_key():
|
||||
content = DOCS_WORKFLOW.read_text()
|
||||
assert "VITE_SUPABASE_ANON_KEY" in content, "workflow doesn't read the secret"
|
||||
assert "leaderboard-config.js" in content, "workflow doesn't write the config file"
|
||||
assert _ANON_GLOBAL in content, "workflow doesn't set the anon-key global"
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
from openjarvis.engine._base import estimate_prompt_tokens
|
||||
|
||||
|
||||
def test_estimate_prompt_tokens_handles_none_content_tool_call_turn() -> None:
|
||||
messages = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None,
|
||||
tool_calls=[ToolCall(id="call_1", name="lookup", arguments="{}")],
|
||||
),
|
||||
]
|
||||
|
||||
assert estimate_prompt_tokens(messages) == 12
|
||||
|
||||
|
||||
def test_estimate_prompt_tokens_counts_tool_call_arguments() -> None:
|
||||
base = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(role=Role.ASSISTANT, content=None),
|
||||
]
|
||||
with_tool_call = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None,
|
||||
tool_calls=[ToolCall(id="", name="", arguments="abcdefgh")],
|
||||
),
|
||||
]
|
||||
|
||||
assert estimate_prompt_tokens(with_tool_call) - estimate_prompt_tokens(base) == 2
|
||||
|
||||
|
||||
def test_estimate_prompt_tokens_counts_reasoning_metadata() -> None:
|
||||
base = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(role=Role.ASSISTANT, content=None),
|
||||
]
|
||||
with_reasoning = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None,
|
||||
metadata={"reasoning_content": "abcdefgh"},
|
||||
),
|
||||
]
|
||||
|
||||
assert estimate_prompt_tokens(with_reasoning) - estimate_prompt_tokens(base) == 2
|
||||
|
||||
|
||||
def test_estimate_prompt_tokens_counts_tool_result_ids() -> None:
|
||||
messages = [
|
||||
Message(role=Role.USER, content="hi"),
|
||||
Message(role=Role.TOOL, content="ok", tool_call_id="abcdefgh"),
|
||||
]
|
||||
|
||||
assert estimate_prompt_tokens(messages) == 11
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.registry import FactStoreRegistry
|
||||
from openjarvis.memory.store import LocalFactStore, create_fact_store
|
||||
|
||||
|
||||
@@ -74,6 +75,20 @@ def test_clear(tmp_path):
|
||||
assert LocalFactStore(path).count() == 0
|
||||
|
||||
|
||||
def test_external_clear_does_not_resurrect_stale_facts(tmp_path):
|
||||
"""A running store instance must not re-flush facts cleared elsewhere."""
|
||||
path = tmp_path / "facts.jsonl"
|
||||
running = LocalFactStore(path)
|
||||
cli = LocalFactStore(path)
|
||||
|
||||
running.add("old fact")
|
||||
assert cli.clear() == 1
|
||||
|
||||
running.add("new fact")
|
||||
|
||||
assert [f.text for f in LocalFactStore(path).list()] == ["new fact"]
|
||||
|
||||
|
||||
def test_load_skips_malformed_lines(tmp_path):
|
||||
path = tmp_path / "facts.jsonl"
|
||||
path.write_text(
|
||||
@@ -107,6 +122,26 @@ def test_create_fact_store_local(tmp_path):
|
||||
assert isinstance(store, LocalFactStore)
|
||||
|
||||
|
||||
def test_create_fact_store_uses_fact_store_registry(tmp_path):
|
||||
class CustomFactStore(LocalFactStore):
|
||||
pass
|
||||
|
||||
FactStoreRegistry.register_value("custom", CustomFactStore)
|
||||
|
||||
store = create_fact_store("custom", path=tmp_path / "f.jsonl", max_facts=5)
|
||||
|
||||
assert isinstance(store, CustomFactStore)
|
||||
|
||||
|
||||
def test_create_fact_store_default_path_uses_openjarvis_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path))
|
||||
|
||||
store = create_fact_store("local")
|
||||
|
||||
assert isinstance(store, LocalFactStore)
|
||||
assert store.path == tmp_path / "memory_facts.jsonl"
|
||||
|
||||
|
||||
def test_create_fact_store_unknown_backend(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
create_fact_store("cloud", path=tmp_path / "f.jsonl")
|
||||
|
||||
@@ -7,7 +7,12 @@ import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openjarvis.core.config import StorageConfig
|
||||
from openjarvis.memory.service import MemoryService, build_memory_service
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.memory.service import (
|
||||
MemoryService,
|
||||
build_memory_service,
|
||||
publish_completed_exchange,
|
||||
)
|
||||
from openjarvis.memory.store import LocalFactStore
|
||||
|
||||
|
||||
@@ -67,6 +72,38 @@ def test_submit_extracts_and_stores(tmp_path):
|
||||
svc.stop()
|
||||
|
||||
|
||||
def test_completed_exchange_event_extracts_and_stores(tmp_path):
|
||||
bus = EventBus(record_history=True)
|
||||
extractor = FakeExtractor(["User likes jazz"])
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
svc = MemoryService(store, extractor, event_bus=bus)
|
||||
svc.start()
|
||||
try:
|
||||
assert publish_completed_exchange(
|
||||
bus,
|
||||
"I like jazz",
|
||||
"Noted.",
|
||||
source="test",
|
||||
)
|
||||
assert _wait_until(lambda: svc.fact_count() == 1)
|
||||
assert extractor.calls == [("I like jazz", "Noted.")]
|
||||
finally:
|
||||
svc.stop()
|
||||
|
||||
|
||||
def test_completed_exchange_event_unsubscribes_on_stop(tmp_path):
|
||||
bus = EventBus(record_history=True)
|
||||
extractor = FakeExtractor(["User likes jazz"])
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
svc = MemoryService(store, extractor, event_bus=bus)
|
||||
svc.start()
|
||||
svc.stop()
|
||||
|
||||
publish_completed_exchange(bus, "I like jazz", "Noted.", source="test")
|
||||
|
||||
assert extractor.calls == []
|
||||
|
||||
|
||||
def test_submit_when_not_running_is_dropped(tmp_path):
|
||||
extractor = FakeExtractor(["x"])
|
||||
svc = _service(tmp_path, extractor)
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_path_traversal_rejected(bad):
|
||||
SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name=bad))
|
||||
|
||||
|
||||
def test_none_persona_build_does_not_raise():
|
||||
def test_none_persona_build_does_not_raise(tmp_path, monkeypatch):
|
||||
"""Regression (#497): `--persona none` resolves to empty file paths; building
|
||||
the prompt must not raise IsADirectoryError when those empty paths are read
|
||||
(Path("") is "." — reading a directory raised before the empty-path guard).
|
||||
@@ -42,7 +42,8 @@ def test_none_persona_build_does_not_raise():
|
||||
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
cfg = load_config(tmp_path / "missing-config.toml")
|
||||
mf = dataclasses.replace(cfg.memory_files, persona_name="none")
|
||||
builder = SystemPromptBuilder(
|
||||
agent_template=cfg.agent.default_system_prompt or "",
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -48,6 +48,71 @@ class TestAgentManagerRoutes:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["agents"] == []
|
||||
|
||||
def test_sendblue_verify_uses_async_http_client(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = [
|
||||
"+15551234567",
|
||||
{"phone_number": "+15557654321"},
|
||||
None,
|
||||
]
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
instance = mock_client_cls.return_value.__aenter__.return_value
|
||||
instance.get = AsyncMock(return_value=mock_resp)
|
||||
resp = client.post(
|
||||
"/v1/channels/sendblue/verify",
|
||||
json={"api_key_id": "key-id", "api_secret_key": "secret"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["valid"] is True
|
||||
assert resp.json()["numbers"] == ["+15551234567", "+15557654321"]
|
||||
instance.get.assert_awaited_once()
|
||||
|
||||
def test_sendblue_register_webhook_uses_async_http_client(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"ok": True}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
instance = mock_client_cls.return_value.__aenter__.return_value
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
resp = client.post(
|
||||
"/v1/channels/sendblue/register-webhook",
|
||||
json={
|
||||
"api_key_id": "key-id",
|
||||
"api_secret_key": "secret",
|
||||
"webhook_url": "https://example.com/webhooks/sendblue",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["registered"] is True
|
||||
instance.post.assert_awaited_once()
|
||||
|
||||
def test_sendblue_test_message_uses_async_http_client(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"ok": True}
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
instance = mock_client_cls.return_value.__aenter__.return_value
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
resp = client.post(
|
||||
"/v1/channels/sendblue/test",
|
||||
json={
|
||||
"api_key_id": "key-id",
|
||||
"api_secret_key": "secret",
|
||||
"from_number": "+15550000000",
|
||||
"to_number": "+15551234567",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sent"] is True
|
||||
instance.post.assert_awaited_once()
|
||||
|
||||
def test_create_agent(self, client):
|
||||
resp = client.post(
|
||||
"/v1/managed-agents",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -75,10 +75,9 @@ class TestModelPull:
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("httpx.Client") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.post.return_value = mock_resp
|
||||
instance.close = MagicMock()
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
resp = client.post("/v1/models/pull", json={"model": "qwen3.5:4b"})
|
||||
|
||||
@@ -86,6 +85,10 @@ class TestModelPull:
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["model"] == "qwen3.5:4b"
|
||||
instance.post.assert_awaited_once_with(
|
||||
"/api/pull",
|
||||
json={"name": "qwen3.5:4b", "stream": False},
|
||||
)
|
||||
|
||||
def test_pull_ollama_unreachable(self):
|
||||
engine = _make_ollama_engine()
|
||||
@@ -93,10 +96,9 @@ class TestModelPull:
|
||||
|
||||
import httpx
|
||||
|
||||
with patch("httpx.Client") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.post.side_effect = httpx.ConnectError("refused")
|
||||
instance.close = MagicMock()
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
|
||||
|
||||
resp = client.post("/v1/models/pull", json={"model": "foo"})
|
||||
|
||||
@@ -123,10 +125,9 @@ class TestModelDelete:
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("httpx.Client") as MockClient:
|
||||
instance = MockClient.return_value
|
||||
instance.request.return_value = mock_resp
|
||||
instance.close = MagicMock()
|
||||
with patch("httpx.AsyncClient") as MockClient:
|
||||
instance = MockClient.return_value.__aenter__.return_value
|
||||
instance.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
resp = client.delete("/v1/models/qwen3:0.6b")
|
||||
|
||||
@@ -134,6 +135,11 @@ class TestModelDelete:
|
||||
data = resp.json()
|
||||
assert data["status"] == "deleted"
|
||||
assert data["model"] == "qwen3:0.6b"
|
||||
instance.request.assert_awaited_once_with(
|
||||
"DELETE",
|
||||
"/api/delete",
|
||||
json={"name": "qwen3:0.6b"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -268,3 +274,19 @@ class TestModelsEndpointExtended:
|
||||
assert resp.status_code == 200
|
||||
# The endpoint returns whatever list_models() gives
|
||||
assert resp.json()["object"] == "list"
|
||||
|
||||
def test_models_list_offloads_engine_list_models(self):
|
||||
engine = _make_engine(models=["qwen3.5:4b"])
|
||||
app = create_app(engine, "qwen3.5:4b")
|
||||
client = TestClient(app)
|
||||
|
||||
with patch(
|
||||
"openjarvis.server.routes.asyncio.to_thread",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_to_thread:
|
||||
mock_to_thread.return_value = ["qwen3.5:4b"]
|
||||
resp = client.get("/v1/models")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert [m["id"] for m in resp.json()["data"]] == ["qwen3.5:4b"]
|
||||
mock_to_thread.assert_awaited_once_with(engine.list_models)
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Tests for web Deep Research planner engine selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.agents.research_loop import DEFAULT_PLANNER_MODEL
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.server import research_router
|
||||
|
||||
|
||||
class _DummyEngine:
|
||||
def __init__(self, servable: bool = True) -> None:
|
||||
self.servable = servable
|
||||
|
||||
def can_serve(self, model: str) -> bool:
|
||||
return self.servable
|
||||
|
||||
|
||||
def test_resolve_planner_config_uses_chat_defaults() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"lmstudio",
|
||||
"local-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_prefers_active_chat_runtime() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "ollama"
|
||||
cfg.intelligence.default_model = ""
|
||||
|
||||
assert research_router._resolve_planner_config(
|
||||
cfg,
|
||||
active_engine_key="lmstudio",
|
||||
active_model="server-model",
|
||||
request_model="selected-model",
|
||||
) == (
|
||||
"lmstudio",
|
||||
"selected-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_uses_server_model_before_legacy_default() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "ollama"
|
||||
cfg.intelligence.default_model = ""
|
||||
cfg.server.model = "serve-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"ollama",
|
||||
"serve-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_allows_deep_research_override() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "chat-model"
|
||||
cfg.deep_research.engine = "vllm"
|
||||
cfg.deep_research.model = "planner-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"vllm",
|
||||
"planner-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_allows_partial_model_override() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "chat-model"
|
||||
cfg.deep_research.model = "planner-model"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"lmstudio",
|
||||
"planner-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_allows_partial_engine_override() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "chat-model"
|
||||
cfg.deep_research.engine = "vllm"
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"vllm",
|
||||
"chat-model",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planner_config_keeps_legacy_fallback_when_unconfigured() -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = ""
|
||||
cfg.intelligence.default_model = ""
|
||||
|
||||
assert research_router._resolve_planner_config(cfg) == (
|
||||
"ollama",
|
||||
DEFAULT_PLANNER_MODEL,
|
||||
)
|
||||
|
||||
|
||||
def test_build_planner_engine_uses_configured_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
engine = _DummyEngine()
|
||||
calls: list[tuple[str | None, str | None]] = []
|
||||
|
||||
def fake_get_engine(
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, _DummyEngine]:
|
||||
calls.append((engine_key, model))
|
||||
return "lmstudio", engine
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
|
||||
|
||||
engine_key, resolved_engine, model = research_router._build_planner_engine(cfg)
|
||||
|
||||
assert calls == [("lmstudio", "local-model")]
|
||||
assert engine_key == "lmstudio"
|
||||
assert resolved_engine is engine
|
||||
assert model == "local-model"
|
||||
|
||||
|
||||
def test_build_planner_engine_uses_active_engine_without_config_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "ollama"
|
||||
cfg.intelligence.default_model = ""
|
||||
active_engine = _DummyEngine()
|
||||
|
||||
def fail_get_engine(*args: object, **kwargs: object) -> None:
|
||||
raise AssertionError("should use the live app engine")
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fail_get_engine)
|
||||
|
||||
engine_key, resolved_engine, model = research_router._build_planner_engine(
|
||||
cfg,
|
||||
active_engine=active_engine,
|
||||
active_engine_key="lmstudio",
|
||||
active_model="server-model",
|
||||
request_model="selected-model",
|
||||
)
|
||||
|
||||
assert engine_key == "lmstudio"
|
||||
assert resolved_engine is active_engine
|
||||
assert model == "selected-model"
|
||||
|
||||
|
||||
def test_build_planner_engine_rejects_active_engine_that_cannot_serve_model() -> None:
|
||||
cfg = JarvisConfig()
|
||||
|
||||
with pytest.raises(RuntimeError, match="selected-model"):
|
||||
research_router._build_planner_engine(
|
||||
cfg,
|
||||
active_engine=_DummyEngine(servable=False),
|
||||
active_engine_key="cloud",
|
||||
request_model="selected-model",
|
||||
)
|
||||
|
||||
|
||||
def test_build_planner_engine_honors_explicit_deep_research_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.deep_research.engine = "vllm"
|
||||
cfg.deep_research.model = "planner-model"
|
||||
active_engine = _DummyEngine()
|
||||
planner_engine = _DummyEngine()
|
||||
|
||||
def fake_get_engine(
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, _DummyEngine]:
|
||||
assert engine_key == "vllm"
|
||||
assert model == "planner-model"
|
||||
return "vllm", planner_engine
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
|
||||
|
||||
engine_key, resolved_engine, model = research_router._build_planner_engine(
|
||||
cfg,
|
||||
active_engine=active_engine,
|
||||
active_engine_key="lmstudio",
|
||||
active_model="chat-model",
|
||||
request_model="selected-model",
|
||||
)
|
||||
|
||||
assert engine_key == "vllm"
|
||||
assert resolved_engine is planner_engine
|
||||
assert model == "planner-model"
|
||||
|
||||
|
||||
def test_research_route_passes_live_engine_and_selected_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
active_engine = _DummyEngine()
|
||||
|
||||
def fake_stream(query: str, **kwargs: object):
|
||||
captured["query"] = query
|
||||
captured.update(kwargs)
|
||||
|
||||
async def gen():
|
||||
yield "data: {\"type\":\"done\",\"usage\":{}}\n\n"
|
||||
|
||||
return gen()
|
||||
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
engine=active_engine,
|
||||
engine_name="lmstudio",
|
||||
model="server-model",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(research_router, "_stream_research", fake_stream)
|
||||
|
||||
response = asyncio.run(
|
||||
research_router.research(
|
||||
research_router.ResearchRequest(
|
||||
query="find notes",
|
||||
model="selected-model",
|
||||
),
|
||||
request, # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
|
||||
assert response.media_type == "text/event-stream"
|
||||
assert captured == {
|
||||
"query": "find notes",
|
||||
"active_engine": active_engine,
|
||||
"active_engine_key": "lmstudio",
|
||||
"active_model": "server-model",
|
||||
"request_model": "selected-model",
|
||||
}
|
||||
|
||||
|
||||
def test_build_planner_engine_rejects_fallback_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
|
||||
def fake_get_engine(
|
||||
config: JarvisConfig,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, _DummyEngine]:
|
||||
return "ollama", _DummyEngine()
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
|
||||
|
||||
with pytest.raises(RuntimeError, match="lmstudio"):
|
||||
research_router._build_planner_engine(cfg)
|
||||
|
||||
|
||||
def test_build_planner_engine_rejects_unavailable_engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
cfg = JarvisConfig()
|
||||
cfg.engine.default = "lmstudio"
|
||||
cfg.intelligence.default_model = "local-model"
|
||||
|
||||
monkeypatch.setattr(research_router, "get_engine", lambda *args, **kwargs: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="local-model"):
|
||||
research_router._build_planner_engine(cfg)
|
||||
+152
-23
@@ -10,6 +10,7 @@ import pytest
|
||||
fastapi = pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType # noqa: E402
|
||||
from openjarvis.server.app import create_app # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -54,10 +55,19 @@ def _make_agent(content="Hello from agent"):
|
||||
return agent
|
||||
|
||||
|
||||
def _test_config():
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
cfg = JarvisConfig()
|
||||
cfg.analytics.enabled = False
|
||||
cfg.traces.enabled = False
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -65,7 +75,7 @@ def client():
|
||||
def client_with_agent():
|
||||
engine = _make_engine()
|
||||
agent = _make_agent()
|
||||
app = create_app(engine, "test-model", agent=agent)
|
||||
app = create_app(engine, "test-model", agent=agent, config=_test_config())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -92,7 +102,12 @@ class TestMemoryServiceWiring:
|
||||
def test_non_streaming_completion_feeds_memory(self):
|
||||
engine = _make_engine(content="remembered reply")
|
||||
spy = _SpyMemoryService()
|
||||
app = create_app(engine, "test-model", memory_service=spy)
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
memory_service=spy,
|
||||
config=_test_config(),
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
@@ -109,7 +124,13 @@ class TestMemoryServiceWiring:
|
||||
engine = _make_engine()
|
||||
agent = _make_agent(content="agent reply")
|
||||
spy = _SpyMemoryService()
|
||||
app = create_app(engine, "test-model", agent=agent, memory_service=spy)
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
agent=agent,
|
||||
memory_service=spy,
|
||||
config=_test_config(),
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
@@ -122,9 +143,83 @@ class TestMemoryServiceWiring:
|
||||
assert resp.status_code == 200
|
||||
assert spy.submissions == [("remember this", "agent reply")]
|
||||
|
||||
def test_non_streaming_completion_publishes_completed_exchange(self):
|
||||
bus = EventBus(record_history=True)
|
||||
engine = _make_engine(content="event reply")
|
||||
app = create_app(engine, "test-model", bus=bus, config=_test_config())
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "publish this"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
events = [
|
||||
e for e in bus.history if e.event_type == EventType.CHAT_EXCHANGE_COMPLETED
|
||||
]
|
||||
assert len(events) == 1
|
||||
assert events[0].data["user_text"] == "publish this"
|
||||
assert events[0].data["assistant_text"] == "event reply"
|
||||
|
||||
def test_streaming_completion_feeds_memory_without_bus(self):
|
||||
engine = _make_engine()
|
||||
spy = _SpyMemoryService()
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
memory_service=spy,
|
||||
config=_test_config(),
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "stream remember"}],
|
||||
"stream": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "data:" in resp.text
|
||||
assert spy.submissions == [("stream remember", "Hello world")]
|
||||
|
||||
def test_streaming_completion_publishes_completed_exchange(self):
|
||||
bus = EventBus(record_history=True)
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model", bus=bus, config=_test_config())
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "stream event"}],
|
||||
"stream": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "data:" in resp.text
|
||||
events = [
|
||||
e for e in bus.history if e.event_type == EventType.CHAT_EXCHANGE_COMPLETED
|
||||
]
|
||||
assert len(events) == 1
|
||||
assert events[0].data["user_text"] == "stream event"
|
||||
assert events[0].data["assistant_text"] == "Hello world"
|
||||
|
||||
def test_no_memory_service_is_noop(self):
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model") # memory_service defaults to None
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
config=_test_config(),
|
||||
) # memory_service defaults to None
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -207,7 +302,7 @@ class TestChatCompletions:
|
||||
"model": "test-model",
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -255,7 +350,7 @@ class TestChatCompletions:
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
agent = _make_agent(content="GENERIC AGENT FILLER")
|
||||
app = create_app(engine, "test-model", agent=agent)
|
||||
app = create_app(engine, "test-model", agent=agent, config=_test_config())
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
@@ -342,7 +437,7 @@ class TestChatCompletions:
|
||||
lambda data: received_records.append(data),
|
||||
)
|
||||
|
||||
app = create_app(wrapped, "test-model")
|
||||
app = create_app(wrapped, "test-model", config=_test_config())
|
||||
app.state.bus = bus
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -483,7 +578,13 @@ class TestChatCompletions:
|
||||
# bus present + agent registered == the exact live condition under
|
||||
# which the pre-fix code routed to the (broken) agent stream bridge.
|
||||
agent = _make_agent(content="GENERIC AGENT FILLER")
|
||||
app = create_app(engine, "test-model", agent=agent, bus=EventBus())
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
agent=agent,
|
||||
bus=EventBus(),
|
||||
config=_test_config(),
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
@@ -592,6 +693,15 @@ def _make_capturing_engine(captured: list):
|
||||
return engine
|
||||
|
||||
|
||||
def _identity_config():
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
cfg = JarvisConfig()
|
||||
cfg.agent.default_system_prompt = "You are OpenJarvis."
|
||||
cfg.analytics.enabled = False
|
||||
return cfg
|
||||
|
||||
|
||||
class TestIdentityPromptInjection:
|
||||
"""Regression for #540.
|
||||
|
||||
@@ -607,7 +717,7 @@ class TestIdentityPromptInjection:
|
||||
def test_stream_injects_identity_when_absent(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -628,7 +738,7 @@ class TestIdentityPromptInjection:
|
||||
def test_stream_no_double_injection_when_client_supplies_system(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -652,7 +762,7 @@ class TestIdentityPromptInjection:
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
# No agent -> non-stream request goes through _handle_direct.
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -670,7 +780,7 @@ class TestIdentityPromptInjection:
|
||||
def test_direct_no_double_injection_when_client_supplies_system(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -691,7 +801,7 @@ class TestIdentityPromptInjection:
|
||||
def test_stream_tools_injects_identity_when_absent(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
client = TestClient(create_app(engine, "test-model"))
|
||||
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
@@ -733,7 +843,7 @@ class TestModelsEndpoint:
|
||||
|
||||
def test_multiple_models(self):
|
||||
engine = _make_engine(models=["model-a", "model-b", "model-c"])
|
||||
app = create_app(engine, "model-a")
|
||||
app = create_app(engine, "model-a", config=_test_config())
|
||||
client = TestClient(app)
|
||||
resp = client.get("/v1/models")
|
||||
data = resp.json()
|
||||
@@ -754,7 +864,7 @@ class TestHealthEndpoint:
|
||||
def test_unhealthy(self):
|
||||
engine = _make_engine()
|
||||
engine.health.return_value = False
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
client = TestClient(app)
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 503
|
||||
@@ -768,19 +878,19 @@ class TestHealthEndpoint:
|
||||
class TestCreateApp:
|
||||
def test_app_state(self):
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
assert app.state.engine is engine
|
||||
assert app.state.model == "test-model"
|
||||
|
||||
def test_app_with_agent(self):
|
||||
engine = _make_engine()
|
||||
agent = _make_agent()
|
||||
app = create_app(engine, "test-model", agent=agent)
|
||||
app = create_app(engine, "test-model", agent=agent, config=_test_config())
|
||||
assert app.state.agent is agent
|
||||
|
||||
def test_app_without_agent(self):
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_test_config())
|
||||
assert app.state.agent is None
|
||||
|
||||
|
||||
@@ -790,8 +900,26 @@ class TestCreateApp:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _traces_enabled_config(tmp_path):
|
||||
"""A config with traces explicitly enabled, isolated to *tmp_path*.
|
||||
|
||||
``create_app`` only builds a trace store when ``config.traces.enabled`` is
|
||||
true (server/app.py). Relying on the ambient ``load_config()`` made these
|
||||
tests fail on any machine whose ``~/.openjarvis/config.toml`` disables
|
||||
traces; pinning an explicit config + tmp db keeps them hermetic and
|
||||
parallel-safe under ``pytest -n auto``.
|
||||
"""
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
cfg = JarvisConfig()
|
||||
cfg.traces.enabled = True
|
||||
cfg.traces.db_path = str(tmp_path / "traces.db")
|
||||
cfg.analytics.enabled = False
|
||||
return cfg
|
||||
|
||||
|
||||
class TestTraceRecording:
|
||||
def test_agent_completion_creates_trace(self):
|
||||
def test_agent_completion_creates_trace(self, tmp_path):
|
||||
"""A non-streaming agent completion records exactly one trace.
|
||||
|
||||
The collector is the single writer: it saves directly and also
|
||||
@@ -810,9 +938,10 @@ class TestTraceRecording:
|
||||
"test-model",
|
||||
agent=agent,
|
||||
bus=EventBus(record_history=False),
|
||||
config=_traces_enabled_config(tmp_path),
|
||||
)
|
||||
store = app.state.trace_store
|
||||
assert store is not None, "traces enabled by default → store should exist"
|
||||
assert store is not None, "traces explicitly enabled → store should exist"
|
||||
assert store.count() == 0
|
||||
|
||||
client = TestClient(app)
|
||||
@@ -831,10 +960,10 @@ class TestTraceRecording:
|
||||
assert trace.query == "What is 2+2?"
|
||||
assert trace.result == "traced reply"
|
||||
|
||||
def test_streaming_completion_creates_trace(self):
|
||||
def test_streaming_completion_creates_trace(self, tmp_path):
|
||||
"""A streamed completion (no agent) records the assembled response."""
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model")
|
||||
app = create_app(engine, "test-model", config=_traces_enabled_config(tmp_path))
|
||||
store = app.state.trace_store
|
||||
assert store is not None
|
||||
assert store.count() == 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for speech API endpoints."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -56,6 +56,33 @@ def test_transcribe_endpoint(client, mock_speech_backend):
|
||||
assert data["duration_seconds"] == 1.5
|
||||
|
||||
|
||||
def test_transcribe_endpoint_offloads_backend_work(client, mock_speech_backend):
|
||||
expected = TranscriptionResult(
|
||||
text="Offloaded",
|
||||
language="en",
|
||||
confidence=0.9,
|
||||
duration_seconds=1.0,
|
||||
segments=[],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openjarvis.server.api_routes.asyncio.to_thread",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_to_thread:
|
||||
mock_to_thread.return_value = expected
|
||||
response = client.post(
|
||||
"/v1/speech/transcribe",
|
||||
files={"file": ("test.wav", b"fake audio data", "audio/wav")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_to_thread.assert_awaited_once()
|
||||
args, kwargs = mock_to_thread.await_args
|
||||
assert args == (mock_speech_backend.transcribe, b"fake audio data")
|
||||
assert kwargs == {"format": "wav", "language": None}
|
||||
assert response.json()["text"] == "Offloaded"
|
||||
|
||||
|
||||
def test_transcribe_endpoint_surfaces_backend_error(client, mock_speech_backend):
|
||||
mock_speech_backend.transcribe.side_effect = RuntimeError("missing cublas64_12.dll")
|
||||
|
||||
|
||||
@@ -49,6 +49,21 @@ def _setup(tmp_path: Path, records: list[TelemetryRecord] | None = None):
|
||||
|
||||
|
||||
class TestTelemetryAggregator:
|
||||
def test_uses_wal_with_normal_synchronous_and_busy_timeout(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agg = _setup(tmp_path)
|
||||
|
||||
journal_mode = agg._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
synchronous = agg._conn.execute("PRAGMA synchronous").fetchone()[0]
|
||||
busy_timeout = agg._conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
||||
|
||||
assert journal_mode.lower() == "wal"
|
||||
assert synchronous == 1
|
||||
assert busy_timeout == 5000
|
||||
agg.close()
|
||||
|
||||
def test_empty_db_summary(self, tmp_path: Path) -> None:
|
||||
agg = _setup(tmp_path)
|
||||
s = agg.summary()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
@@ -17,6 +18,35 @@ class TestTelemetryStore:
|
||||
assert rows == []
|
||||
store.close()
|
||||
|
||||
def test_uses_wal_with_normal_synchronous(self, tmp_path: Path) -> None:
|
||||
store = TelemetryStore(tmp_path / "test.db")
|
||||
journal_mode = store._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
synchronous = store._conn.execute("PRAGMA synchronous").fetchone()[0]
|
||||
busy_timeout = store._conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
||||
|
||||
assert journal_mode.lower() == "wal"
|
||||
assert synchronous == 1
|
||||
assert busy_timeout == 5000
|
||||
store.close()
|
||||
|
||||
def test_concurrent_record_writes_are_serialized(self, tmp_path: Path) -> None:
|
||||
store = TelemetryStore(tmp_path / "test.db")
|
||||
|
||||
def write_one(i: int) -> None:
|
||||
store.record(
|
||||
TelemetryRecord(
|
||||
timestamp=time.time(),
|
||||
model_id=f"model-{i}",
|
||||
engine="test",
|
||||
)
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
list(pool.map(write_one, range(32)))
|
||||
|
||||
assert len(store._fetchall()) == 32
|
||||
store.close()
|
||||
|
||||
def test_record_values(self, tmp_path: Path) -> None:
|
||||
store = TelemetryStore(tmp_path / "test.db")
|
||||
rec = TelemetryRecord(
|
||||
|
||||
Reference in New Issue
Block a user