mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
204
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6382f2473 | ||
|
|
2922a2b154 | ||
|
|
403dec8e98 | ||
|
|
2c7cf6118c | ||
|
|
2bdd860b54 | ||
|
|
81f1ffbb4f | ||
|
|
93fc7b9e77 | ||
|
|
9fc5b875d1 | ||
|
|
08279e6b99 | ||
|
|
a7c31e89b4 | ||
|
|
04014c658a | ||
|
|
c1238d3e7e | ||
|
|
687e80a55a | ||
|
|
b90fd01af2 | ||
|
|
bbe7df7d33 | ||
|
|
9685b9b78f | ||
|
|
aa2d127de4 | ||
|
|
87f6238338 | ||
|
|
452bcc38cf | ||
|
|
b35a4c8113 | ||
|
|
f001e3b0ca | ||
|
|
b6dba93ae5 | ||
|
|
3000116d18 | ||
|
|
9db21d37ef | ||
|
|
95480363b7 | ||
|
|
4419b76412 | ||
|
|
99bbc2054a | ||
|
|
d5d8fddc94 | ||
|
|
cadb3e2ae6 | ||
|
|
7dc904c1b2 | ||
|
|
d9725fbb6a | ||
|
|
23f04264f9 | ||
|
|
8b59eb87e0 | ||
|
|
2e68e227b7 | ||
|
|
fc98614437 | ||
|
|
b1c5aba6fd | ||
|
|
6240c59ca3 | ||
|
|
9f3c7fd086 | ||
|
|
657c8dd26b | ||
|
|
4ef296e9d0 | ||
|
|
d5d06ca0e5 | ||
|
|
213ee4ff7e | ||
|
|
215ab76e5f | ||
|
|
0812ee0701 | ||
|
|
0b140110d8 | ||
|
|
2623f9e0f4 | ||
|
|
d454c41500 | ||
|
|
e3fb816d12 | ||
|
|
3486f27357 | ||
|
|
928776a71c | ||
|
|
2c2a4b6ae4 | ||
|
|
26bc7efb09 | ||
|
|
f3954e087a | ||
|
|
d865b4bed4 | ||
|
|
c686517cc7 | ||
|
|
904133cb25 | ||
|
|
299dee1f40 | ||
|
|
44ff286005 | ||
|
|
be51eb8684 | ||
|
|
420908401c | ||
|
|
b70be55681 | ||
|
|
a0187e40e6 | ||
|
|
d32f20f9b3 | ||
|
|
0b552cbcb5 | ||
|
|
19fd3c8d2b | ||
|
|
1fa80d8ecd | ||
|
|
b3f90691bf | ||
|
|
4ebf0839e7 | ||
|
|
b1e93d4ed0 | ||
|
|
eb2b612c7c | ||
|
|
560ec860df | ||
|
|
e7c46c1985 | ||
|
|
00d1e39b6d | ||
|
|
843375d6ef | ||
|
|
8d33cb58fa | ||
|
|
e4c4bcbae3 | ||
|
|
993c24c8b9 | ||
|
|
5bc8d3a2f6 | ||
|
|
433d10db5e | ||
|
|
9b7b3681f6 | ||
|
|
6dbe5461bb | ||
|
|
a65592fecb | ||
|
|
0513fbdb84 | ||
|
|
d4eb6308b1 | ||
|
|
2853a0001d | ||
|
|
3c99481975 | ||
|
|
4bf39af9bd | ||
|
|
0a3e812751 | ||
|
|
eb46febad5 | ||
|
|
81482b45d4 | ||
|
|
3e2f4bcdb4 | ||
|
|
a35b21195f | ||
|
|
f9d1bc8c27 | ||
|
|
dfa908c358 | ||
|
|
8ef1ab1928 | ||
|
|
28e75cb513 | ||
|
|
4b9948250b | ||
|
|
79e23719d4 | ||
|
|
7ba334b5f0 | ||
|
|
48a2627c9a | ||
|
|
8625f4f95f | ||
|
|
cf08f164c0 | ||
|
|
b21463aab6 | ||
|
|
0cac61d3bb | ||
|
|
50993dfa4d | ||
|
|
527f84f960 | ||
|
|
8eaeb3a754 | ||
|
|
90b7d0cb9b | ||
|
|
d7053c35d5 | ||
|
|
03c5ec3e40 | ||
|
|
4218258486 | ||
|
|
176ed3029b | ||
|
|
590addae4c | ||
|
|
bc9fa6b3c8 | ||
|
|
18d9897317 | ||
|
|
a61d3c09e1 | ||
|
|
c76c80d6b4 | ||
|
|
d908372eb5 | ||
|
|
1b2b0a06e1 | ||
|
|
9e4504cce4 | ||
|
|
d8d4985eb5 | ||
|
|
3359629456 | ||
|
|
726445433b | ||
|
|
b30bf43557 | ||
|
|
a99ee7c473 | ||
|
|
4523715bff | ||
|
|
f7c948fe12 | ||
|
|
30a014faf4 | ||
|
|
bb90480430 | ||
|
|
3ab7764e06 | ||
|
|
68b27654a8 | ||
|
|
1827c1578b | ||
|
|
28ef745384 | ||
|
|
50780a6a1d | ||
|
|
f72abadf68 | ||
|
|
945dabbce7 | ||
|
|
7506bcc0e4 | ||
|
|
739bff417c | ||
|
|
de3e86c544 | ||
|
|
58e822ff91 | ||
|
|
b3cfa398b4 | ||
|
|
53fb42104e | ||
|
|
f922811cba | ||
|
|
097795567b | ||
|
|
98b79eca72 | ||
|
|
58f7f717ae | ||
|
|
4b4fd587b2 | ||
|
|
690b95e050 | ||
|
|
d1ad3316b6 | ||
|
|
2b14315f3c | ||
|
|
09b19193fe | ||
|
|
b5926400e2 | ||
|
|
3398e0c10b | ||
|
|
bd1ab115ec | ||
|
|
5270149ea5 | ||
|
|
a13d8a909d | ||
|
|
0fcee8236e | ||
|
|
a08282c3e9 | ||
|
|
d13006263e | ||
|
|
6fc644e209 | ||
|
|
21f1becb4d | ||
|
|
d9af1eca45 | ||
|
|
a901fcc12a | ||
|
|
f833193454 | ||
|
|
0591a80448 | ||
|
|
d8a8cd8df7 | ||
|
|
c48784e304 | ||
|
|
e29ed9986e | ||
|
|
a7b9e09305 | ||
|
|
efa64beda8 | ||
|
|
a79cd6f5e9 | ||
|
|
b6a8280d68 | ||
|
|
caa3adbbce | ||
|
|
ad7c86495f | ||
|
|
f1b0df6b6b | ||
|
|
c8970cc387 | ||
|
|
48dcb40be4 | ||
|
|
94c515bba5 | ||
|
|
c9290aa77f | ||
|
|
cf29ef710e | ||
|
|
3aa24b2709 | ||
|
|
c6f172cbdc | ||
|
|
00b85b71dd | ||
|
|
6a011b4abb | ||
|
|
20f90f3ca0 | ||
|
|
4fa9163ea9 | ||
|
|
23ed17fe12 | ||
|
|
26285e56fa | ||
|
|
c6448f7cf2 | ||
|
|
6a27094658 | ||
|
|
c14c7c24c0 | ||
|
|
75d26d23ea | ||
|
|
4ece9a933e | ||
|
|
00065cd960 | ||
|
|
0702f2793c | ||
|
|
f6156f480b | ||
|
|
eb08987dae | ||
|
|
5acc86d7cc | ||
|
|
f827a8165d | ||
|
|
0d3cc6db51 | ||
|
|
b0f133a42d | ||
|
|
9cd760ad1e | ||
|
|
87e978ef20 | ||
|
|
47ca09f1a3 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "77,239",
|
||||
"message": "176,576",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 77239,
|
||||
"last_updated": "2026-05-25T07:37:53Z",
|
||||
"total_clones": 176576,
|
||||
"last_updated": "2026-07-29T08:35:42Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -60,6 +60,71 @@
|
||||
"2026-05-21": 1605,
|
||||
"2026-05-22": 612,
|
||||
"2026-05-23": 2437,
|
||||
"2026-05-24": 4900
|
||||
"2026-05-24": 4900,
|
||||
"2026-05-25": 1319,
|
||||
"2026-05-26": 1199,
|
||||
"2026-05-27": 898,
|
||||
"2026-05-28": 1276,
|
||||
"2026-05-29": 2950,
|
||||
"2026-05-30": 4338,
|
||||
"2026-05-31": 1887,
|
||||
"2026-06-01": 2072,
|
||||
"2026-06-02": 1847,
|
||||
"2026-06-03": 2164,
|
||||
"2026-06-04": 2632,
|
||||
"2026-06-05": 2127,
|
||||
"2026-06-06": 2204,
|
||||
"2026-06-07": 1174,
|
||||
"2026-06-08": 2369,
|
||||
"2026-06-09": 1361,
|
||||
"2026-06-10": 1310,
|
||||
"2026-06-11": 2564,
|
||||
"2026-06-12": 1313,
|
||||
"2026-06-13": 2804,
|
||||
"2026-06-14": 1543,
|
||||
"2026-06-15": 1379,
|
||||
"2026-06-16": 1317,
|
||||
"2026-06-17": 1170,
|
||||
"2026-06-18": 1408,
|
||||
"2026-06-19": 1350,
|
||||
"2026-06-20": 1437,
|
||||
"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,
|
||||
"2026-06-29": 765,
|
||||
"2026-06-30": 951,
|
||||
"2026-07-01": 1134,
|
||||
"2026-07-02": 593,
|
||||
"2026-07-03": 537,
|
||||
"2026-07-04": 411,
|
||||
"2026-07-05": 485,
|
||||
"2026-07-06": 555,
|
||||
"2026-07-07": 905,
|
||||
"2026-07-08": 1171,
|
||||
"2026-07-09": 1857,
|
||||
"2026-07-10": 1181,
|
||||
"2026-07-11": 2185,
|
||||
"2026-07-12": 1917,
|
||||
"2026-07-13": 2102,
|
||||
"2026-07-14": 2337,
|
||||
"2026-07-15": 2362,
|
||||
"2026-07-16": 2497,
|
||||
"2026-07-17": 1773,
|
||||
"2026-07-18": 1542,
|
||||
"2026-07-19": 1445,
|
||||
"2026-07-20": 1481,
|
||||
"2026-07-21": 1528,
|
||||
"2026-07-22": 1529,
|
||||
"2026-07-23": 1209,
|
||||
"2026-07-24": 1118,
|
||||
"2026-07-25": 928,
|
||||
"2026-07-26": 740,
|
||||
"2026-07-27": 799,
|
||||
"2026-07-28": 665
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,20 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Base version is the next patch above whatever is in pyproject.toml.
|
||||
# Base version is the next patch above the latest plain release tag
|
||||
# (vX.Y.Z) reachable from HEAD. pyproject.toml no longer carries a
|
||||
# static version (#526 switched it to hatch-vcs), so the release tag
|
||||
# is the source of truth. `.devN`/`.rcN`/`desktop-*` tags are excluded
|
||||
# so they can't be mistaken for the release base.
|
||||
# Any future manual `X.Y.Z` release will outrank every `X.Y.Z.devN`
|
||||
# autotag — PEP 440 sorts dev releases strictly below the final.
|
||||
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
|
||||
if [[ -z "$BASE" ]]; then
|
||||
echo "::error::Could not parse version from pyproject.toml"
|
||||
LATEST_RELEASE=$(git tag --list 'v[0-9]*' --merged HEAD \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
if [[ -z "$LATEST_RELEASE" ]]; then
|
||||
echo "::error::No release tag (vX.Y.Z) reachable from HEAD"
|
||||
exit 1
|
||||
fi
|
||||
BASE="${LATEST_RELEASE#v}"
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
|
||||
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra framework-comparison --extra server
|
||||
@@ -30,6 +32,9 @@ jobs:
|
||||
- name: Ruff check
|
||||
run: uv run ruff check src/ tests/
|
||||
|
||||
- name: Ruff format check
|
||||
run: uv run ruff format --check src/ tests/
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -55,6 +60,8 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra framework-comparison --extra server
|
||||
@@ -63,8 +70,13 @@ jobs:
|
||||
run: uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
|
||||
|
||||
- name: Run tests
|
||||
# COVERAGE_CORE=sysmon uses CPython 3.12's sys.monitoring backend,
|
||||
# which is dramatically cheaper than the default C trace function.
|
||||
# -n auto fans the suite out across all runner cores via pytest-xdist.
|
||||
env:
|
||||
COVERAGE_CORE: sysmon
|
||||
run: |
|
||||
uv run pytest tests/ -v --tb=short -m "not live and not cloud" \
|
||||
uv run pytest tests/ -n auto -q --tb=short -m "not live and not cloud and not hub" \
|
||||
--cov=openjarvis \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=xml \
|
||||
@@ -90,16 +102,25 @@ jobs:
|
||||
# interpolation), so there is no workflow-injection surface here.
|
||||
test-windows:
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 3.12 = common; 3.13 = the supported ceiling — installing there guards
|
||||
# against a numpy/native wheel gap at the top of the range (#350), which
|
||||
# is exactly how the source-build failure slips in on Windows.
|
||||
python-version: ["3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra server
|
||||
|
||||
@@ -11,22 +11,33 @@ concurrency:
|
||||
group: claude-issues-${{ github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least-privilege: only what the issue-fixer job actually needs.
|
||||
# id-token (OIDC) is intentionally omitted — claude-code-action@v1 is passed
|
||||
# github_token directly, so OIDC is unused here.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
fix:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 15
|
||||
# Security gate: this job reaches secrets.ANTHROPIC_API_KEY and holds a
|
||||
# write-scoped GITHUB_TOKEN. `issues` / `issue_comment` are public,
|
||||
# attacker-controllable events that run in the base-repo context with full
|
||||
# secret access, so the human-triggered paths are restricted to actors with
|
||||
# write-level association (OWNER / MEMBER / COLLABORATOR). This blocks
|
||||
# external / first-time contributors from draining the API budget or
|
||||
# creating branches/PRs, while leaving maintainer use unaffected.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) &&
|
||||
(contains(github.event.issue.labels.*.name, 'bug') ||
|
||||
contains(github.event.issue.labels.*.name, 'autofix'))) ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
!github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'claude[bot]')
|
||||
|
||||
@@ -11,23 +11,33 @@ concurrency:
|
||||
group: claude-review-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least-privilege: PR review only needs to post comments on the PR.
|
||||
# id-token (OIDC) is omitted — claude-code-action@v1 is passed github_token
|
||||
# directly, so OIDC is unused here.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Security gate: this job reaches secrets.ANTHROPIC_API_KEY. Both
|
||||
# issue_comment and pull_request_review_comment are public,
|
||||
# attacker-controllable events that run in the base-repo context with full
|
||||
# secret access, so the @claude paths are restricted to actors with
|
||||
# write-level association (OWNER / MEMBER / COLLABORATOR). External /
|
||||
# first-time contributors cannot trigger the key; maintainers are unaffected.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'claude[bot]') ||
|
||||
(github.event_name == 'pull_request_review_comment' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
|
||||
contains(github.event.comment.body, '@claude') &&
|
||||
github.actor != 'claude[bot]')
|
||||
steps:
|
||||
|
||||
@@ -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
|
||||
@@ -114,6 +115,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Full history + tags so the workflow_dispatch fallback in
|
||||
# "Determine release info" can derive the dev version from the
|
||||
# latest release tag (#526).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -125,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
|
||||
@@ -183,7 +190,16 @@ jobs:
|
||||
# workflow_dispatch fallback (manual UI dispatch without --ref).
|
||||
# Derive a PEP 440 dev version aligned with autotag.yml so we
|
||||
# don't burn the X.Y.Z release-version namespace.
|
||||
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
|
||||
# pyproject.toml no longer carries a static version (#526), so the
|
||||
# base comes from the latest plain release tag (vX.Y.Z), matching
|
||||
# autotag.yml. .dev/.rc/desktop-* tags are excluded.
|
||||
LATEST_RELEASE=$(git tag --list 'v[0-9]*' --merged HEAD \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
|
||||
if [[ -z "$LATEST_RELEASE" ]]; then
|
||||
echo "::error::No release tag (vX.Y.Z) reachable from HEAD"
|
||||
exit 1
|
||||
fi
|
||||
BASE="${LATEST_RELEASE#v}"
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
|
||||
@@ -238,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 }}
|
||||
|
||||
@@ -12,6 +12,11 @@ on:
|
||||
description: 'Tag to publish (e.g. v1.0.2.dev500). Overrides github.ref.'
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run: build + validate, then publish to TestPyPI instead of PyPI (no production upload).'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -50,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
|
||||
@@ -67,27 +74,41 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Set version from tag
|
||||
- name: Resolve build version from tag
|
||||
env:
|
||||
REF: ${{ steps.ref.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Strip leading "v" if present (e.g. v1.0.2.dev500 -> 1.0.2.dev500)
|
||||
# Strip leading "v" (e.g. v1.0.3.dev825 -> 1.0.3.dev825).
|
||||
VERSION="${REF#v}"
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "::error::Could not resolve version from ref '$REF'"
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "::error::ref '$REF' is not a version tag (expected vX.Y.Z[.devN]); pass -f tag=vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
|
||||
# Sanity check the substitution actually took
|
||||
grep -q "^version = \"${VERSION}\"" pyproject.toml || {
|
||||
echo "::error::sed failed to update pyproject.toml version"
|
||||
exit 1
|
||||
}
|
||||
echo "Building version $VERSION"
|
||||
# pyproject.toml is now dynamic = ["version"] via hatch-vcs (#526), so
|
||||
# there is no static line to sed. setuptools_scm cannot bump custom
|
||||
# `.devN` tags, so we pin the exact build version explicitly — the
|
||||
# published version always equals the pushed tag.
|
||||
echo "SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "Building version ${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
- name: Publish to TestPyPI (dry run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${UV_PUBLISH_TOKEN:-}" ]]; then
|
||||
echo "::warning::TEST_PYPI_API_TOKEN is not set — skipping the TestPyPI upload."
|
||||
echo "Build + twine check passed, which validated version derivation and packaging end to end."
|
||||
echo "To exercise a real upload, add a TEST_PYPI_API_TOKEN secret (or a TestPyPI trusted publisher)."
|
||||
exit 0
|
||||
fi
|
||||
uv publish --publish-url https://test.pypi.org/legacy/
|
||||
|
||||
- name: Publish to PyPI
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: uv publish
|
||||
|
||||
@@ -39,6 +39,9 @@ Thumbs.db
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
# ...but keep checked-in example/templates (never contain real secrets)
|
||||
!.env.example
|
||||
!**/.env.example
|
||||
|
||||
# Project
|
||||
*.sqlite
|
||||
@@ -122,3 +125,8 @@ learning.db
|
||||
**/learning/benchmarks/
|
||||
**/teacher_traces/
|
||||
*.session.json
|
||||
|
||||
# Local dev artifacts (hybrid worker logs + cli debug dumps)
|
||||
minion_logs/
|
||||
*.oj-debug.json
|
||||
oj-debug.*.json
|
||||
|
||||
@@ -8,6 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
**Vision input for `jarvis ask`** — attach images to a query with
|
||||
`-i`/`--image` (repeatable) or capture the current screen with
|
||||
`-S`/`--screen`, for vision-capable models such as `gemma3:4b`. Images flow
|
||||
through `Message.images` into Ollama's `/api/chat` `images` field; text-only
|
||||
requests are unaffected. A privacy guard warns before any image is sent to a
|
||||
non-local engine, and the security guardrail now preserves images when it
|
||||
sanitizes a flagged prompt. Screen capture uses the built-in Windows .NET
|
||||
stack with `mss`/`Pillow` fallbacks on other platforms. Adds the
|
||||
`JARVIS_NUM_CTX` environment variable to tune the Ollama context window
|
||||
(default `16384`).
|
||||
|
||||
## [1.0.2] - 2026-05-24
|
||||
|
||||
A patch release that fixes a packaging bug which broke the v1.0.1
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.PHONY: setup build test lint format
|
||||
|
||||
# Mirrors .github/workflows/ci.yml so `make test` matches CI locally.
|
||||
|
||||
setup:
|
||||
uv sync --extra dev --extra framework-comparison --extra server
|
||||
|
||||
build:
|
||||
uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
|
||||
|
||||
test: build
|
||||
uv run pytest tests/ -n auto -q --tb=short -m "not live and not cloud and not hub"
|
||||
|
||||
lint:
|
||||
uv run ruff check src/ tests/
|
||||
uv run ruff format --check src/ tests/
|
||||
|
||||
format:
|
||||
uv run ruff format src/ tests/
|
||||
@@ -4,20 +4,29 @@
|
||||
<p><i>Personal AI, On Personal Devices.</i></p>
|
||||
|
||||
<p>
|
||||
<a href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://arxiv.org/abs/2605.17172"><img src="https://img.shields.io/badge/arXiv-2605.17172-b31b1b.svg" alt="arXiv"></a>
|
||||
<a href="https://openjarvis.stanford.edu/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
|
||||
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
|
||||
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
|
||||
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
|
||||
<a href="https://discord.gg/YZZRxCAhmm"><img src="https://img.shields.io/badge/discord-join-7289da?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://discord.gg/CMVBmDQ5Fj"><img src="https://img.shields.io/badge/discord-join-7289da?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/OpenJarvisAI"><img src="https://img.shields.io/badge/X-@OpenJarvisAI-black?logo=x&logoColor=white" alt="X / Twitter"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img alt="OpenJarvis demo reel" src="assets/openjarvis_demo_reel.webp" width="75%">
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **[Documentation](https://open-jarvis.github.io/OpenJarvis/)**
|
||||
>
|
||||
> **[Project Site](https://scalingintelligence.stanford.edu/blogs/openjarvis/)**
|
||||
> **[Project Site](https://openjarvis.stanford.edu/)**
|
||||
>
|
||||
> **[Paper](https://arxiv.org/abs/2605.17172)**
|
||||
>
|
||||
> **[Leaderboard](https://open-jarvis.github.io/OpenJarvis/leaderboard/)**
|
||||
>
|
||||
@@ -31,78 +40,44 @@ OpenJarvis is that stack. It is a framework for local-first personal AI, built a
|
||||
|
||||
## Installation
|
||||
|
||||
**macOS / Linux:**
|
||||
Pick your platform and run one command. Each installer handles [uv](https://docs.astral.sh/uv/), the Python venv, Ollama, and a starter model — about 3 minutes on broadband.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
| Platform | One-liner |
|
||||
|---|---|
|
||||
| **macOS · Linux · WSL2** | `curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh \| bash` |
|
||||
| **Native Windows** | `irm https://open-jarvis.github.io/OpenJarvis/install.ps1 \| iex` |
|
||||
| **Desktop GUI** | Download `.exe` / `.dmg` / `.deb` / `.rpm` / `.AppImage` from the [latest release](https://github.com/open-jarvis/OpenJarvis/releases) |
|
||||
|
||||
The installer handles everything for you — including [uv](https://docs.astral.sh/uv/), the Python venv, Ollama, and a small starter model. You don't need to install anything first.
|
||||
Then `jarvis` to start. The Rust extension and larger models continue downloading in the background; `jarvis doctor` shows status.
|
||||
|
||||
**Windows:** the installer is a `bash` script and won't run in PowerShell or `cmd`. Pick one of:
|
||||
|
||||
- **WSL2 (recommended for the CLI / Python SDK)** — one-time setup in an admin PowerShell, then run the same `curl … | bash` inside Ubuntu:
|
||||
```powershell
|
||||
wsl --install -d Ubuntu-24.04
|
||||
```
|
||||
Open the Ubuntu shell that gets installed, then follow [WSL2 install instructions](https://open-jarvis.github.io/OpenJarvis/getting-started/wsl2/).
|
||||
- **Desktop app** — download the [Windows installer (`.exe`)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe) from the latest [desktop release](https://github.com/open-jarvis/OpenJarvis/releases/tag/desktop-v1.0.2) (macOS `.dmg` and Linux `.deb`/`.rpm`/`.AppImage` are there too) for the GUI experience, no terminal required. **Prerequisite:** the desktop app expects [uv](https://docs.astral.sh/uv/) to be installed already — if it isn't, install it first in PowerShell, then launch the app:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
About 3 minutes on a typical broadband connection. Then:
|
||||
|
||||
```bash
|
||||
jarvis
|
||||
```
|
||||
|
||||
The Rust extension and bigger models continue downloading in the background while you chat. Run `jarvis doctor` to see status.
|
||||
|
||||
**Platforms:** macOS (Intel + Apple Silicon), Linux, WSL2 on Windows. Native Windows is not supported — use WSL2 or the desktop binary.
|
||||
|
||||
**Manual install / contributors:** see [docs/getting-started/install.md](docs/getting-started/install.md).
|
||||
Platform-specific notes (WSL2 setup, native-Windows scheduled-task service, desktop prerequisites, manual / contributor install): see the [installation docs](https://open-jarvis.github.io/OpenJarvis/getting-started/install/).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
jarvis
|
||||
jarvis # start chatting (default: chat-simple)
|
||||
jarvis init --preset <name> # switch to a starter config
|
||||
```
|
||||
|
||||
`jarvis init --preset <name>` switches to a starter config. Available presets: `morning-digest-mac`, `morning-digest-linux`, `morning-digest-minimal`, `deep-research`, `code-assistant`, `scheduled-monitor`, `chat-simple`.
|
||||
> Prefix `jarvis ...` with `uv run`, or `source .venv/bin/activate` first.
|
||||
|
||||
## Starter Configs
|
||||
| Preset | What it does |
|
||||
|---|---|
|
||||
| `morning-digest-mac` / `morning-digest-linux` / `morning-digest-minimal` | Spoken daily briefing from email, calendar, health, news |
|
||||
| `deep-research` | Multi-hop research across indexed docs with citations |
|
||||
| `code-assistant` | Agent with code execution, file I/O, and shell access |
|
||||
| `scheduled-monitor` | Stateful agent on a schedule with memory |
|
||||
| `chat-simple` | Lightweight conversation, no tools |
|
||||
|
||||
Install any preset with one command:
|
||||
Example:
|
||||
|
||||
```bash
|
||||
uv run jarvis init --preset morning-digest-mac # or any preset below
|
||||
jarvis init --preset morning-digest-mac
|
||||
jarvis connect gdrive # one OAuth covers Gmail / Calendar / Tasks
|
||||
jarvis digest --fresh # generate and play your first briefing
|
||||
```
|
||||
|
||||
> Prefix every `jarvis ...` invocation with `uv run`, or activate the venv first (`source .venv/bin/activate`) so plain `jarvis ...` works for the rest of your shell session.
|
||||
|
||||
| Preset | Use Case | What it does |
|
||||
|--------|----------|-------------|
|
||||
| `morning-digest-mac` | Daily Briefing (Mac) | Spoken briefing from email, calendar, health, news with Jarvis voice |
|
||||
| `morning-digest-linux` | Daily Briefing (Linux) | Same, with vLLM support for GPU servers |
|
||||
| `morning-digest-minimal` | Daily Briefing (minimal) | Just Gmail + Calendar, runs on any machine |
|
||||
| `deep-research` | Research Assistant | Multi-hop research across indexed docs with citations |
|
||||
| `code-assistant` | Code Companion | Agent with code execution, file I/O, and shell access |
|
||||
| `scheduled-monitor` | Persistent Monitor | Stateful agent that runs on a schedule with memory |
|
||||
| `chat-simple` | Simple Chat | Lightweight conversation, no tools needed |
|
||||
|
||||
```bash
|
||||
# Example: Morning Digest on Mac
|
||||
uv run jarvis init --preset morning-digest-mac
|
||||
uv run jarvis connect gdrive # one OAuth flow covers Gmail, Calendar, Tasks
|
||||
uv run jarvis digest --fresh # generate and play your first briefing
|
||||
|
||||
# Example: Deep Research
|
||||
uv run jarvis init --preset deep-research
|
||||
uv run jarvis memory index ./docs/ # requires the Rust extension — see Setup above
|
||||
uv run jarvis ask "Summarize all emails about Project X"
|
||||
```
|
||||
Per-preset deep dives: [morning digest](https://open-jarvis.github.io/OpenJarvis/user-guide/morning-digest/) · [deep research](https://open-jarvis.github.io/OpenJarvis/user-guide/deep-research/) · [code assistant](https://open-jarvis.github.io/OpenJarvis/user-guide/code-assistant/) · [scheduled monitor](https://open-jarvis.github.io/OpenJarvis/user-guide/scheduled-monitor/) · [chat simple](https://open-jarvis.github.io/OpenJarvis/user-guide/chat-simple/) · or the full [quickstart guide](https://open-jarvis.github.io/OpenJarvis/getting-started/quickstart/).
|
||||
|
||||
### Skills
|
||||
|
||||
@@ -149,7 +124,7 @@ Full documentation — including Docker deployment, cloud engines, development s
|
||||
## Community
|
||||
|
||||
- **GitHub:** [github.com/open-jarvis/OpenJarvis](https://github.com/open-jarvis/OpenJarvis)
|
||||
- **Discord:** [discord.gg/YZZRxCAhmm](https://discord.gg/YZZRxCAhmm)
|
||||
- **Discord:** [discord.gg/CMVBmDQ5Fj](https://discord.gg/CMVBmDQ5Fj)
|
||||
- **X / Twitter:** [@OpenJarvisAI](https://x.com/OpenJarvisAI)
|
||||
- **Docs:** [open-jarvis.github.io/OpenJarvis](https://open-jarvis.github.io/OpenJarvis/)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
@@ -106,6 +106,11 @@ enabled = true # Record traces for analysis
|
||||
db_path = "~/.openjarvis/traces.db"
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
# Bind to loopback by default so the API is not exposed to the local network.
|
||||
# To serve other devices on your LAN, set host = "0.0.0.0" AND set an API key
|
||||
# (OPENJARVIS_API_KEY / `jarvis auth generate-key`) — startup refuses a
|
||||
# non-loopback bind without a key. The "server" security profile also flips
|
||||
# this to 0.0.0.0 intentionally.
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
agent = "native_openhands"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Full system access: unrestricted shell and filesystem
|
||||
# Copy to ~/.openjarvis/config.toml
|
||||
#
|
||||
# WARNING: shell_exec runs arbitrary commands as your user. No command
|
||||
# allowlist, no denylist, no working-directory restriction. file_read and
|
||||
# file_write aren't restricted to any directory either. Only enable what you
|
||||
# actually want the agent to have. tools.enabled is the whole permission grant;
|
||||
# there's no second allowlist to configure.
|
||||
#
|
||||
# On macOS, this config alone does not reach TCC-protected data (Messages,
|
||||
# Mail, Photos, Safari). That requires Full Disk Access granted to the process
|
||||
# hosting the backend. See docs/user-guide/system-access.md.
|
||||
#
|
||||
# Usage:
|
||||
# jarvis ask "What's using the most disk space in my home directory?"
|
||||
# jarvis chat # prompts before each shell_exec call
|
||||
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[intelligence]
|
||||
default_model = "qwen3.5:9b"
|
||||
|
||||
[agent]
|
||||
default_agent = "orchestrator"
|
||||
max_turns = 10
|
||||
|
||||
[tools]
|
||||
enabled = [
|
||||
"shell_exec",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"apply_patch",
|
||||
"code_interpreter",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"think",
|
||||
"calculator",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copy to `.env` in this directory (deploy/docker/.env) before `docker compose up`.
|
||||
# docker-compose.yml requires this — the container binds 0.0.0.0, so the
|
||||
# server refuses to start without an API key.
|
||||
#
|
||||
# Generate a key with: jarvis auth generate-key
|
||||
# Then clients must send: Authorization: Bearer <key>
|
||||
OPENJARVIS_API_KEY=
|
||||
@@ -1,32 +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,30 +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 && \
|
||||
@@ -34,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,30 +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 && \
|
||||
@@ -34,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
|
||||
|
||||
@@ -8,13 +8,19 @@ services:
|
||||
environment:
|
||||
- OPENJARVIS_ENGINE_DEFAULT=ollama
|
||||
- OLLAMA_HOST=http://ollama:11434
|
||||
# The container binds 0.0.0.0, so an API key is REQUIRED. Compose fails
|
||||
# fast if OPENJARVIS_API_KEY is unset (set it in deploy/docker/.env —
|
||||
# see .env.example, or `export` it). Generate one: `jarvis auth generate-key`.
|
||||
- OPENJARVIS_API_KEY=${OPENJARVIS_API_KEY:?OPENJARVIS_API_KEY must be set (see deploy/docker/.env.example)}
|
||||
depends_on:
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
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:
|
||||
|
||||
@@ -4,15 +4,27 @@
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.openjarvis</string>
|
||||
<!-- Binds loopback only: the personal-device default, reachable from this
|
||||
Mac but not the network, so no API key is required. To expose it on
|
||||
your LAN, change the host below to 0.0.0.0 AND uncomment the
|
||||
EnvironmentVariables block to set an API key (an unauthenticated
|
||||
0.0.0.0 server will refuse to start). -->
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/jarvis</string>
|
||||
<string>serve</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
<string>127.0.0.1</string>
|
||||
<string>--port</string>
|
||||
<string>8000</string>
|
||||
</array>
|
||||
<!--
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>OPENJARVIS_API_KEY</key>
|
||||
<string>REPLACE_WITH_A_REAL_KEY</string>
|
||||
</dict>
|
||||
-->
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
|
||||
@@ -10,6 +10,31 @@ ExecStart=/opt/openjarvis/.venv/bin/jarvis serve --host 0.0.0.0 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=HOME=/opt/openjarvis
|
||||
# Binding 0.0.0.0 requires authentication. This file MUST exist and contain:
|
||||
# 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,126 @@
|
||||
# OpenJarvis on native Windows
|
||||
|
||||
Phase-1 of the native-Windows-support RFC (#298). Mirrors the Linux
|
||||
(`deploy/systemd/`) and macOS (`deploy/launchd/`) deployments — but for
|
||||
PowerShell, without WSL2 or Docker.
|
||||
|
||||
## One-liner install
|
||||
|
||||
In an elevated-or-regular PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://open-jarvis.github.io/OpenJarvis/install.ps1 | iex
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
1. Refuses non-Windows hosts and Windows < 10 1809.
|
||||
2. Checks Python 3.10 – 3.13 (3.14 has no numpy wheels yet — see #432).
|
||||
3. Checks `git` on PATH.
|
||||
4. Installs `uv` (https://astral.sh/uv) if absent.
|
||||
5. Clones the OpenJarvis repository to `%LOCALAPPDATA%\OpenJarvis`
|
||||
(override with `$env:OPENJARVIS_HOME`).
|
||||
6. Runs `uv sync --extra desktop --group desktop-native` so the FastAPI server,
|
||||
speech backend, and native extension are importable.
|
||||
7. Optionally prompts to register a scheduled task that auto-starts the
|
||||
server at logon.
|
||||
|
||||
Flags (when invoked directly rather than via `irm | iex`):
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `-Service` | Register the scheduled task without prompting |
|
||||
| `-SkipService` | Don't prompt; don't register |
|
||||
| `-Force` | Re-run all steps even if already done |
|
||||
|
||||
`irm | iex` can't pass `param()` args into a piped script string, so
|
||||
the same knobs are honored via env vars when the corresponding flag is
|
||||
absent:
|
||||
|
||||
```powershell
|
||||
$env:OPENJARVIS_SKIP_SERVICE = '1'
|
||||
irm https://open-jarvis.github.io/OpenJarvis/install.ps1 | iex
|
||||
```
|
||||
|
||||
The available env vars: `OPENJARVIS_SKIP_SERVICE`, `OPENJARVIS_SERVICE`,
|
||||
`OPENJARVIS_FORCE`. If you need richer control, save the script first
|
||||
(`irm ... -OutFile install.ps1; .\install.ps1 -Force`).
|
||||
|
||||
## Manual scheduled-task setup
|
||||
|
||||
If you skipped the prompt during install, you can register / inspect /
|
||||
remove the task with `jarvis-service.ps1`:
|
||||
|
||||
```powershell
|
||||
$srv = "$env:LOCALAPPDATA\OpenJarvis\src\deploy\windows\jarvis-service.ps1"
|
||||
|
||||
# install (idempotent — replaces existing)
|
||||
powershell -ExecutionPolicy Bypass -File $srv install
|
||||
|
||||
# status
|
||||
powershell -ExecutionPolicy Bypass -File $srv status
|
||||
|
||||
# remove
|
||||
powershell -ExecutionPolicy Bypass -File $srv uninstall
|
||||
```
|
||||
|
||||
The task runs as the current user with `LogonType=Interactive` and
|
||||
`RunLevel=Limited`. It restarts up to 3 times on failure (1-minute
|
||||
gap), has no execution-time limit, and starts when available (catches
|
||||
up if missed).
|
||||
|
||||
## Loopback vs LAN-exposed
|
||||
|
||||
By default the scheduled task binds `127.0.0.1` — reachable only from
|
||||
this machine, no API key required. This matches launchd parity (see
|
||||
`deploy/launchd/com.openjarvis.plist`).
|
||||
|
||||
To expose on your LAN:
|
||||
|
||||
```powershell
|
||||
# 1. Generate an API key. The server REFUSES to bind 0.0.0.0 without one.
|
||||
$env:OPENJARVIS_API_KEY = (uv run jarvis auth generate-key)
|
||||
|
||||
# 2. Re-register the task with -ListenHost 0.0.0.0.
|
||||
powershell -ExecutionPolicy Bypass -File $srv install -ListenHost 0.0.0.0
|
||||
```
|
||||
|
||||
`jarvis-service.ps1 install` refuses `-ListenHost 0.0.0.0` if
|
||||
`$env:OPENJARVIS_API_KEY` is unset — same guard as the systemd unit's
|
||||
`EnvironmentFile=/etc/openjarvis/env`.
|
||||
|
||||
## Parity table
|
||||
|
||||
| Concern | systemd | launchd | Windows |
|
||||
|---------|---------|---------|---------|
|
||||
| Service definition | `deploy/systemd/openjarvis.service` | `deploy/launchd/com.openjarvis.plist` | `deploy/windows/jarvis-service.ps1` (cmdlet-driven) |
|
||||
| Default bind | `0.0.0.0` (with API key) | `127.0.0.1` (no API key) | `127.0.0.1` (no API key) |
|
||||
| Restart on failure | `Restart=on-failure RestartSec=5` | `KeepAlive=true` | `RestartCount=3 RestartInterval=PT1M` |
|
||||
| Auto-start | `multi-user.target` | `RunAtLoad=true` | `AtLogOn` trigger |
|
||||
|
||||
## Updating
|
||||
|
||||
To pull the latest:
|
||||
|
||||
```powershell
|
||||
cd "$env:LOCALAPPDATA\OpenJarvis\src"
|
||||
git pull --ff-only
|
||||
uv sync --extra desktop --group desktop-native
|
||||
```
|
||||
|
||||
Or re-run the installer with `-Force`:
|
||||
|
||||
```powershell
|
||||
irm https://open-jarvis.github.io/OpenJarvis/install.ps1 | iex
|
||||
# (then re-run with the file directly, passing -Force)
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\OpenJarvis\src\deploy\windows\jarvis-service.ps1" uninstall
|
||||
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\OpenJarvis"
|
||||
```
|
||||
|
||||
Uninstalling does NOT remove `uv` (it's a separate tool — you may have
|
||||
other Python projects using it).
|
||||
@@ -0,0 +1,529 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
OpenJarvis native Windows installer.
|
||||
|
||||
.DESCRIPTION
|
||||
Phase-1 of the native-Windows-support RFC (#298). Mirrors the
|
||||
behavior of scripts/install/install.sh (the curl-pipe-bash installer
|
||||
for Linux/WSL2/macOS) but for native Windows PowerShell - no WSL,
|
||||
no Docker, no MSYS2.
|
||||
|
||||
Steps:
|
||||
1. Refuse non-Windows / Windows < 10.
|
||||
2. Check Python 3.10 - 3.13 on PATH (3.14 has no numpy wheels yet,
|
||||
see #432).
|
||||
3. Check git on PATH.
|
||||
4. Install uv (https://astral.sh/uv) if absent.
|
||||
5. Clone the OpenJarvis repository to $env:LOCALAPPDATA\OpenJarvis
|
||||
(override with $env:OPENJARVIS_HOME).
|
||||
6. Run `uv sync --extra desktop --group desktop-native` so the FastAPI
|
||||
server, speech backend, and native extension are importable.
|
||||
7. Optionally register the scheduled-task service (see
|
||||
deploy/windows/jarvis-service.ps1).
|
||||
|
||||
Usage (one-liner):
|
||||
irm https://open-jarvis.github.io/OpenJarvis/install.ps1 | iex
|
||||
|
||||
Usage (file invocation, supports flags):
|
||||
irm https://open-jarvis.github.io/OpenJarvis/install.ps1 -OutFile install.ps1
|
||||
.\install.ps1 -SkipService
|
||||
|
||||
Flags (when running the file directly):
|
||||
-SkipService Don't prompt for / install the scheduled task.
|
||||
-Service Install the scheduled task without prompting.
|
||||
-Force Re-run all steps even if already done.
|
||||
|
||||
Under `irm | iex` the param block is unreachable (Invoke-Expression
|
||||
can't pass named args into a piped script string), so the same knobs
|
||||
are honored via env vars when the corresponding flag is absent:
|
||||
$env:OPENJARVIS_SKIP_SERVICE = '1'
|
||||
$env:OPENJARVIS_SERVICE = '1'
|
||||
$env:OPENJARVIS_FORCE = '1'
|
||||
|
||||
.NOTES
|
||||
Loopback default: the scheduled-task service binds 127.0.0.1, so no
|
||||
API key is needed. To expose on the LAN, edit the registered task to
|
||||
pass `--host 0.0.0.0` AND set $env:OPENJARVIS_API_KEY (an
|
||||
unauthenticated 0.0.0.0 server refuses to start). See
|
||||
deploy/windows/README.md.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch] $SkipService,
|
||||
[switch] $Service,
|
||||
[switch] $Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Env-var fallback for the `irm | iex` path, where the param block is
|
||||
# unreachable (see header comment). Any explicit -switch wins; env vars
|
||||
# only fill in the gaps.
|
||||
if (-not $SkipService -and $env:OPENJARVIS_SKIP_SERVICE) { $SkipService = $true }
|
||||
if (-not $Service -and $env:OPENJARVIS_SERVICE) { $Service = $true }
|
||||
if (-not $Force -and $env:OPENJARVIS_FORCE) { $Force = $true }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output helpers - coloured but plain enough for Constrained Language Mode.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Write-Info ($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan }
|
||||
function Write-Ok ($msg) { Write-Host "[ok] $msg" -ForegroundColor Green }
|
||||
function Write-Warn2 ($msg) { Write-Host "[warn] $msg" -ForegroundColor Yellow }
|
||||
function Write-Fail ($msg) {
|
||||
Write-Host "[fail] $msg" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers - winget bootstrap + PATH refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pull the latest Machine + User PATH from the registry into the current
|
||||
# PowerShell session. Tools installed by `winget install` (Python, git,
|
||||
# Ollama, etc.) update the User PATH, but the running process inherits
|
||||
# the parent shell's environment - so without this refresh the just-
|
||||
# installed tool stays invisible to subsequent `Get-Command` calls.
|
||||
#
|
||||
# CRITICAL: registry PATH entries can be REG_EXPAND_SZ (with literal
|
||||
# `%VAR%` placeholders); the Python.org installer in per-user mode adds
|
||||
# entries like `%LOCALAPPDATA%\Programs\Python\Python313\` unexpanded.
|
||||
# `GetEnvironmentVariable` returns the raw string and PowerShell does
|
||||
# NOT auto-expand on assignment to `$env:Path`, so `Get-Command python`
|
||||
# would miss the just-installed binary. Expand explicitly.
|
||||
function Update-PathFromRegistry {
|
||||
$machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine')
|
||||
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
$combined = "$machinePath;$userPath"
|
||||
$env:Path = [System.Environment]::ExpandEnvironmentVariables($combined)
|
||||
}
|
||||
|
||||
# Bootstrap a tool by winget id. Returns the resolved command source on
|
||||
# success, $null on failure. Caller decides whether failure is fatal.
|
||||
function Install-WithWinget {
|
||||
param(
|
||||
[string] $WingetId, # e.g. 'Python.Python.3.13'
|
||||
[string] $CommandName # e.g. 'python' or 'git'
|
||||
)
|
||||
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
||||
# Windows 10 pre-2004 / Windows Server / locked-down corporate
|
||||
# images may not have winget. Fall back to the caller's manual
|
||||
# instructions.
|
||||
return $null
|
||||
}
|
||||
Write-Info " Installing $WingetId via winget (silent)..."
|
||||
& winget install --id $WingetId --silent --accept-source-agreements --accept-package-agreements 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn2 " winget install $WingetId exited $LASTEXITCODE"
|
||||
return $null
|
||||
}
|
||||
Update-PathFromRegistry
|
||||
$cmd = Get-Command $CommandName -ErrorAction SilentlyContinue
|
||||
if ($cmd) { return $cmd.Source }
|
||||
return $null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. OS check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Checking OS..."
|
||||
if ($PSVersionTable.Platform -and $PSVersionTable.Platform -ne 'Win32NT') {
|
||||
Write-Fail "install.ps1 is for native Windows. On Linux/macOS use install.sh."
|
||||
}
|
||||
|
||||
# Build number 17763 = Windows 10 1809 (the oldest LTS we test against).
|
||||
$build = [System.Environment]::OSVersion.Version.Build
|
||||
if ($build -lt 17763) {
|
||||
Write-Fail "Windows 10 1809 (build 17763) or newer is required. Detected build $build."
|
||||
}
|
||||
Write-Ok "Windows build $build"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Python check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Get-PythonCommand {
|
||||
# Prefer `python3` (matches our cross-platform helper convention),
|
||||
# fall back to `python` (the Windows store / python.org default).
|
||||
foreach ($name in @('python3', 'python')) {
|
||||
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($cmd) { return $cmd.Source }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
Write-Info "Checking Python (3.10 - 3.13)..."
|
||||
$pythonExe = Get-PythonCommand
|
||||
if (-not $pythonExe) {
|
||||
Write-Info "Python not on PATH - attempting auto-install via winget..."
|
||||
$pythonExe = Install-WithWinget -WingetId 'Python.Python.3.13' -CommandName 'python'
|
||||
if (-not $pythonExe) {
|
||||
Write-Fail @"
|
||||
Python 3.10 - 3.13 not found and auto-install via winget failed.
|
||||
|
||||
Install manually from https://python.org (check 'Add python.exe to PATH'
|
||||
during install) or via winget:
|
||||
|
||||
winget install Python.Python.3.13
|
||||
|
||||
Then re-run this installer.
|
||||
"@
|
||||
}
|
||||
}
|
||||
|
||||
$verRaw = & $pythonExe --version 2>&1
|
||||
$verMatch = [regex]::Match($verRaw, '(\d+)\.(\d+)\.(\d+)')
|
||||
if (-not $verMatch.Success) {
|
||||
Write-Fail "Could not parse Python version from: $verRaw"
|
||||
}
|
||||
$pyMajor = [int]$verMatch.Groups[1].Value
|
||||
$pyMinor = [int]$verMatch.Groups[2].Value
|
||||
if ($pyMajor -ne 3 -or $pyMinor -lt 10 -or $pyMinor -gt 13) {
|
||||
Write-Fail @"
|
||||
Found Python $pyMajor.$pyMinor at $pythonExe, but OpenJarvis requires
|
||||
3.10 - 3.13. Python 3.14 has no numpy Windows wheels yet (#432, will
|
||||
re-open once numpy ships cp314).
|
||||
"@
|
||||
}
|
||||
Write-Ok "Python $pyMajor.$pyMinor ($pythonExe)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. git check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Checking git..."
|
||||
$gitExe = (Get-Command git -ErrorAction SilentlyContinue).Source
|
||||
if (-not $gitExe) {
|
||||
Write-Info "git not on PATH - attempting auto-install via winget..."
|
||||
$gitExe = Install-WithWinget -WingetId 'Git.Git' -CommandName 'git'
|
||||
if (-not $gitExe) {
|
||||
Write-Fail @"
|
||||
git not found and auto-install via winget failed.
|
||||
|
||||
Install manually via winget:
|
||||
|
||||
winget install Git.Git
|
||||
|
||||
or download from https://git-scm.com, then re-run this installer.
|
||||
"@
|
||||
}
|
||||
}
|
||||
Write-Ok "git ($gitExe)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. uv check / install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Checking uv..."
|
||||
$uvExe = (Get-Command uv -ErrorAction SilentlyContinue).Source
|
||||
if (-not $uvExe) {
|
||||
Write-Info "Installing uv via astral.sh/uv (official PowerShell installer)..."
|
||||
try {
|
||||
Invoke-RestMethod -Uri 'https://astral.sh/uv/install.ps1' -UseBasicParsing | Invoke-Expression
|
||||
} catch {
|
||||
Write-Fail "uv install failed: $($_.Exception.Message)"
|
||||
}
|
||||
# The astral installer puts uv at %USERPROFILE%\.local\bin\uv.exe and
|
||||
# adds that dir to the User PATH. The current process's PATH isn't
|
||||
# refreshed automatically - prepend the install dir so the rest of
|
||||
# this script picks it up.
|
||||
$uvDir = Join-Path $env:USERPROFILE '.local\bin'
|
||||
if (Test-Path (Join-Path $uvDir 'uv.exe')) {
|
||||
$env:Path = "$uvDir;$env:Path"
|
||||
}
|
||||
$uvExe = (Get-Command uv -ErrorAction SilentlyContinue).Source
|
||||
if (-not $uvExe) {
|
||||
Write-Fail "uv installed but isn't on PATH. Re-open a fresh PowerShell and re-run."
|
||||
}
|
||||
}
|
||||
Write-Ok "uv ($uvExe)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Clone the repo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$installRoot = if ($env:OPENJARVIS_HOME) {
|
||||
$env:OPENJARVIS_HOME
|
||||
} else {
|
||||
Join-Path $env:LOCALAPPDATA 'OpenJarvis'
|
||||
}
|
||||
$srcDir = Join-Path $installRoot 'src'
|
||||
|
||||
Write-Info "Install root: $installRoot"
|
||||
|
||||
if (-not (Test-Path $installRoot)) {
|
||||
New-Item -ItemType Directory -Path $installRoot | Out-Null
|
||||
}
|
||||
|
||||
$repoUrl = if ($env:OPENJARVIS_REPO_URL) {
|
||||
$env:OPENJARVIS_REPO_URL
|
||||
} else {
|
||||
'https://github.com/open-jarvis/OpenJarvis.git'
|
||||
}
|
||||
|
||||
if (Test-Path (Join-Path $srcDir '.git')) {
|
||||
if ($Force) {
|
||||
Write-Info "Force: pulling latest from $repoUrl..."
|
||||
& $gitExe -C $srcDir pull --ff-only
|
||||
if ($LASTEXITCODE -ne 0) { Write-Fail "git pull failed" }
|
||||
} else {
|
||||
Write-Ok "Repository already cloned (use -Force to update)"
|
||||
}
|
||||
} else {
|
||||
Write-Info "Cloning $repoUrl..."
|
||||
& $gitExe clone --depth 1 $repoUrl $srcDir
|
||||
if ($LASTEXITCODE -ne 0) { Write-Fail "git clone failed" }
|
||||
Write-Ok "Cloned to $srcDir"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. uv sync --extra desktop --group desktop-native
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Running 'uv sync --extra desktop --group desktop-native' in $srcDir (this can take a few minutes)..."
|
||||
Push-Location $srcDir
|
||||
try {
|
||||
& $uvExe sync --extra desktop --group desktop-native
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail "uv sync failed with exit code $LASTEXITCODE. Check the output above."
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Ok "Dependencies installed"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Ollama - install + start + wait for daemon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Checking Ollama..."
|
||||
$ollamaExe = (Get-Command ollama -ErrorAction SilentlyContinue).Source
|
||||
if (-not $ollamaExe) {
|
||||
Write-Info " Ollama not on PATH - downloading the official installer (~150 MB)..."
|
||||
$ollamaSetup = Join-Path $env:TEMP 'OllamaSetup.exe'
|
||||
# SilentlyContinue is load-bearing in PS 5.1: the default progress
|
||||
# bar renderer slows Invoke-WebRequest down 30x on large downloads
|
||||
# (a known PS5.1 issue), turning a 30s download into 15+ minutes.
|
||||
$prevProgress = $ProgressPreference
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
try {
|
||||
Invoke-WebRequest `
|
||||
-Uri 'https://ollama.com/download/OllamaSetup.exe' `
|
||||
-OutFile $ollamaSetup `
|
||||
-UseBasicParsing
|
||||
} catch {
|
||||
Remove-Item $ollamaSetup -ErrorAction SilentlyContinue # clean up partial download
|
||||
$ProgressPreference = $prevProgress
|
||||
Write-Fail "Ollama download failed: $($_.Exception.Message)`nInstall manually from https://ollama.com, then re-run."
|
||||
} finally {
|
||||
$ProgressPreference = $prevProgress
|
||||
}
|
||||
# OllamaSetup.exe is built with NSIS, whose silent-install flag is
|
||||
# /S (uppercase). The Inno-Setup-style /silent would open the GUI
|
||||
# and hang `Start-Process -Wait` indefinitely.
|
||||
Write-Info " Running OllamaSetup.exe /S (this can take a minute)..."
|
||||
Start-Process -FilePath $ollamaSetup -ArgumentList '/S' -Wait
|
||||
Remove-Item $ollamaSetup -ErrorAction SilentlyContinue
|
||||
Update-PathFromRegistry
|
||||
$ollamaExe = (Get-Command ollama -ErrorAction SilentlyContinue).Source
|
||||
if (-not $ollamaExe) {
|
||||
Write-Fail "Ollama installer ran but 'ollama' isn't on PATH. Open a fresh PowerShell and re-run, or install manually from https://ollama.com."
|
||||
}
|
||||
}
|
||||
Write-Ok "Ollama ($ollamaExe)"
|
||||
|
||||
# Make sure the daemon is actually responsive before pulling. The Ollama
|
||||
# Windows installer launches the tray app at install time, but on a re-
|
||||
# run with an existing install the daemon may not be running yet.
|
||||
Write-Info "Waiting for Ollama daemon..."
|
||||
$ollamaReady = $false
|
||||
for ($i = 0; $i -lt 60; $i++) {
|
||||
# 'ollama list' writes to stderr until the daemon is reachable; under
|
||||
# $ErrorActionPreference='Stop' the 2>&1 merge surfaces that as a
|
||||
# terminating NativeCommandError that would abort the whole install on
|
||||
# the very first probe. Swallow it and rely on $LASTEXITCODE so the
|
||||
# Start-Process serve fallback below actually runs (issue #522).
|
||||
try { & $ollamaExe list 2>&1 | Out-Null } catch { }
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$ollamaReady = $true
|
||||
break
|
||||
}
|
||||
if ($i -eq 5) {
|
||||
# Daemon clearly isn't auto-running - start it ourselves. Ollama
|
||||
# for Windows uses the tray app `ollama app.exe`; falling back to
|
||||
# `ollama serve` works headless.
|
||||
Start-Process -FilePath $ollamaExe -ArgumentList 'serve' -WindowStyle Hidden -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if (-not $ollamaReady) {
|
||||
Write-Warn2 "Ollama daemon didn't become ready in 60s. Continuing - bg-orchestrator will retry later."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Pull a starter model (qwen3.5:2b - ~1.5 GB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$modelPullOk = $false
|
||||
if ($ollamaReady) {
|
||||
Write-Info "Pulling qwen3.5:2b (~1.5 GB) so 'jarvis' works on first run..."
|
||||
& $ollamaExe pull 'qwen3.5:2b'
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$modelPullOk = $true
|
||||
Write-Ok "Starter model ready"
|
||||
} else {
|
||||
Write-Warn2 "ollama pull failed; the bg-orchestrator will retry once Ollama is reachable."
|
||||
}
|
||||
} else {
|
||||
Write-Warn2 "Skipping model pull - daemon wasn't ready."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. jarvis.cmd shim - so bare `jarvis` works in any new PowerShell
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$binDir = Join-Path $installRoot 'bin'
|
||||
$shimPath = Join-Path $binDir 'jarvis.cmd'
|
||||
|
||||
if (-not (Test-Path $binDir)) {
|
||||
New-Item -ItemType Directory -Path $binDir | Out-Null
|
||||
}
|
||||
|
||||
# %~dp0 in a .cmd file resolves to the directory containing the script,
|
||||
# so the shim is self-locating - moving %LOCALAPPDATA%\OpenJarvis won't
|
||||
# break it as long as the user moves the whole tree. `uv` is resolved
|
||||
# from PATH at runtime (astral installer adds it to User PATH); avoids
|
||||
# pinning to the install-time uv.exe path which can shift on uv updates.
|
||||
$shimContent = @"
|
||||
@echo off
|
||||
setlocal
|
||||
set "SRC=%~dp0..\src"
|
||||
uv run --project "%SRC%" jarvis %*
|
||||
"@
|
||||
Set-Content -Path $shimPath -Value $shimContent -Encoding ASCII
|
||||
|
||||
# Add %LOCALAPPDATA%\OpenJarvis\bin to User PATH if it isn't already
|
||||
# there. The current process won't see it until restart - handled in the
|
||||
# final banner.
|
||||
#
|
||||
# Compare against the EXPANDED form: a previous install may have written
|
||||
# the entry as `%LOCALAPPDATA%\OpenJarvis\bin` (unexpanded) into User
|
||||
# PATH, and a literal `-ieq` against the expanded `$binDir` would miss
|
||||
# it and append a duplicate every re-run.
|
||||
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
$pathOnUser = $false
|
||||
if ($userPath) {
|
||||
foreach ($entry in ($userPath -split ';')) {
|
||||
$expanded = [System.Environment]::ExpandEnvironmentVariables($entry)
|
||||
if ($expanded -ieq $binDir) { $pathOnUser = $true; break }
|
||||
}
|
||||
}
|
||||
$pathNeedsRefresh = $false
|
||||
if (-not $pathOnUser) {
|
||||
$newUserPath = if ($userPath) { "$userPath;$binDir" } else { $binDir }
|
||||
[System.Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User')
|
||||
$pathNeedsRefresh = $true
|
||||
}
|
||||
Write-Ok "jarvis shim installed at $shimPath"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Optional: register the scheduled-task service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$serviceScript = Join-Path $srcDir 'deploy\windows\jarvis-service.ps1'
|
||||
$shouldInstallService = $false
|
||||
|
||||
# Pre-check admin if the user wants the service - Register-ScheduledTask
|
||||
# requires elevation. We do this before the prompt so we don't ask "do
|
||||
# you want the service?" only to fail with Access Denied after they say
|
||||
# yes.
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal] `
|
||||
[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
|
||||
if ($Service -and -not $isAdmin) {
|
||||
Write-Fail "-Service was requested, but this PowerShell is not elevated. Register-ScheduledTask needs admin rights - re-run from an elevated PowerShell, or drop -Service."
|
||||
}
|
||||
if ($Service) {
|
||||
$shouldInstallService = $true
|
||||
} elseif ($SkipService) {
|
||||
$shouldInstallService = $false
|
||||
} elseif (-not $isAdmin) {
|
||||
# Default to skip-with-explanation when we can't elevate, rather
|
||||
# than prompting and then failing at Register-ScheduledTask.
|
||||
Write-Warn2 "Skipping scheduled-task setup - this PowerShell is not elevated."
|
||||
Write-Warn2 " Register-ScheduledTask requires admin. To install the service later:"
|
||||
Write-Warn2 " Right-click PowerShell -> Run as administrator, then run:"
|
||||
Write-Warn2 " powershell -ExecutionPolicy Bypass -File `"$serviceScript`" install"
|
||||
} else {
|
||||
# Interactive prompt only when there's a real user at the keyboard
|
||||
# AND stdin isn't piped. [Environment]::UserInteractive is the
|
||||
# canonical PowerShell idiom for "is this a user session" (false for
|
||||
# services, scheduled tasks, etc); we additionally guard against the
|
||||
# `irm | iex` case where stdin is redirected.
|
||||
$isInteractive = [Environment]::UserInteractive `
|
||||
-and -not [System.Console]::IsInputRedirected
|
||||
if ($isInteractive) {
|
||||
$reply = Read-Host "Register OpenJarvis as a Windows scheduled task (auto-start at logon, loopback only)? [y/N]"
|
||||
$shouldInstallService = ($reply -match '^[yY]')
|
||||
} else {
|
||||
Write-Warn2 "Non-interactive install - skipping scheduled-task setup."
|
||||
Write-Warn2 "To register the service later, run (from an elevated PowerShell):"
|
||||
Write-Warn2 " powershell -ExecutionPolicy Bypass -File `"$serviceScript`" install"
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldInstallService) {
|
||||
if (-not (Test-Path $serviceScript)) {
|
||||
Write-Fail "Service script not found at $serviceScript (the clone may be missing files; try -Force)."
|
||||
}
|
||||
Write-Info "Installing scheduled task..."
|
||||
& powershell -ExecutionPolicy Bypass -File $serviceScript install -InstallRoot $installRoot
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail "Scheduled task setup failed."
|
||||
}
|
||||
Write-Ok "Scheduled task 'OpenJarvis' registered (loopback default)."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Final message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " +----------------------------------+" -ForegroundColor Green
|
||||
Write-Host " | OpenJarvis install complete |" -ForegroundColor Green
|
||||
Write-Host " +----------------------------------+" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " Repo: $srcDir"
|
||||
|
||||
# Tell the truth about what the user can run next, given (a) whether the
|
||||
# starter model finished pulling and (b) whether the User-PATH update
|
||||
# needs a fresh PowerShell to take effect.
|
||||
$nextCmd = if ($modelPullOk) { 'jarvis' } else { 'jarvis doctor' }
|
||||
|
||||
if ($pathNeedsRefresh) {
|
||||
Write-Host ""
|
||||
Write-Host " Run it: open a NEW PowerShell, then: $nextCmd" -ForegroundColor Yellow
|
||||
Write-Host " (the jarvis shim was added to your User PATH; the"
|
||||
Write-Host " current PowerShell won't see it until restart)"
|
||||
} else {
|
||||
Write-Host " Run it: $nextCmd"
|
||||
}
|
||||
|
||||
if (-not $modelPullOk) {
|
||||
Write-Host ""
|
||||
Write-Host " NOTE: the qwen3.5:2b model didn't finish downloading." -ForegroundColor Yellow
|
||||
Write-Host " Chat will fail until the bg-orchestrator finishes the retry."
|
||||
Write-Host " 'jarvis doctor' shows progress."
|
||||
}
|
||||
|
||||
if ($shouldInstallService) {
|
||||
Write-Host ""
|
||||
Write-Host " Service: schtasks /Query /TN OpenJarvis (status)"
|
||||
Write-Host " powershell -File `"$serviceScript`" uninstall (remove)"
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host " Docs: https://open-jarvis.github.io/OpenJarvis/"
|
||||
Write-Host ""
|
||||
@@ -0,0 +1,208 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Register / unregister the OpenJarvis Windows scheduled task.
|
||||
|
||||
.DESCRIPTION
|
||||
The Windows equivalent of deploy/systemd/openjarvis.service and
|
||||
deploy/launchd/com.openjarvis.plist.
|
||||
|
||||
Registers a per-user scheduled task named "OpenJarvis" that starts
|
||||
`jarvis serve` at logon and restarts on failure. Loopback default
|
||||
(127.0.0.1) so no API key is required — matches launchd parity.
|
||||
|
||||
Subcommands:
|
||||
install — create or replace the task
|
||||
uninstall — remove the task
|
||||
status — show task state
|
||||
|
||||
Arguments (install only):
|
||||
-InstallRoot <path> default: %LOCALAPPDATA%\OpenJarvis (matches
|
||||
install.ps1's default)
|
||||
-ListenHost <addr> default: 127.0.0.1 (loopback). Set to 0.0.0.0
|
||||
ONLY if you also set $env:OPENJARVIS_API_KEY
|
||||
— the server refuses to start unauthenticated
|
||||
on a non-loopback bind.
|
||||
-ListenPort <int> default: 8000
|
||||
|
||||
Usage:
|
||||
powershell -ExecutionPolicy Bypass -File jarvis-service.ps1 install
|
||||
powershell -ExecutionPolicy Bypass -File jarvis-service.ps1 uninstall
|
||||
powershell -ExecutionPolicy Bypass -File jarvis-service.ps1 status
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[ValidateSet('install', 'uninstall', 'status')]
|
||||
[string] $Command = 'status',
|
||||
|
||||
[string] $InstallRoot,
|
||||
[string] $ListenHost = '127.0.0.1',
|
||||
[int] $ListenPort = 8000
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$TaskName = 'OpenJarvis'
|
||||
|
||||
function Write-Info ($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan }
|
||||
function Write-Ok ($msg) { Write-Host "[ok] $msg" -ForegroundColor Green }
|
||||
function Write-Warn2 ($msg) { Write-Host "[warn] $msg" -ForegroundColor Yellow }
|
||||
function Write-Fail ($msg) {
|
||||
Write-Host "[fail] $msg" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-DefaultInstallRoot {
|
||||
# Use $script: prefix so this is robust to being called from any
|
||||
# function scope (PowerShell's default dynamic lookup would also
|
||||
# work today, but $script: is the explicit contract).
|
||||
if ($script:InstallRoot) { return $script:InstallRoot }
|
||||
if ($env:OPENJARVIS_HOME) { return $env:OPENJARVIS_HOME }
|
||||
return (Join-Path $env:LOCALAPPDATA 'OpenJarvis')
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Install-Task {
|
||||
$root = Get-DefaultInstallRoot
|
||||
$srcDir = Join-Path $root 'src'
|
||||
if (-not (Test-Path $srcDir)) {
|
||||
Write-Fail "OpenJarvis source not found at $srcDir. Run install.ps1 first."
|
||||
}
|
||||
|
||||
$uvCmd = Get-Command uv -ErrorAction SilentlyContinue
|
||||
if (-not $uvCmd) {
|
||||
$uvFallback = Join-Path $env:USERPROFILE '.local\bin\uv.exe'
|
||||
if (Test-Path $uvFallback) {
|
||||
$uvPath = $uvFallback
|
||||
} else {
|
||||
Write-Fail "uv.exe not found on PATH or at $uvFallback. Re-run install.ps1."
|
||||
}
|
||||
} else {
|
||||
$uvPath = $uvCmd.Source
|
||||
}
|
||||
|
||||
# Safety: refuse to register a non-loopback bind without an API key.
|
||||
# Mirrors deploy/systemd/openjarvis.service's EnvironmentFile guard.
|
||||
$isLoopback = ($ListenHost -eq '127.0.0.1' -or $ListenHost -eq 'localhost')
|
||||
if (-not $isLoopback -and -not $env:OPENJARVIS_API_KEY) {
|
||||
Write-Fail @"
|
||||
ListenHost is $ListenHost (non-loopback) but `$env:OPENJARVIS_API_KEY is
|
||||
not set. An unauthenticated non-loopback bind is refused by jarvis serve
|
||||
and would also create a security hole. Set the env var first:
|
||||
|
||||
`$env:OPENJARVIS_API_KEY = (uv run jarvis auth generate-key)
|
||||
|
||||
then re-run with -ListenHost 0.0.0.0.
|
||||
"@
|
||||
}
|
||||
|
||||
# CRITICAL: scheduled tasks do NOT inherit the registering session's
|
||||
# environment. If we registered the task now and stopped here, the
|
||||
# task would launch at logon with a clean env, find no API key, and
|
||||
# `jarvis serve` would refuse to bind 0.0.0.0 — failing silently every
|
||||
# logon. Persist the key to the User env scope so the task's logon
|
||||
# session picks it up. (Loopback path doesn't need the key, so this
|
||||
# only runs for the explicit LAN-exposed case.)
|
||||
if (-not $isLoopback) {
|
||||
Write-Info "Persisting OPENJARVIS_API_KEY to User environment so the scheduled task can read it at logon."
|
||||
[System.Environment]::SetEnvironmentVariable(
|
||||
'OPENJARVIS_API_KEY',
|
||||
$env:OPENJARVIS_API_KEY,
|
||||
'User'
|
||||
)
|
||||
}
|
||||
|
||||
Write-Info "Registering scheduled task '$TaskName'..."
|
||||
Write-Info " Working dir : $srcDir"
|
||||
Write-Info " Listen : $ListenHost`:$ListenPort"
|
||||
Write-Info " User : $env:USERNAME"
|
||||
|
||||
# If a previous task exists, remove it first (idempotent install).
|
||||
$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Info "Existing task found — replacing."
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction `
|
||||
-Execute $uvPath `
|
||||
-Argument "run jarvis serve --host $ListenHost --port $ListenPort" `
|
||||
-WorkingDirectory $srcDir
|
||||
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
||||
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable `
|
||||
-RestartCount 3 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Seconds 0)
|
||||
|
||||
$principal = New-ScheduledTaskPrincipal `
|
||||
-UserId $env:USERNAME `
|
||||
-LogonType Interactive `
|
||||
-RunLevel Limited
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $TaskName `
|
||||
-Action $action `
|
||||
-Trigger $trigger `
|
||||
-Settings $settings `
|
||||
-Principal $principal `
|
||||
-Description 'OpenJarvis API server (loopback default — see deploy/windows/README.md)' | Out-Null
|
||||
|
||||
Write-Ok "Task '$TaskName' registered."
|
||||
Write-Info "It will start automatically at next logon."
|
||||
Write-Info "To start it now: Start-ScheduledTask -TaskName $TaskName"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# uninstall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Uninstall-Task {
|
||||
$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if (-not $existing) {
|
||||
Write-Warn2 "Task '$TaskName' is not registered — nothing to remove."
|
||||
return
|
||||
}
|
||||
Write-Info "Stopping '$TaskName' (if running)..."
|
||||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Write-Info "Unregistering '$TaskName'..."
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
||||
Write-Ok "Task '$TaskName' removed."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Show-Status {
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if (-not $task) {
|
||||
Write-Host "Task '$TaskName' is not registered."
|
||||
Write-Host "Install it with:"
|
||||
Write-Host " powershell -ExecutionPolicy Bypass -File `"$PSCommandPath`" install"
|
||||
return
|
||||
}
|
||||
$info = Get-ScheduledTaskInfo -TaskName $TaskName
|
||||
Write-Host "Task : $TaskName"
|
||||
Write-Host "State : $($task.State)"
|
||||
Write-Host "LastRun : $($info.LastRunTime)"
|
||||
Write-Host "LastRes : 0x$('{0:X8}' -f $info.LastTaskResult)"
|
||||
Write-Host "NextRun : $($info.NextRunTime)"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
switch ($Command) {
|
||||
'install' { Install-Task }
|
||||
'uninstall' { Uninstall-Task }
|
||||
'status' { Show-Status }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Showcase screenshots
|
||||
|
||||
This directory holds the hero screenshot for each Showcase entry in `docs/showcase/`. Convention is one file per entry, named to match the entry's slug:
|
||||
|
||||
| Entry | Screenshot path |
|
||||
|---|---|
|
||||
| `docs/showcase/morning-brief.md` | `morning-brief.png` |
|
||||
| `docs/showcase/persistent-memory.md` | `persistent-memory.png` |
|
||||
| `docs/showcase/cost-savings.md` | `cost-savings.png` |
|
||||
| `docs/showcase/discord-companion.md` | `discord-companion.png` |
|
||||
| `docs/showcase/coding-assistant.md` | `coding-assistant.png` |
|
||||
|
||||
## Conventions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Format | PNG, sRGB, no alpha channel |
|
||||
| Size | 1600×1000 (4:2.5 — wider than 16:9, so screenshots don't get letterboxed in the docs grid) |
|
||||
| File size | Under 400 KB after `pngquant --quality 70-90 --speed 1` |
|
||||
| Loading | All `<img>` and `<figure>` tags in showcase pages use `loading=lazy` — these images are below the fold on the gallery page |
|
||||
|
||||
## What to redact
|
||||
|
||||
- Real email addresses
|
||||
- API keys, OAuth tokens, anything starting with `sk-`, `ghp_`, `xox`, `eyJ`
|
||||
- Personal phone numbers
|
||||
- Conversation partners' faces or full names (unless they've signed off)
|
||||
- File paths that include other people's home directories
|
||||
|
||||
## What to keep
|
||||
|
||||
- Model names ("llama3.1:8b", "qwen2.5:14b") — they're informative
|
||||
- Timestamps — proves the screenshot is recent
|
||||
- Dollar amounts on the leaderboard — the whole point
|
||||
- Emoji reactions, your own first name, your own avatar
|
||||
|
||||
## Placeholder PNGs
|
||||
|
||||
This directory ships with no images on the initial PR. The Showcase pages reference image paths that don't exist yet — MkDocs will render a broken-image placeholder, and the figcaption still conveys what should be there. Real screenshots arrive in follow-up PRs as Showcase entries are populated with each contributor's actual setup.
|
||||
|
||||
If you're contributing the first real entry, drop your PNG at `docs/assets/showcase/<your-slug>.png` in the same PR that adds your markdown page. The image filename must match the slug used in the showcase page's `<img>` reference.
|
||||
|
||||
## Regenerating screenshots in bulk
|
||||
|
||||
A future enhancement (tracked as PR #3 in the showcase-tier roadmap) will add `scripts/showcase/regen_screenshots.py` — a Playwright-driven pipeline that boots a demo `jarvis serve` against a sealed config and captures fresh screenshots for every showcase entry on each release tag. Until that lands, screenshots are contributed manually by each Showcase author.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -4,12 +4,25 @@ OpenJarvis provides Docker images for both CPU-only and GPU-accelerated deployme
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to get OpenJarvis running in Docker is with Docker Compose, which starts both the API server and an Ollama backend:
|
||||
The container binds `0.0.0.0`, so an **API key is required** — the server
|
||||
refuses to start on a non-loopback address without one. Set it first:
|
||||
|
||||
```bash
|
||||
cd deploy/docker
|
||||
cp .env.example .env
|
||||
echo "OPENJARVIS_API_KEY=$(jarvis auth generate-key)" > .env # or paste your own
|
||||
```
|
||||
|
||||
Then start both the API server and an Ollama backend with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker compose` reads `OPENJARVIS_API_KEY` from `.env` (or your shell
|
||||
environment) and fails fast if it is unset. Clients must then send
|
||||
`Authorization: Bearer <key>` on `/v1/*` and `/api/*` requests.
|
||||
|
||||
This brings up two services:
|
||||
|
||||
| Service | Port | Description |
|
||||
|
||||
@@ -24,6 +24,14 @@ launchctl load ~/Library/LaunchAgents/com.openjarvis.plist
|
||||
|
||||
The service starts immediately (due to `RunAtLoad`) and will automatically restart at each login.
|
||||
|
||||
!!! note "Binds loopback by default"
|
||||
The plist binds `127.0.0.1` — reachable from this Mac but not the network,
|
||||
the right default for a personal device, and no API key is needed. To
|
||||
expose it on your LAN, change the host to `0.0.0.0` **and** uncomment the
|
||||
`EnvironmentVariables` block to set `OPENJARVIS_API_KEY`
|
||||
(`jarvis auth generate-key`); an unauthenticated `0.0.0.0` server refuses
|
||||
to start.
|
||||
|
||||
Verify it is running:
|
||||
|
||||
```bash
|
||||
@@ -55,10 +63,17 @@ The provided plist file at `deploy/launchd/com.openjarvis.plist`:
|
||||
<string>/usr/local/bin/jarvis</string>
|
||||
<string>serve</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
<string>127.0.0.1</string>
|
||||
<string>--port</string>
|
||||
<string>8000</string>
|
||||
</array>
|
||||
<!-- To expose on the LAN: set host to 0.0.0.0 and uncomment this block.
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>OPENJARVIS_API_KEY</key>
|
||||
<string>REPLACE_WITH_A_REAL_KEY</string>
|
||||
</dict>
|
||||
-->
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
@@ -76,7 +91,7 @@ The provided plist file at `deploy/launchd/com.openjarvis.plist`:
|
||||
| Key | Value | Description |
|
||||
|----------------------|--------------------------------|------------------------------------------------------------------------------------------------------|
|
||||
| `Label` | `com.openjarvis` | Unique identifier for the service. Used with `launchctl` commands to manage the service. |
|
||||
| `ProgramArguments` | `["/usr/local/bin/jarvis", "serve", "--host", "0.0.0.0", "--port", "8000"]` | The command and arguments to execute. Each element of the command line is a separate string in the array. |
|
||||
| `ProgramArguments` | `["/usr/local/bin/jarvis", "serve", "--host", "127.0.0.1", "--port", "8000"]` | The command and arguments to execute. Binds loopback by default; see the note above to expose on the LAN with an API key. |
|
||||
| `RunAtLoad` | `true` | Start the service immediately when the plist is loaded (and on each login). |
|
||||
| `KeepAlive` | `true` | Automatically restart the service if it exits for any reason. launchd monitors the process and relaunches it. |
|
||||
| `StandardOutPath` | `/tmp/openjarvis.stdout.log` | File where standard output is written. Contains server startup messages and access logs. |
|
||||
|
||||
@@ -21,7 +21,17 @@ cd /opt/openjarvis/OpenJarvis && sudo -u openjarvis uv sync --extra server
|
||||
|
||||
## Installing the Service
|
||||
|
||||
Copy the unit file to the systemd directory, reload the daemon, and enable the service:
|
||||
The unit binds `0.0.0.0`, so an **API key is required** — and the unit
|
||||
declares `EnvironmentFile=/etc/openjarvis/env` (no `-` prefix), so it will
|
||||
**fail to start** until that file exists with a key. Create it first:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/openjarvis
|
||||
echo "OPENJARVIS_API_KEY=$(jarvis auth generate-key)" | sudo tee /etc/openjarvis/env
|
||||
sudo chmod 600 /etc/openjarvis/env
|
||||
```
|
||||
|
||||
Then copy the unit file, reload the daemon, and enable the service:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/systemd/openjarvis.service /etc/systemd/system/
|
||||
@@ -30,6 +40,10 @@ sudo systemctl enable openjarvis
|
||||
sudo systemctl start openjarvis
|
||||
```
|
||||
|
||||
Clients must send `Authorization: Bearer <key>` on `/v1/*` and `/api/*`
|
||||
requests. (If you instead bind to `127.0.0.1`, the key is optional and you
|
||||
can drop the `EnvironmentFile` line.)
|
||||
|
||||
Verify it is running:
|
||||
|
||||
```bash
|
||||
|
||||
+2
-2
@@ -94,7 +94,7 @@ cd OpenJarvis
|
||||
|
||||
The script handles everything:
|
||||
|
||||
1. Checks for Python 3.10+ and Node.js 18+
|
||||
1. Checks for Python 3.10–3.13 and Node.js 18+
|
||||
2. Installs Ollama if not present and pulls a starter model
|
||||
3. Installs Python and frontend dependencies
|
||||
4. Starts the backend API server and frontend dev server
|
||||
@@ -109,7 +109,7 @@ If you prefer to run each step yourself:
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra server
|
||||
uv sync --extra desktop
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
"""Publish the canonical install.sh into the docs site.
|
||||
"""Publish the canonical install scripts into the docs site.
|
||||
|
||||
Serves the installers at::
|
||||
|
||||
https://open-jarvis.github.io/OpenJarvis/install.sh (Linux / macOS / WSL2)
|
||||
https://open-jarvis.github.io/OpenJarvis/install.ps1 (native Windows)
|
||||
|
||||
Serves the installer at ``https://open-jarvis.github.io/OpenJarvis/install.sh``
|
||||
so users have an HTTPS-valid, project-controlled install URL that does not
|
||||
depend on the externally-hosted ``openjarvis.ai`` domain — whose TLS config
|
||||
broke and which the project does not control (issue #337).
|
||||
|
||||
Single source of truth: the script lives at ``scripts/install/install.sh``
|
||||
(also bundled into the wheel as ``_install_scripts/``). This copies it
|
||||
verbatim into the built site at ``install.sh`` on every ``mkdocs build``,
|
||||
so the published copy can never drift from the canonical one.
|
||||
Single source of truth: the scripts live under ``scripts/install/`` and
|
||||
``deploy/windows/`` (also bundled into the wheel as ``_install_scripts/``).
|
||||
This copies them verbatim into the built site on every ``mkdocs build``,
|
||||
so the published copies can never drift from the canonical ones.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import mkdocs_gen_files
|
||||
|
||||
_SRC = Path("scripts/install/install.sh")
|
||||
# (source path, published URL path)
|
||||
_SCRIPTS = [
|
||||
(Path("scripts/install/install.sh"), "install.sh"),
|
||||
(Path("deploy/windows/install.ps1"), "install.ps1"),
|
||||
]
|
||||
|
||||
with mkdocs_gen_files.open("install.sh", "wb") as dst:
|
||||
dst.write(_SRC.read_bytes())
|
||||
for src, dest in _SCRIPTS:
|
||||
with mkdocs_gen_files.open(dest, "wb") as out:
|
||||
out.write(src.read_bytes())
|
||||
|
||||
@@ -17,6 +17,46 @@ The configuration file lives at:
|
||||
|
||||
OpenJarvis creates the `~/.openjarvis/` directory and populates it with a default config when you run `jarvis init`.
|
||||
|
||||
## Relocating the OpenJarvis directory
|
||||
|
||||
OpenJarvis keeps **all** of its state — config, databases, caches, logs,
|
||||
credentials, skills, recipes, connectors — under a **single root** so it never
|
||||
clutters your home directory beyond one folder. By default that root is
|
||||
`~/.openjarvis`, but you can move it.
|
||||
|
||||
The root is resolved in priority order:
|
||||
|
||||
1. **`$OPENJARVIS_HOME`** — explicit override. Honored by both the installer
|
||||
and the Python runtime.
|
||||
2. **`$XDG_DATA_HOME/openjarvis`** — used when `$XDG_DATA_HOME` is set (a single
|
||||
`openjarvis` directory nested under it, per the XDG Base Directory spec).
|
||||
3. **`~/.openjarvis`** — the default. With no environment variables set, the
|
||||
resolved path is exactly this, so existing installs are untouched.
|
||||
|
||||
```bash
|
||||
# Relocate the whole install + runtime tree at install time:
|
||||
OPENJARVIS_HOME=~/apps/openjarvis curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
|
||||
# Or for a single run / your shell profile:
|
||||
export OPENJARVIS_HOME=~/apps/openjarvis
|
||||
```
|
||||
|
||||
Confirm where your data lives with:
|
||||
|
||||
```bash
|
||||
jarvis config path
|
||||
```
|
||||
|
||||
!!! note "Migration"
|
||||
Because the default is unchanged, **no data migration is required** for
|
||||
existing installs. If you set `OPENJARVIS_HOME` (or `XDG_DATA_HOME`) on a
|
||||
machine that already has data in `~/.openjarvis`, OpenJarvis will look in
|
||||
the new location and not see your old data — move it yourself if you want
|
||||
to keep it: `mv ~/.openjarvis "$OPENJARVIS_HOME"`.
|
||||
|
||||
`$OPENJARVIS_CONFIG` still points at an explicit `config.toml` file
|
||||
independently of the root, if you need to override just the config file path.
|
||||
|
||||
## Generating Configuration
|
||||
|
||||
### First-Time Setup
|
||||
@@ -564,7 +604,7 @@ enforce_tool_confirmation = true
|
||||
| `scan_output` | bool | `true` | Whether to scan model output. |
|
||||
| `secret_scanner` | bool | `true` | Enable secret detection (API keys, tokens, passwords). |
|
||||
| `pii_scanner` | bool | `true` | Enable PII detection (emails, SSNs, credit cards). |
|
||||
| `enforce_tool_confirmation` | bool | `true` | Require confirmation before executing tools. |
|
||||
| `enforce_tool_confirmation` | bool | `true` | Accepted but **not currently enforced**. Whether you get prompts depends on the entry point. See [System Access](../user-guide/system-access.md#confirmation-behaviour). |
|
||||
|
||||
!!! tip "Choosing a security mode"
|
||||
Use `"warn"` during development to see what would be flagged without disrupting output.
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
# Installation
|
||||
|
||||
OpenJarvis ships a one-line installer for macOS, Linux, and WSL2.
|
||||
## Platform-specific guides
|
||||
|
||||
| Platform | One-liner | Detailed guide |
|
||||
|---|---|---|
|
||||
| **macOS** | `curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh \| bash` | [macOS install](macos.md) |
|
||||
| **Linux** | `curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh \| bash` | [Linux install](linux.md) |
|
||||
| **WSL2 on Windows** | `curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh \| bash` (run inside Ubuntu) | [WSL2 install](wsl2.md) |
|
||||
| **Native Windows** | `irm https://open-jarvis.github.io/OpenJarvis/install.ps1 \| iex` | [Native Windows install](windows-native.md) |
|
||||
| **Desktop GUI** | Download from the [latest release](https://github.com/open-jarvis/OpenJarvis/releases) | — |
|
||||
|
||||
The bash and PowerShell installers do the same thing on their respective hosts. The rest of this page documents the bash installer in detail; the [native Windows guide](windows-native.md) is the equivalent reference for PowerShell.
|
||||
|
||||
## Bash installer
|
||||
|
||||
```bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
|
||||
@@ -41,7 +41,7 @@ If you prefer to run each step yourself:
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra server
|
||||
uv sync --extra desktop
|
||||
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
@@ -238,7 +238,7 @@ See the [Python SDK guide](../user-guide/python-sdk.md) for the full API referen
|
||||
|
||||
| Requirement | Version | Install | Notes |
|
||||
|-------------|---------|---------|-------|
|
||||
| Python | 3.10+ | [python.org](https://www.python.org/downloads/) | Required |
|
||||
| Python | 3.10–3.13 | [python.org](https://www.python.org/downloads/) | Required. 3.14+ not yet supported (a core dependency lacks 3.14 wheels). |
|
||||
| uv | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` or `brew install uv` (macOS) | Python package & project manager |
|
||||
| Git | any | [git-scm.com](https://git-scm.com/) or `brew install git` (macOS) | Required |
|
||||
| Rust | stable | `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \| sh` | Required for the Rust extension |
|
||||
@@ -278,6 +278,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
|
||||
|
||||
| Extra | Install Command | Description |
|
||||
|-------|----------------|-------------|
|
||||
| `desktop` | `uv sync --extra desktop` | Desktop/API server plus local speech input |
|
||||
| `server` | `uv sync --extra server` | OpenAI-compatible API server (`jarvis serve`) |
|
||||
| `dev` | `uv sync --extra dev` | Development and testing tools |
|
||||
| `docs` | `uv sync --extra docs` | Documentation build tools |
|
||||
@@ -285,7 +286,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
|
||||
Combine extras:
|
||||
|
||||
```bash
|
||||
uv sync --extra server --extra memory-faiss --extra inference-cloud
|
||||
uv sync --extra desktop --extra memory-faiss --extra inference-cloud
|
||||
```
|
||||
|
||||
## Setting Up an Inference Backend
|
||||
|
||||
@@ -135,6 +135,19 @@ cd OpenJarvis
|
||||
This launches the backend API server and a React frontend at [http://localhost:5173](http://localhost:5173).
|
||||
You get a ChatGPT-like interface with streaming responses, tool use, energy monitoring, and a telemetry dashboard — all running locally on your hardware.
|
||||
|
||||
Web search is available through the built-in DuckDuckGo fallback. To use
|
||||
Tavily, add `TAVILY_API_KEY` under **Settings → Tools → Web Search** after the
|
||||
app starts, or export it before starting quickstart:
|
||||
|
||||
```bash
|
||||
export TAVILY_API_KEY="tvly-..."
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
The script does not automatically source `.env` files. Run `source .env`
|
||||
first if that is where you keep the key. Stop any existing OpenJarvis server
|
||||
before restarting so it inherits the updated environment.
|
||||
|
||||
To stop all services, press ++ctrl+c++ in the terminal.
|
||||
|
||||
!!! tip "Environment variable"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Native Windows (advanced)
|
||||
|
||||
Phase-1 of the native-Windows-support RFC (#298). Mirrors the Linux
|
||||
(systemd) and macOS (launchd) deployments — but for PowerShell, without
|
||||
WSL2 or Docker. Choose this over [WSL2](wsl2.md) only if you want to
|
||||
avoid a Linux VM; WSL2 remains the smoother experience for most users.
|
||||
|
||||
## What you get
|
||||
|
||||
- A PowerShell installer that probes prerequisites, installs `uv`,
|
||||
clones the repo, and runs `uv sync --extra desktop --group desktop-native`.
|
||||
- An optional Windows scheduled-task service equivalent to the systemd
|
||||
unit and launchd plist.
|
||||
- Loopback default — the service binds `127.0.0.1` so no API key is
|
||||
required.
|
||||
|
||||
## What you need
|
||||
|
||||
- Windows 10 1809+ or Windows 11.
|
||||
- Python 3.10 – 3.13 (Python 3.14 has no numpy Windows wheels yet —
|
||||
see [#432](https://github.com/open-jarvis/OpenJarvis/issues/432)).
|
||||
- `git` on PATH.
|
||||
- ~5 GB free disk on `%LOCALAPPDATA%`.
|
||||
|
||||
## Install
|
||||
|
||||
In any PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://open-jarvis.github.io/OpenJarvis/install.ps1 | iex
|
||||
```
|
||||
|
||||
The installer will:
|
||||
|
||||
1. Refuse non-Windows hosts and old Windows builds.
|
||||
2. Confirm Python 3.10 – 3.13.
|
||||
3. Confirm `git`.
|
||||
4. Install `uv` if absent (via the official `astral.sh/uv` PowerShell
|
||||
installer).
|
||||
5. Clone the repo to `%LOCALAPPDATA%\OpenJarvis\src`.
|
||||
6. Run `uv sync --extra desktop --group desktop-native`.
|
||||
7. Prompt to register the scheduled-task service (skip with
|
||||
`-SkipService`).
|
||||
|
||||
## Run it
|
||||
|
||||
```powershell
|
||||
cd "$env:LOCALAPPDATA\OpenJarvis\src"
|
||||
uv run jarvis serve
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000/health` to verify.
|
||||
|
||||
## Scheduled-task service
|
||||
|
||||
If you skipped the prompt during install, register the auto-start task
|
||||
manually:
|
||||
|
||||
```powershell
|
||||
$srv = "$env:LOCALAPPDATA\OpenJarvis\src\deploy\windows\jarvis-service.ps1"
|
||||
powershell -ExecutionPolicy Bypass -File $srv install
|
||||
```
|
||||
|
||||
State:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File $srv status
|
||||
```
|
||||
|
||||
Remove:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File $srv uninstall
|
||||
```
|
||||
|
||||
See [`deploy/windows/README.md`](https://github.com/open-jarvis/OpenJarvis/blob/main/deploy/windows/README.md)
|
||||
for the LAN-exposed configuration and the parity table against
|
||||
systemd / launchd.
|
||||
|
||||
## See also
|
||||
|
||||
- [WSL2 install](wsl2.md) — the recommended Windows path.
|
||||
- [Full installer reference](install.md).
|
||||
@@ -1,6 +1,9 @@
|
||||
# WSL2 Install
|
||||
|
||||
OpenJarvis runs in WSL2 on Windows. Native Windows is not supported.
|
||||
OpenJarvis on Windows installs two ways: **WSL2** (this page — the
|
||||
recommended path; identical to native Linux) or **[native Windows
|
||||
(advanced)](windows-native.md)** (Phase-1; PowerShell installer, no
|
||||
WSL2 / no Docker). Pick WSL2 for the smoothest experience.
|
||||
|
||||
## One-time WSL setup
|
||||
|
||||
|
||||
+14
-2
@@ -14,6 +14,18 @@ OpenJarvis is a research framework for composable, on-device AI systems.
|
||||
Build personal AI that runs on your hardware. Cloud APIs are optional.
|
||||
</p>
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-image-multiple:{ .lg .middle } **See what people use it for**
|
||||
|
||||
---
|
||||
|
||||
A gallery of real setups — morning briefs that summarize your overnight Slack and email, a Discord companion that knows your calendar, a code reviewer that works at 30,000 feet. Outcome-first, with links to the docs that explain how to build each one.
|
||||
|
||||
[:octicons-arrow-right-24: Browse the Showcase](showcase/index.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Why OpenJarvis?
|
||||
@@ -171,7 +183,7 @@ OpenJarvis is built around five composable layers. Each has a clean interface an
|
||||
|
||||
---
|
||||
|
||||
CLI, Python SDK, and guides for [Morning Digest](user-guide/morning-digest.md), [Deep Research](user-guide/deep-research.md), [Code Assistant](user-guide/code-assistant.md), [Scheduled Monitor](user-guide/scheduled-monitor.md), [Simple Chat](user-guide/chat-simple.md), agents, memory, tools, and telemetry.
|
||||
CLI, Python SDK, and guides for [Morning Digest](user-guide/morning-digest.md), [Deep Research](user-guide/deep-research.md), [Code Assistant](user-guide/code-assistant.md), [Scheduled Monitor](user-guide/scheduled-monitor.md), [Simple Chat](user-guide/chat-simple.md), [Evaluations](user-guide/evaluations.md), agents, memory, tools, and telemetry.
|
||||
|
||||
- **[Architecture](architecture/overview.md)**
|
||||
|
||||
@@ -203,7 +215,7 @@ OpenJarvis is built around five composable layers. Each has a clean interface an
|
||||
|
||||
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the efficiency of on-device AI systems. Developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
|
||||
|
||||
Read the [blog post](https://scalingintelligence.stanford.edu/blogs/openjarvis/) for the full research motivation, architecture details, and experimental results.
|
||||
Read the [blog post](https://openjarvis.stanford.edu/) for the full research motivation, architecture details, and experimental results.
|
||||
|
||||
## Citation
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
@@ -12,8 +12,14 @@
|
||||
// Outlier detection — hide entries with values that are physically
|
||||
// implausible relative to their token count. Thresholds are ~1000x
|
||||
// above legitimate per-token values to avoid false positives.
|
||||
var MAX_ENERGY_WH_PER_TOKEN = 10; // legit ≈ 0.001 Wh/tok
|
||||
var MAX_FLOPS_PER_TOKEN = 1e17; // legit ≈ 1e12 /tok
|
||||
// Outlier bounds. Set well above realistic upper limits but tight
|
||||
// enough to drop the pre-fix bimodal Group B (1-5 Wh/token, ~3e16
|
||||
// FLOPs/token) — see the leaderboard PR for the full diagnosis.
|
||||
// Realistic per-token rates on a consumer GPU + 10–30B local model:
|
||||
// ~0.001 Wh/token, ~1e10–1e11 FLOPs/token. We allow 500× and 10,000×
|
||||
// headroom respectively for inefficient hardware / larger models.
|
||||
var MAX_ENERGY_WH_PER_TOKEN = 0.5; // legit ≈ 0.001 Wh/tok
|
||||
var MAX_FLOPS_PER_TOKEN = 1e15; // legit ≈ 1e11 /tok
|
||||
var MAX_DOLLAR_PER_TOKEN = 25.0 / 1e6; // hard ceiling: $25/1M output
|
||||
|
||||
function isOutlier(row) {
|
||||
@@ -29,6 +35,25 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Distinguish "user actually has zero work done" from "user's energy /
|
||||
// FLOPs telemetry never landed". The latter happens when the server
|
||||
// submits with valid dollar savings + token counts but the per-record
|
||||
// energy stamp was missing (pre-fix builds, GPU energy meter
|
||||
// unavailable, etc.). Without this check those rows show as "0.00 Wh"
|
||||
// and skew the rankings + headline totals.
|
||||
//
|
||||
// Threshold: 1000 tokens is well above any single chat-turn — if a
|
||||
// user has that many tokens recorded but no measured energy, the
|
||||
// telemetry is incomplete, not legitimately zero.
|
||||
var MIN_TOKENS_FOR_TELEMETRY = 1000;
|
||||
|
||||
function isMissingTelemetry(row) {
|
||||
var tokens = Number(row.total_tokens) || 0;
|
||||
var energy = Number(row.energy_wh_saved) || 0;
|
||||
var flops = Number(row.flops_saved) || 0;
|
||||
return tokens > MIN_TOKENS_FOR_TELEMETRY && energy === 0 && flops === 0;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
var el = document.createElement("span");
|
||||
el.textContent = s;
|
||||
@@ -62,13 +87,24 @@
|
||||
var medal =
|
||||
rank === 1 ? "\uD83E\uDD47" : rank === 2 ? "\uD83E\uDD48" : rank === 3 ? "\uD83E\uDD49" : "";
|
||||
var row = pageRows[j];
|
||||
// Render "—" for energy / FLOPs columns when telemetry didn't
|
||||
// land (vs the user genuinely having 0). The dollar / request /
|
||||
// token columns are unaffected because those measurements landed
|
||||
// even when energy didn't.
|
||||
var missing = isMissingTelemetry(row);
|
||||
var energyCell = missing
|
||||
? '<td class="lb-number lb-missing" title="Energy telemetry missing for this entry">—</td>'
|
||||
: '<td class="lb-number">' + Number(row.energy_wh_saved || 0).toFixed(2) + "</td>";
|
||||
var flopsCell = missing
|
||||
? '<td class="lb-number lb-missing" title="FLOPs telemetry missing for this entry">—</td>'
|
||||
: '<td class="lb-number">' + fmtLarge(Number(row.flops_saved || 0)) + "</td>";
|
||||
html +=
|
||||
"<tr>" +
|
||||
'<td><span class="lb-rank' + rankClass + '">' + (medal || rank) + "</span></td>" +
|
||||
'<td class="lb-name">' + escapeHtml(row.display_name) + "</td>" +
|
||||
'<td class="lb-number">$' + Number(row.dollar_savings || 0).toFixed(4) + "</td>" +
|
||||
'<td class="lb-number">' + Number(row.energy_wh_saved || 0).toFixed(2) + "</td>" +
|
||||
'<td class="lb-number">' + fmtLarge(Number(row.flops_saved || 0)) + "</td>" +
|
||||
energyCell +
|
||||
flopsCell +
|
||||
'<td class="lb-number">' + Number(row.total_calls || 0).toLocaleString() + "</td>" +
|
||||
'<td class="lb-number">' + Number(row.total_tokens || 0).toLocaleString() + "</td>" +
|
||||
"</tr>";
|
||||
@@ -116,8 +152,15 @@
|
||||
}
|
||||
|
||||
fetch(
|
||||
// `methodology_version=gte.1` filter excludes rows that the
|
||||
// leaderboard-correctness migration quarantined (version 0). Rows
|
||||
// written by current and future clients carry version >= 1, so this
|
||||
// is forward-compatible — pre-fix corrupt rows hide at the query
|
||||
// level (fewer bytes over the wire than client-side outlier
|
||||
// filtering), and downstream client-side checks remain as a
|
||||
// belt-and-suspenders second line of defence.
|
||||
SUPABASE_URL +
|
||||
"/rest/v1/savings_entries?select=display_name,dollar_savings,energy_wh_saved,flops_saved,total_calls,total_tokens&order=dollar_savings.desc&limit=1000",
|
||||
"/rest/v1/savings_entries?select=display_name,dollar_savings,energy_wh_saved,flops_saved,total_calls,total_tokens&methodology_version=gte.1&order=dollar_savings.desc&limit=1000",
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
|
||||
+1
-1
@@ -55,5 +55,5 @@ See how the OpenJarvis community saves money, energy, and compute by running AI
|
||||
<div id="leaderboard-pagination" class="lb-pagination"></div>
|
||||
|
||||
<p style="font-size:12px;opacity:0.6;margin-top:12px">
|
||||
*Dollar savings estimated vs. Claude Opus 4.6 API pricing ($5/1M input, $25/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
|
||||
*Dollar savings estimated vs. Claude Fable 5 API pricing ($10/1M input, $50/1M output tokens). Assumes local open-source models produce roughly the same number of tokens per request as cloud models.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Contributing a Showcase Entry
|
||||
description: How to add your setup to the OpenJarvis Showcase
|
||||
---
|
||||
|
||||
# Contributing a Showcase Entry
|
||||
|
||||
The Showcase exists for one reason: to help a confused, curious, *non-technical* reader figure out whether OpenJarvis is worth their weekend. That goal sets every editorial choice on this page.
|
||||
|
||||
## The format
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: <Your Title — short, capitalized>
|
||||
description: <One sentence. The hook a stranger sees in search results.>
|
||||
---
|
||||
|
||||
# <emoji> <One-sentence hook — what it does FOR you, in plain English>
|
||||
|
||||
<figure markdown>
|
||||
{ .showcase-screenshot loading=lazy }
|
||||
<figcaption>A one-sentence caption that adds context the image can't show on its own.</figcaption>
|
||||
</figure>
|
||||
|
||||
<2–3 short paragraphs of context: when do you use this, what changed for
|
||||
you, what the experience feels like. Concrete > abstract. "I read it on
|
||||
my phone before coffee" > "improves morning productivity."
|
||||
|
||||
A bulleted list of two or three CONCRETE OUTCOMES works well — your
|
||||
calendar, your inbox, your code. Specific verbs and proper nouns.>
|
||||
|
||||
## Why it's nice
|
||||
|
||||
- **<one-line benefit>.** <one or two sentences of evidence>
|
||||
- **<one-line benefit>.** <one or two sentences of evidence>
|
||||
- **<one-line benefit>.** <one or two sentences of evidence>
|
||||
|
||||
## How I set this up
|
||||
|
||||
→ **[Tutorial: <name>](../tutorials/<file>.md)** is the closest match.
|
||||
|
||||
→ **[Recipe: <name>](https://github.com/open-jarvis/OpenJarvis/tree/main/src/openjarvis/recipes/data)** if you want the exact config.
|
||||
|
||||
→ **[<one more related doc>](../<path>.md)** if the reader is going deeper.
|
||||
```
|
||||
|
||||
## Editorial conventions
|
||||
|
||||
These are guardrails, not rules. Break them if you have a reason.
|
||||
|
||||
### Lead with the outcome, not the technology
|
||||
|
||||
❌ "Multi-channel routing with MCP-backed memory and an orchestrator agent."<br>
|
||||
✅ "Jarvis answers my Discord messages while I sleep."
|
||||
|
||||
The reader doesn't know what an "orchestrator agent" is yet. They know what a Discord message is.
|
||||
|
||||
### Show one screenshot. Make it the headline.
|
||||
|
||||
A single, large, *interesting* screenshot beats five small ones. Crop it to show the result, not the UI chrome. If you can convey it in an image, don't write the paragraph.
|
||||
|
||||
**Screenshot specs:**
|
||||
|
||||
- 1600×1000 PNG, sRGB, no alpha
|
||||
- File path: `docs/assets/showcase/<your-slug>.png`
|
||||
- Redact: real email addresses, API keys, personal phone numbers, conversation partners' faces or full names (unless they've signed off)
|
||||
- Keep: model names, timestamps, dollar amounts, emoji reactions, your own first name
|
||||
|
||||
### Specific over impressive
|
||||
|
||||
❌ "Saves significant time every morning."<br>
|
||||
✅ "Cut my morning catch-up from 25 minutes to 2."
|
||||
|
||||
Numbers, durations, dollar amounts, and named tools build trust. Adjectives don't.
|
||||
|
||||
### Three paragraphs is plenty
|
||||
|
||||
A reader who wants more clicks the "How I set this up →" link at the bottom. Showcase pages are a funnel into the docs, not a replacement for them. If you find yourself explaining configuration in the showcase entry, that material belongs in the linked tutorial.
|
||||
|
||||
### "Why it's nice" is for the experience, not the architecture
|
||||
|
||||
The bullets under **Why it's nice** should answer "what's different *for you*?" — not "what's different about how the framework works?". Save the architecture talk for the linked docs.
|
||||
|
||||
❌ "Uses local SQLite for state with WAL mode for concurrent reads."<br>
|
||||
✅ "I can read my own memory file in a text editor. I can delete a line and the memory is gone."
|
||||
|
||||
### Every entry must end with at least one "How I set this up →" link
|
||||
|
||||
If there isn't a relevant tutorial yet, link to the closest [User Guide](../user-guide/cli.md) and open an issue noting that the tutorial is missing. We will write it.
|
||||
|
||||
## Submitting
|
||||
|
||||
1. **Fork** the repo and create a branch: `docs/showcase-<your-slug>`.
|
||||
2. **Add** your markdown file at `docs/showcase/<your-slug>.md` and screenshot at `docs/assets/showcase/<your-slug>.png`.
|
||||
3. **Add a tile** to the grid in `docs/showcase/index.md` (matches the existing pattern — emoji + title + 1-sentence summary + `[:octicons-arrow-right-24: See it](<your-slug>.md)`).
|
||||
4. **Open a PR** with the title `docs(showcase): <your title>`. Tag a maintainer if you'd like editorial feedback before merge.
|
||||
|
||||
## Where this goes after merge
|
||||
|
||||
Hannah and the docs team post merged showcase entries to **`#config-showcase`** in [the OpenJarvis Discord](https://discord.gg/openjarvis). You'll get tagged in the post — you don't have to do it yourself.
|
||||
|
||||
## Questions, drafts, half-finished ideas
|
||||
|
||||
Drop them in **`#config-showcase`** on Discord *before* opening a PR. Editorial feedback is faster on chat than in a PR review, and you'll save yourself a round of revisions.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Offline Code Reviewer
|
||||
description: Review a pull request on a transatlantic flight, no internet required
|
||||
---
|
||||
|
||||
# 🛠️ Offline Code Reviewer — code review on an airplane
|
||||
|
||||
<figure markdown>
|
||||
{ .showcase-screenshot loading=lazy }
|
||||
<figcaption>Airplane mode in the menu bar. Jarvis reading a `git diff`, the surrounding files, and producing a code review at gate-level Wi-Fi (i.e., none).</figcaption>
|
||||
</figure>
|
||||
|
||||
Earlier this month I was on a flight from SFO to FRA — eleven hours, no usable Wi-Fi. I had a teammate's pull request open in VS Code. I asked Jarvis to review it. It read the diff, read the three files the diff touched, read the project's `CLAUDE.md` for conventions, and produced a review with five comments — two of which caught real bugs.
|
||||
|
||||
The review took about 40 seconds on the laptop's built-in GPU. No API call. No "you're offline" error. By the time we landed I'd dropped the comments into GitHub and the PR was merging.
|
||||
|
||||
The same setup handles:
|
||||
|
||||
- **Code review** — diff + context files + conventions, structured comments.
|
||||
- **Debugging** — paste a traceback, Jarvis reads the stack, opens the relevant files, suggests fixes.
|
||||
- **Test generation** — point at a function, get back a `pytest` file with edge cases.
|
||||
- **Documentation** — generate docstrings that actually match the code, because Jarvis has the file open.
|
||||
|
||||
## Why it's nice
|
||||
|
||||
- **It works on a plane.** Or a train, or a hotel with bad Wi-Fi, or your couch when Comcast is having a day. Same speed every time.
|
||||
- **It sees your repo, not a sanitized chunk.** Cloud coding assistants make you upload a context window. The local one just reads `git status` and the files you're working on.
|
||||
- **No "we trained on your code" question.** Your code never leaves your laptop. Period.
|
||||
|
||||
## How I set this up
|
||||
|
||||
→ **[Tutorial: Code Companion](../tutorials/code-companion.md)** walks through the ReAct-agent + git/file/shell tool stack this uses end-to-end.
|
||||
|
||||
→ **[User Guide: Code Assistant](../user-guide/code-assistant.md)** is the focused recipe walkthrough for daily-driver code review.
|
||||
|
||||
→ **[OpenAI-compatible server](../getting-started/quickstart.md)** — point your editor's existing AI integration (Cursor, Continue, Cody, Aider) at `localhost:8000`. They mostly don't know they're not talking to OpenAI.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Track Your Savings
|
||||
description: A leaderboard that tells you exactly how much you saved by running locally
|
||||
---
|
||||
|
||||
# 💸 Track Your Savings — the leaderboard that makes local-first feel real
|
||||
|
||||
<figure markdown>
|
||||
{ .showcase-screenshot loading=lazy }
|
||||
<figcaption>The public leaderboard. The bar on the right is what a month of my Jarvis usage would have cost on the cloud — measured per-query, not estimated.</figcaption>
|
||||
</figure>
|
||||
|
||||
OpenJarvis tracks every inference call you make — the tokens, the latency, the GPU energy — and computes what that same call *would have cost* on OpenAI, Anthropic, Google, and Bedrock. There's a public leaderboard at **[/leaderboard](../leaderboard.md)** where anyone running Jarvis can opt in and watch their savings rack up.
|
||||
|
||||
My current month is roughly:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Local inference cost | **`$0.00`** |
|
||||
| Cloud-equivalent cost | **`$342.18`** (Claude Sonnet 4.6 baseline) |
|
||||
| Energy used | **`1.4 kWh`** (~12¢ of grid power) |
|
||||
| Prompts sent to a third party | **`0`** |
|
||||
|
||||
The dollar number is the hook. The bottom row is the actual reason I run Jarvis.
|
||||
|
||||
## Why it's nice
|
||||
|
||||
- **You can see what each query costs you.** Not estimated, not "roughly" — measured. Watt-hours per token, FLOPs per token, latency. Every primitive in OpenJarvis treats compute cost as a first-class quantity alongside accuracy.
|
||||
- **It makes "local-first" stop being abstract.** Watching a bar chart accumulate `$X` a week that *didn't* leave your hands is a different kind of motivating than "your data is private" claims that you can't verify.
|
||||
- **Privacy stops being an act of faith.** Every prompt I send to Jarvis can be traced through the codebase to local-only paths. No "cloud failover" hiding behind a switch.
|
||||
|
||||
## How I set this up
|
||||
|
||||
You don't, really — it's on by default. Every `jarvis ask`, `jarvis serve` request, and channel-routed message is metered by the [telemetry system](../telemetry.md). To opt your savings into the public leaderboard:
|
||||
|
||||
→ **[Leaderboard guide](../leaderboard.md)** — one command to opt in, one command to opt out. Telemetry is local-only by default.
|
||||
|
||||
→ **[Telemetry overview](../telemetry.md)** — what's measured, where it's stored, and how to inspect it yourself with `jarvis telemetry`.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: Discord Companion
|
||||
description: Jarvis answers questions in your private Discord while you sleep — reads your notes, checks your calendar, schedules things
|
||||
---
|
||||
|
||||
# 💬 Discord Companion — a personal assistant that lives in my Discord
|
||||
|
||||
<figure markdown>
|
||||
{ .showcase-screenshot loading=lazy }
|
||||
<figcaption>I DM'd Jarvis from my phone at midnight. It checked my Google Calendar, cross-referenced a note from last week, and answered — running on the Mac mini in my closet.</figcaption>
|
||||
</figure>
|
||||
|
||||
I have a private Discord server with two channels and one user (me). Jarvis lives there. I can DM it from my phone, my laptop, or my watch — anywhere Discord runs. Sample things I've asked it this week:
|
||||
|
||||
- "What's the address of the place I had that meeting last Tuesday?" → Jarvis searches my calendar + meeting notes, replies in 4 seconds.
|
||||
- "Reply to Mom's text from earlier saying I'll call tomorrow at 7." → drafts a reply, asks me to confirm, sends.
|
||||
- "Add 'Sam's birthday is March 12' to my long-term memory." → updates `MEMORY.md`, confirms.
|
||||
- "Summarize the last hour of conversation in `#deploys-prod`." → reads the Slack channel via MCP, summarizes.
|
||||
|
||||
I used to use my phone's voice assistant for this. The two differences that matter: **Jarvis answers in three sentences, not one,** and **it actually has my context** — my notes, my calendar, my projects, my history.
|
||||
|
||||
## Why it's nice
|
||||
|
||||
- **Latency feels like talking to a person.** Local inference on a modest GPU is 5–10× faster than round-tripping to a cloud API. Question to answer in 3 seconds.
|
||||
- **The Discord interface is multi-device for free.** Same conversation thread on my phone, laptop, watch — no special app to install.
|
||||
- **It's already private.** A Discord server I run, talking to a model on a machine I own. The data trail is two endpoints I control.
|
||||
|
||||
## How I set this up
|
||||
|
||||
→ **[Tutorial: Messaging Hub](../tutorials/messaging-hub.md)** is the closest match — same channel-adapter + orchestrator-agent pattern, with Discord substituted for Slack.
|
||||
|
||||
→ **[Channel docs](../user-guide/cli.md)** walks through Discord/Slack/Telegram/WhatsApp setup. Discord is two environment variables and a bot token.
|
||||
|
||||
→ **[MCP integration guide](../user-guide/cli.md)** if you want Jarvis to reach into Notion, Linear, Gmail, etc.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: Showcase
|
||||
description: What people actually do with OpenJarvis — outcomes first, scripts later
|
||||
---
|
||||
|
||||
# Showcase
|
||||
|
||||
These are stories from people who use OpenJarvis day to day. Each entry shows the **result** — a screenshot, a paragraph of context, and a short link to the docs that explain how to build it. If you're trying to figure out whether OpenJarvis is worth a weekend of your time, start here.
|
||||
|
||||
!!! tip "New here?"
|
||||
The Showcase answers *"what's possible?"*. When you find something you want for yourself, follow the **How I set this up** link at the bottom of each page — it lands on a [Tutorial](../tutorials/index.md) that walks through the build.
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-coffee:{ .lg .middle } **Morning Brief**
|
||||
|
||||
---
|
||||
|
||||
Slack, email, GitHub, and calendar — read overnight, summarized into 5 bullets in your phone by 7am. Cuts the daily "what did I miss" tax to zero.
|
||||
|
||||
[:octicons-arrow-right-24: See it](morning-brief.md)
|
||||
|
||||
- :material-brain:{ .lg .middle } **Memory That Doesn't Reset**
|
||||
|
||||
---
|
||||
|
||||
Tell Jarvis you're allergic to shellfish once. Three months later it brings it up when you're restaurant-planning. Plain markdown files, no vector-DB tricks.
|
||||
|
||||
[:octicons-arrow-right-24: See it](persistent-memory.md)
|
||||
|
||||
- :material-piggy-bank-outline:{ .lg .middle } **Track Your Savings**
|
||||
|
||||
---
|
||||
|
||||
A leaderboard that tells you exactly how much you saved by running locally — and reminds you that none of your prompts ever left your house.
|
||||
|
||||
[:octicons-arrow-right-24: See it](cost-savings.md)
|
||||
|
||||
- :material-message-text:{ .lg .middle } **Discord Companion**
|
||||
|
||||
---
|
||||
|
||||
Jarvis answers questions in your private Discord while you sleep. Reads your notes, checks your calendar, schedules things, replies in your voice.
|
||||
|
||||
[:octicons-arrow-right-24: See it](discord-companion.md)
|
||||
|
||||
- :material-code-tags-check:{ .lg .middle } **Offline Code Reviewer**
|
||||
|
||||
---
|
||||
|
||||
Review a pull request on a transatlantic flight. Jarvis reads the diff, the surrounding files, and the project conventions — without an internet connection.
|
||||
|
||||
[:octicons-arrow-right-24: See it](coding-assistant.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Share your setup
|
||||
|
||||
The Showcase grows from real users. If you've built something interesting on top of OpenJarvis — or just have a configuration you're proud of — the format is simple and the bar is low:
|
||||
|
||||
1. **One-sentence hook**: what does this *do for you*?
|
||||
2. **A screenshot or 15-second screen recording**: the visible result.
|
||||
3. **2–3 short paragraphs**: when you use it, why it's nice (cost, privacy, speed, calm).
|
||||
4. **"How I set this up →"**: a link to the relevant [Tutorial](../tutorials/index.md), [User Guide](../user-guide/cli.md), or [Recipe](https://github.com/open-jarvis/OpenJarvis/tree/main/src/openjarvis/recipes/data).
|
||||
|
||||
See [Contributing a Showcase Entry](CONTRIBUTING.md) for the template and the editorial conventions (screenshot sizing, what to redact, tone).
|
||||
|
||||
## Want to talk to other people doing this?
|
||||
|
||||
The **`#config-showcase`** channel in the [OpenJarvis Discord](https://discord.gg/openjarvis) is where people post and discuss personal setups. Drop a screenshot, ask "how would I do X?", or browse what others have shared.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Morning Brief
|
||||
description: Slack, email, GitHub, and calendar — summarized into a 5-bullet brief on your phone by 7am
|
||||
---
|
||||
|
||||
# ☕ Morning Brief — Jarvis reads everything overnight so I don't have to
|
||||
|
||||
<figure markdown>
|
||||
{ .showcase-screenshot loading=lazy }
|
||||
<figcaption>The 7am brief that arrives in my private Discord — 5 bullets, two minutes to read, written by an agent that ran on my desk while I slept.</figcaption>
|
||||
</figure>
|
||||
|
||||
Every morning at 7am, before my first coffee, a message appears in my private Discord with five bullets:
|
||||
|
||||
- what shipped at work overnight (GitHub releases + merged PRs)
|
||||
- the two emails I actually need to act on (with one-line summaries)
|
||||
- anything mentioned in my team's `#general` Slack channel
|
||||
- today's calendar with the next 24 hours of meetings
|
||||
- one thing I asked Jarvis to track for me ("did Tuesday's deploy roll out cleanly?")
|
||||
|
||||
It's the first thing I read on my phone, while I'm still in bed. The brief used to take me 25 minutes — opening four apps, scrolling, deciding what mattered. Now it's two minutes of reading and I'm done.
|
||||
|
||||
## Why it's nice
|
||||
|
||||
- **Costs me nothing per month.** It runs on a Mac mini in my closet. Same prompt-volume on the OpenAI API would be `~$18/month` based on the leaderboard's estimates.
|
||||
- **Nothing leaves my house.** My inbox, my Slack DMs, my calendar — Jarvis reads them locally and writes the digest locally. The only network call is the Discord webhook to my own private server.
|
||||
- **It learns my taste.** Over a few weeks Jarvis figured out that PR titles starting with `chore:` aren't worth surfacing and that I don't want to see calendar holds I created myself. The summarizer has a `MEMORY.md` it updates when I react with 👎 to a bullet.
|
||||
|
||||
## What you'd need
|
||||
|
||||
A laptop or mini-PC that stays on overnight, an inference engine (Ollama is the easy default), accounts on whichever surfaces you want summarized (Slack, Gmail, GitHub, Google Calendar), and a Discord (or Slack, or Telegram, or email) destination to post the brief to.
|
||||
|
||||
## How I set this up
|
||||
|
||||
→ **[Tutorial: Scheduled Personal Ops](../tutorials/scheduled-ops.md)** walks through the cron-scheduled agent pattern this uses. The morning-brief flavour is `orchestrator` agent + the channel adapters + the scheduler primitive — three primitives, one TOML recipe.
|
||||
|
||||
→ **[User Guide: Morning Digest](../user-guide/morning-digest.md)** is the focused recipe walkthrough if you only want this one workflow.
|
||||
|
||||
→ **[User Guide: Channels](../user-guide/cli.md)** for connecting Discord/Slack/Telegram as the destination.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: Memory That Doesn't Reset
|
||||
description: Tell Jarvis something once. It remembers — three months later, across every conversation
|
||||
---
|
||||
|
||||
# 🧠 Memory That Doesn't Reset — Jarvis actually knows me
|
||||
|
||||
<figure markdown>
|
||||
{ .showcase-screenshot loading=lazy }
|
||||
<figcaption>Three months after I mentioned the allergy in passing, Jarvis brings it up — unprompted — while helping me pick a birthday-dinner restaurant.</figcaption>
|
||||
</figure>
|
||||
|
||||
I mentioned to Jarvis once, in a throwaway sentence in April, that I'm allergic to shellfish. In July, when I asked it to help me pick a restaurant for my partner's birthday, it volunteered "you'll want to filter for menus that have non-shellfish options" — without being reminded, in a totally different conversation, on a different topic.
|
||||
|
||||
That's not magic. The trick is that Jarvis writes to three plain markdown files in my home directory whenever it learns something worth remembering:
|
||||
|
||||
- `SOUL.md` — how I want it to behave (tone, length, what to push back on)
|
||||
- `MEMORY.md` — facts about me, my projects, my preferences
|
||||
- `USER.md` — who I am: my role, my team, my context
|
||||
|
||||
Every new conversation starts by reading those three files. I can open them in any text editor. I can delete a line and the memory is gone. The whole thing is `~6 KB` of markdown. No vector DB, no embedding cache, no opaque "personalization layer."
|
||||
|
||||
## Why it's nice
|
||||
|
||||
- **It's auditable.** I can read what Jarvis "knows" about me in 30 seconds. Most personal-AI products literally can't tell you.
|
||||
- **It's portable.** I keep my three files in iCloud Drive. When I set up Jarvis on a new machine, my memory comes with me — without re-onboarding.
|
||||
- **It compounds.** After two weeks Jarvis stopped re-asking what my code style is. After six weeks it stopped re-asking who's on my team. The conversations get shorter because the context is already there.
|
||||
- **It can't drift.** Vector retrieval can confidently surface the wrong "memory" and you'd never know. Plain markdown that I can read can't lie about what it contains.
|
||||
|
||||
## How I set this up
|
||||
|
||||
→ **[User Guide: Agents](../user-guide/agents.md)** explains the persistent-agent pattern, including how `SOUL.md` / `MEMORY.md` / `USER.md` are loaded at conversation start.
|
||||
|
||||
→ **[Tutorial: Deep Research Assistant](../tutorials/deep-research.md)** uses the same persistent-memory primitive — a good place to see it in action with code.
|
||||
@@ -440,6 +440,13 @@
|
||||
font-family: var(--md-code-font-family, monospace);
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Placeholder for rows where energy / FLOPs telemetry didn't land.
|
||||
Distinguishes "telemetry missing" from "user genuinely had 0 work
|
||||
done" without making the row visually pop more than data rows. */
|
||||
.lb-missing {
|
||||
color: var(--md-default-fg-color--light, #999);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── DocSearch ───────────────────────────────────────────────────────── */
|
||||
#docsearch {
|
||||
@@ -562,3 +569,18 @@
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Showcase screenshots
|
||||
*
|
||||
* Hero images on docs/showcase/* pages. Constrains width on wide screens and
|
||||
* adds a subtle border so placeholder/broken-image states still look intentional
|
||||
* before community-contributed screenshots populate docs/assets/showcase/.
|
||||
* ------------------------------------------------------------------------- */
|
||||
.showcase-screenshot {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--md-default-fg-color--lightest, rgba(0, 0, 0, 0.08));
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@@ -83,33 +83,6 @@ dropped. Tests covering the patterns: [`tests/analytics/test_redaction.py`](../t
|
||||
- **Never** sold, shared with advertisers, or used for anything other
|
||||
than improving OpenJarvis.
|
||||
|
||||
## Opting out
|
||||
|
||||
Three independent ways to disable analytics — any one is sufficient:
|
||||
|
||||
1. **Set an env var** (no config file edit needed):
|
||||
```bash
|
||||
export DO_NOT_TRACK=1 # W3C convention, honored by other tools too
|
||||
# or
|
||||
export OPENJARVIS_NO_ANALYTICS=1 # project-specific, leaves other DNT-aware tools unaffected
|
||||
```
|
||||
Both are checked at runtime; any truthy value (`1`, `true`, `yes`,
|
||||
`on`) disables analytics for that process. Truthy = anything other
|
||||
than empty, `0`, `false`, `no`, `off`.
|
||||
|
||||
2. **Edit `~/.openjarvis/config.toml`**:
|
||||
```toml
|
||||
[analytics]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
3. **Delete the anon ID** (`rm ~/.openjarvis/anon_id`) — events for
|
||||
the prior identity are orphaned, but a new identity will be
|
||||
created on the next run. Combine with #1 or #2 to fully stop.
|
||||
|
||||
Env-var opt-out takes precedence over the config file, so setting
|
||||
`DO_NOT_TRACK=1` overrides `enabled = true` in the config.
|
||||
|
||||
## Retention
|
||||
|
||||
- Default retention: **365 days**, then events are deleted by PostHog
|
||||
|
||||
@@ -13,11 +13,77 @@ Agents are the agentic logic layer of OpenJarvis. They determine how a query is
|
||||
| `RLMAgent` | `rlm` | Yes | Yes | Recursive LM with persistent REPL |
|
||||
| `OpenHandsAgent` | `openhands` | No | Yes | Wraps real openhands-sdk |
|
||||
| `ClaudeCodeAgent` | `claude_code` | No | Yes | Claude Agent SDK via Node.js subprocess |
|
||||
| `OpenCodeAgent` | `opencode` | No | Yes | [opencode](https://opencode.ai) coding agent on your local engine |
|
||||
| `OperativeAgent` | `operative` | Yes | Yes | Persistent scheduled agent with state management |
|
||||
| `MonitorOperativeAgent` | `monitor_operative` | Yes | Yes | Long-horizon agent with 4 configurable strategy axes |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -383,6 +449,64 @@ jarvis ask --agent claude_code "Refactor the tests to use pytest fixtures"
|
||||
|
||||
---
|
||||
|
||||
## OpenCodeAgent
|
||||
|
||||
The `OpenCodeAgent` delegates coding tasks to [opencode](https://opencode.ai), the open-source coding agent, running it **on your local engine**. opencode handles the agentic loop, file edits, and tool use; OpenJarvis supplies the model — keeping coding-agent work local-first.
|
||||
|
||||
!!! warning "Requirements"
|
||||
Requires the `opencode` binary on `PATH` (`npm i -g opencode-ai` or `brew install anomalyco/tap/opencode`). It is **not** bundled; `run()` returns a clear error if it is missing. No `ANTHROPIC_API_KEY` needed — inference goes through your OpenJarvis engine.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Derives an OpenAI-compatible base URL from the `engine` (e.g. Ollama/vLLM/llama.cpp at `<host>/v1`) and writes an `opencode.json` in the workspace registering it as an `@ai-sdk/openai-compatible` provider (`openjarvis/<model>`).
|
||||
2. Spawns a headless `opencode serve` (loopback, random port) and waits for `/global/health`.
|
||||
3. Creates a session (`POST /session`) and sends the task (`POST /session/{id}/message`) with `model={providerID, modelID}` and the selected `agent` (`build` or `plan`).
|
||||
4. Parses the returned message `parts` — text parts → `content`, tool parts → `tool_results` — into an `AgentResult`.
|
||||
5. `close()` disposes the session/server.
|
||||
|
||||
**Constructor parameters (selected):**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------------|-------------------|------------------|----------------------------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | Used to derive the local OpenAI-compatible provider URL |
|
||||
| `model` | `str` | -- | Model id served at the provider (e.g. `qwen3:8b`) |
|
||||
| `workspace` | `str` | `os.getcwd()` | Directory opencode operates in |
|
||||
| `agent` | `str` | `"build"` | opencode agent: `build` (full access) or `plan` (read-only) |
|
||||
| `provider_base_url` | `str` | derived | Override the engine-derived OpenAI base URL |
|
||||
| `provider_id` | `str` | `"openjarvis"` | opencode provider id to register/use |
|
||||
| `model_id` | `str` | `model` | Model id within the provider |
|
||||
| `server_password` | `str` | `$OPENCODE_SERVER_PASSWORD` | Optional basic-auth for the opencode server |
|
||||
| `timeout` | `int` | `600` | HTTP timeout in seconds |
|
||||
|
||||
```python
|
||||
from openjarvis.agents.opencode import OpenCodeAgent
|
||||
|
||||
agent = OpenCodeAgent(engine, "qwen3:8b", workspace="/path/to/project", agent="build")
|
||||
result = agent.run("Add type hints to utils.py and run the tests")
|
||||
print(result.content)
|
||||
agent.close()
|
||||
```
|
||||
|
||||
```bash
|
||||
# Via CLI (opencode must be installed)
|
||||
jarvis ask --agent opencode "Refactor the parser to use a state machine"
|
||||
```
|
||||
|
||||
!!! tip "Pass-through providers"
|
||||
If the `engine` has no derivable base URL, pass `model` as `provider/model` (e.g. `ollama/llama3`) and opencode resolves it from its own configuration — no `opencode.json` is written.
|
||||
|
||||
!!! warning "Model capability matters"
|
||||
opencode's agentic loop (planning + correct tool calls + multi-step
|
||||
follow-through) needs a reasonably capable model. In testing, a **27B**
|
||||
local model (Qwen3.5-27B served via vLLM) solved a 7-task coding suite
|
||||
cleanly (create / edit / bug-fix / implement-to-pass-tests / multi-file,
|
||||
verified by running the code and tests). An **8B** model (qwen3:8b) was
|
||||
unreliable — malformed tool calls, syntactically broken code, and
|
||||
half-finished tasks. Prefer a capable local model (or a cloud model) for
|
||||
real coding work.
|
||||
|
||||
---
|
||||
|
||||
## OperativeAgent
|
||||
|
||||
The `OperativeAgent` is a persistent, scheduled autonomous agent with built-in session persistence and state recall. Designed for "Operators" -- autonomous agents that run on a schedule with automatic state management between ticks. Extends `ToolUsingAgent`.
|
||||
|
||||
@@ -66,6 +66,8 @@ jarvis ask "What is the capital of France?"
|
||||
| `--no-context` | flag | off | Disable memory context injection |
|
||||
| `-a`, `--agent AGENT` | string | none | Agent to use (`simple`, `orchestrator`) |
|
||||
| `--tools TOOLS` | string | none | Comma-separated tool names to enable |
|
||||
| `-i`, `--image PATH` | path | none | Image file for a vision model (e.g. `gemma3:4b`); repeatable |
|
||||
| `-S`, `--screen` | flag | off | Capture the current screen and send it to the vision model |
|
||||
|
||||
### Direct Mode vs Agent Mode
|
||||
|
||||
@@ -105,6 +107,39 @@ jarvis ask --no-context "Tell me about Python"
|
||||
jarvis ask --max-tokens 2048 "Write a detailed essay about AI"
|
||||
```
|
||||
|
||||
### Vision Input
|
||||
|
||||
Vision-capable models (such as `gemma3:4b`) can read images alongside your
|
||||
text prompt. Attach one or more image files with `-i`/`--image`, or capture
|
||||
the current screen with `-S`/`--screen`:
|
||||
|
||||
```bash
|
||||
# Ask about a local image
|
||||
jarvis ask -i screenshot.png "What is shown in this image?"
|
||||
|
||||
# Send multiple images (the flag is repeatable)
|
||||
jarvis ask -i chart-a.png -i chart-b.png "Compare these two charts"
|
||||
|
||||
# Capture the current screen and ask about it
|
||||
jarvis ask --screen "Summarize what's on my screen"
|
||||
```
|
||||
|
||||
Vision runs in **direct mode** only. If you also pass `--agent`, the image is
|
||||
ignored and a note is printed — re-run with `--agent ""` to force direct mode.
|
||||
|
||||
The Ollama context window can be tuned for large images or long prompts with
|
||||
the `JARVIS_NUM_CTX` environment variable (default `16384`):
|
||||
|
||||
```bash
|
||||
JARVIS_NUM_CTX=8192 jarvis ask --screen "What's on my screen?"
|
||||
```
|
||||
|
||||
!!! note "Keep vision on-device"
|
||||
Images are sensitive. OpenJarvis prints a privacy warning before sending
|
||||
an image to a non-local engine, so a screenshot never leaves your machine
|
||||
unnoticed. Use a local engine (e.g. `ollama` with `gemma3:4b`) to keep
|
||||
vision fully local.
|
||||
|
||||
### JSON Output Format
|
||||
|
||||
When using `--json` in **direct mode**, the output includes:
|
||||
|
||||
+191
-55
@@ -1,14 +1,14 @@
|
||||
# Evaluations
|
||||
|
||||
The OpenJarvis evaluation framework (`openjarvis-evals`) measures model **correctness and accuracy** on academic datasets. It is a separate package from the main OpenJarvis library and is designed specifically for research workflows where you need reproducible, dataset-driven quality assessments.
|
||||
The OpenJarvis evaluation framework (`openjarvis.evals`) measures model **correctness and accuracy** on academic datasets. It ships inside the main `openjarvis` package (at `src/openjarvis/evals/`) and is designed specifically for research workflows where you need reproducible, dataset-driven quality assessments.
|
||||
|
||||
!!! info "Evals vs. Benchmarks"
|
||||
OpenJarvis has two distinct measurement systems that complement each other:
|
||||
|
||||
| System | Package | Measures | Entry Point |
|
||||
|--------|---------|----------|-------------|
|
||||
| **Evaluations** | `openjarvis-evals` | Correctness on academic datasets (accuracy, pass rate) | `openjarvis-eval` |
|
||||
| **Benchmarks** | `openjarvis` | Engine performance (latency, throughput) | `jarvis bench` |
|
||||
| System | Module | Measures | Entry Point |
|
||||
|--------|--------|----------|-------------|
|
||||
| **Evaluations** | `openjarvis.evals` | Correctness on academic datasets (accuracy, pass rate) | `jarvis eval` |
|
||||
| **Benchmarks** | `openjarvis.bench` | Engine performance (latency, throughput) | `jarvis bench` |
|
||||
|
||||
Use evaluations to answer "does this model get the right answer?" and benchmarks to answer "how fast does this model respond?". See the [Benchmarks guide](benchmarks.md) for the performance measurement system.
|
||||
|
||||
@@ -18,22 +18,38 @@ The OpenJarvis evaluation framework (`openjarvis-evals`) measures model **correc
|
||||
|
||||
## Installation
|
||||
|
||||
The evaluation framework is a standalone package in the `evals/` directory. Install it alongside OpenJarvis:
|
||||
The evaluation framework is part of the main `openjarvis` package — no separate install or extra is required. The standard dev setup is enough:
|
||||
|
||||
```bash
|
||||
uv sync --extra eval
|
||||
uv sync --extra dev
|
||||
```
|
||||
|
||||
This installs the `openjarvis-eval` CLI entry point and all required dependencies (`datasets`, `huggingface-hub`, `tqdm`, `rich`).
|
||||
The framework's core dependencies (`click`, `datasets`, `rich`) are base dependencies of `openjarvis`. Two optional extras enable experiment tracking integrations:
|
||||
|
||||
```bash
|
||||
uv sync --extra dev --extra eval-wandb # Weights & Biases run tracking
|
||||
uv sync --extra dev --extra eval-sheets # Google Sheets results export
|
||||
```
|
||||
|
||||
!!! note "Python version requirement"
|
||||
Python 3.10 requires the `tomli` package for TOML config parsing. The `evals/pyproject.toml` includes this as a conditional dependency, so it is installed automatically.
|
||||
Python 3.10 requires the `tomli` package for TOML config parsing. `openjarvis` declares it as a conditional dependency, so it is installed automatically.
|
||||
|
||||
## Entry Points
|
||||
|
||||
Two equivalent entry points expose the framework:
|
||||
|
||||
| Command | Surface |
|
||||
|---------|---------|
|
||||
| `jarvis eval {list,run,compare,report}` | Canonical CLI. `run` covers the common options; `compare` and `report` post-process result files. |
|
||||
| `python -m openjarvis.evals {list,run,run-all,summarize,reparse-judge}` | Full research surface, including judge configuration, the agentic runner, and episode mode. |
|
||||
|
||||
The `openjarvis-eval` console script is an alias for `python -m openjarvis.evals` — same commands, same options. This guide uses `jarvis eval` wherever its option set suffices and the module form for research-only options.
|
||||
|
||||
---
|
||||
|
||||
## Datasets
|
||||
|
||||
The framework ships with **30+ datasets** covering academic reasoning, agentic tasks, retrieval, conversation quality, and practical use-case benchmarks. Datasets are grouped by category below.
|
||||
The framework ships with **40 registered benchmarks** covering academic reasoning, agentic tasks, coding, retrieval, conversation quality, and practical use-case benchmarks. Datasets are grouped by category below; `uv run python -m openjarvis.evals list` prints the authoritative registry.
|
||||
|
||||
### Use-Case Benchmarks
|
||||
|
||||
@@ -64,6 +80,7 @@ These benchmarks measure reasoning and knowledge on established academic dataset
|
||||
| **MATH-500** | `math500` | reasoning | Competition-level math problems |
|
||||
| **NaturalReasoning** | `natural-reasoning` | reasoning | Natural language reasoning |
|
||||
| **HLE** | `hle` | reasoning | Humanity's Last Exam hard challenges |
|
||||
| **LiveResearchBench** | `liveresearchbench` | reasoning | Recent research comprehension (Salesforce) |
|
||||
| **SimpleQA** | `simpleqa` | chat | Short-form factual question answering |
|
||||
| **IPW** | `ipw` | chat | Intelligence Per Watt mixed benchmark |
|
||||
|
||||
@@ -79,6 +96,11 @@ These benchmarks test multi-step agent capabilities including tool use, code gen
|
||||
| **TerminalBench** | `terminalbench` | agentic | Terminal-based task completion |
|
||||
| **TerminalBench Native** | `terminalbench-native` | agentic | TerminalBench with native Docker execution |
|
||||
| **TerminalBench V2.1** | `terminalbench-v2.1` | agentic | TB v2.1 Harbor-style Docker tasks |
|
||||
| **PinchBench** | `pinchbench` | agentic | Real-world agent tasks |
|
||||
| **TauBench** | `taubench` | agentic | Multi-turn customer service |
|
||||
| **DeepResearchBench** | `liveresearch` | agentic | Deep research report generation |
|
||||
| **DeepResearchBench (alias)** | `deepresearch` | agentic | Same benchmark as `liveresearch` |
|
||||
| **ToolCall-15** | `toolcall15` | agentic | Tool calling benchmark |
|
||||
| **LifelongAgent** | `lifelong-agent` | agentic | Sequential task learning across sessions |
|
||||
| **PaperArena** | `paperarena` | agentic | Scientific paper analysis |
|
||||
| **DeepPlanning** | `deepplanning` | agentic | Shopping constraint planning |
|
||||
@@ -87,6 +109,14 @@ These benchmarks test multi-step agent capabilities including tool use, code gen
|
||||
| **WebChoreArena** | `webchorearena` | agentic | Web chore tasks |
|
||||
| **WorkArena** | `workarena` | agentic | WorkArena++ enterprise workflows |
|
||||
|
||||
Both `liveresearch` and `deepresearch` are registered keys for the DeepResearchBench report-generation benchmark.
|
||||
|
||||
### Coding Benchmarks
|
||||
|
||||
| Dataset | Key | Category | Description |
|
||||
|---------|-----|----------|-------------|
|
||||
| **LiveCodeBench** | `livecodebench` | coding | Competitive programming |
|
||||
|
||||
### Retrieval Benchmarks
|
||||
|
||||
| Dataset | Key | Category | Description |
|
||||
@@ -123,7 +153,7 @@ The framework includes two pre-built configs for evaluating models on the five c
|
||||
### Cloud models
|
||||
|
||||
```bash
|
||||
uv run python -m openjarvis.evals --config src/openjarvis/evals/configs/use_case_v2_cloud.toml
|
||||
uv run jarvis eval run --config src/openjarvis/evals/configs/use_case_v2_cloud.toml
|
||||
```
|
||||
|
||||
This config evaluates **6 cloud models** (Claude Opus 4.6, Claude Haiku 4.5, Gemini 3.1 Pro, Gemini 3.1 Flash Lite, GPT-5.4, GPT-5 Mini) against all 5 use-case benchmarks with 30 samples each, producing a 6x5 = 30-run matrix. Results are written to `results/use-cases-v2-cloud/`.
|
||||
@@ -131,7 +161,7 @@ This config evaluates **6 cloud models** (Claude Opus 4.6, Claude Haiku 4.5, Gem
|
||||
### Local models
|
||||
|
||||
```bash
|
||||
uv run python -m openjarvis.evals --config src/openjarvis/evals/configs/use_case_v2_local.toml
|
||||
uv run jarvis eval run --config src/openjarvis/evals/configs/use_case_v2_local.toml
|
||||
```
|
||||
|
||||
This config evaluates **5 local models** via Ollama (Qwen3.5 122B-A10B, GPT-OSS 120B, GLM4, Qwen3.5 35B-A3B, GLM-4.7-Flash) against the same 5 benchmarks, producing a 5x5 = 25-run matrix. Uses 2 workers (suitable for single-GPU setups). Results are written to `results/use-cases-v2-local/`.
|
||||
@@ -143,15 +173,22 @@ This config evaluates **5 local models** via Ollama (Qwen3.5 122B-A10B, GPT-OSS
|
||||
|
||||
## Inference Backends
|
||||
|
||||
Every evaluation run routes model calls through one of two backends:
|
||||
Every evaluation run routes model calls through one of four backends:
|
||||
|
||||
| Backend | Key | Description |
|
||||
|---------|-----|-------------|
|
||||
| **jarvis-direct** | `jarvis-direct` | Engine-level inference via `SystemBuilder`. Works for local (Ollama, vLLM, llama.cpp) and cloud models. |
|
||||
| **jarvis-agent** | `jarvis-agent` | Agent-level inference with tool calling. Uses `JarvisSystem.ask()` with the specified agent and tools. |
|
||||
| **hermes** | `hermes` | Real Hermes Agent (Nous Research) via subprocess. Requires `--base-url` and `--api-key`. |
|
||||
| **openclaw** | `openclaw` | Real OpenClaw via Node subprocess. Requires `--base-url` and `--api-key`. |
|
||||
|
||||
Use `jarvis-direct` for most evaluations. Use `jarvis-agent` when the benchmark requires tool use — for example, GAIA tasks that reference files that must be read with `file_read`, or arithmetic tasks that benefit from `calculator`.
|
||||
|
||||
The `hermes` and `openclaw` backends shell out to external agent frameworks and need an OpenAI-compatible endpoint for their model calls: pass `--base-url`/`--api-key`, set the `JARVIS_BACKEND_BASE_URL`/`JARVIS_BACKEND_API_KEY` environment variables, or add a `[backend.external]` section to your config (see [Config Reference](#backendexternal)).
|
||||
|
||||
!!! note "TerminalBench Native"
|
||||
`jarvis eval run --backend` additionally accepts `terminalbench-native`, a Docker-based execution backend used by the TerminalBench Native benchmark.
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
@@ -159,73 +196,106 @@ Use `jarvis-direct` for most evaluations. Use `jarvis-agent` when the benchmark
|
||||
### List available benchmarks and backends
|
||||
|
||||
```bash
|
||||
openjarvis-eval list
|
||||
uv run python -m openjarvis.evals list
|
||||
```
|
||||
|
||||
Output:
|
||||
Abridged output (40 benchmarks, 4 backends):
|
||||
|
||||
```
|
||||
Benchmarks:
|
||||
supergpqa [reasoning ] SuperGPQA multiple-choice
|
||||
gaia [agentic ] GAIA agentic benchmark
|
||||
frames [rag ] FRAMES multi-hop RAG
|
||||
wildchat [chat ] WildChat conversation quality
|
||||
|
||||
Backends:
|
||||
jarvis-direct Engine-level inference (local or cloud)
|
||||
jarvis-agent Agent-level inference with tool calling
|
||||
Available Benchmarks
|
||||
┌──────────────────────┬───────────┬───────────────────────────────────┐
|
||||
│ Name │ Category │ Description │
|
||||
├──────────────────────┼───────────┼───────────────────────────────────┤
|
||||
│ supergpqa │ reasoning │ SuperGPQA multiple-choice │
|
||||
│ gpqa │ reasoning │ GPQA graduate-level MCQ │
|
||||
│ ... │ ... │ ... │
|
||||
│ livecodebench │ coding │ LiveCodeBench competitive progr. │
|
||||
│ toolcall15 │ agentic │ ToolCall-15 tool calling benchmark│
|
||||
└──────────────────────┴───────────┴───────────────────────────────────┘
|
||||
Available Backends
|
||||
┌───────────────┬──────────────────────────────────────────────────┐
|
||||
│ jarvis-direct │ Engine-level inference (local or cloud) │
|
||||
│ jarvis-agent │ Agent-level inference with tool calling │
|
||||
│ hermes │ Real Hermes Agent (Nous Research) via subprocess │
|
||||
│ openclaw │ Real OpenClaw via Node subprocess │
|
||||
└───────────────┴──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`jarvis eval list` prints a similar table but currently shows a curated subset of the registry; the module form above is the authoritative listing.
|
||||
|
||||
### Run a single benchmark
|
||||
|
||||
```bash
|
||||
# Evaluate qwen3:8b on SuperGPQA (engine-level, 10 samples default)
|
||||
openjarvis-eval run -b supergpqa -m qwen3:8b
|
||||
# Evaluate qwen3:8b on SuperGPQA (engine-level, 10 samples)
|
||||
uv run jarvis eval run -b supergpqa -m qwen3:8b -n 10
|
||||
|
||||
# Evaluate GPT-4o on GAIA using the agent backend with tools
|
||||
openjarvis-eval run -b gaia -m gpt-4o --backend jarvis-agent \
|
||||
# Evaluate GPT-5 Mini on GAIA using the agent backend with tools
|
||||
uv run jarvis eval run -b gaia -m gpt-5-mini --backend jarvis-agent \
|
||||
--agent orchestrator --tools calculator,file_read -n 50
|
||||
|
||||
# Run FRAMES with vLLM engine, write output to a file
|
||||
openjarvis-eval run -b frames -m llama3:70b -e vllm \
|
||||
# Run FRAMES with the vLLM engine, write output to a file
|
||||
uv run jarvis eval run -b frames -m llama3:70b -e vllm \
|
||||
-o results/frames_llama70b.jsonl
|
||||
|
||||
# Run WildChat with a higher temperature for chat quality
|
||||
openjarvis-eval run -b wildchat -m qwen3:8b --temperature 0.7 -n 100
|
||||
uv run jarvis eval run -b wildchat -m qwen3:8b --temperature 0.7 -n 100
|
||||
```
|
||||
|
||||
#### Full option reference
|
||||
#### `jarvis eval run` option reference
|
||||
|
||||
| Option | Short | Type | Default | Description |
|
||||
|--------|-------|------|---------|-------------|
|
||||
| `--config` | `-c` | path | — | TOML config file; when provided, `-b` and `-m` are not required |
|
||||
| `--benchmark` | `-b` | choice | required* | `supergpqa`, `gaia`, `frames`, or `wildchat` |
|
||||
| `--backend` | | choice | `jarvis-direct` | `jarvis-direct` or `jarvis-agent` |
|
||||
| `--model` | `-m` | str | required* | Model identifier (e.g., `qwen3:8b`, `gpt-4o`) |
|
||||
| `--engine` | `-e` | str | auto | Engine key (`ollama`, `vllm`, `cloud`, ...) |
|
||||
| `--agent` | | str | `orchestrator` | Agent name for `jarvis-agent` backend |
|
||||
| `--tools` | | str | `""` | Comma-separated tool names (e.g., `calculator,file_read`) |
|
||||
| `--benchmark` | `-b` | str | required* | Any registered benchmark key (see `... list`) |
|
||||
| `--model` | `-m` | str | required* | Model identifier (e.g., `qwen3:8b`, `gpt-5-mini`) |
|
||||
| `--max-samples` | `-n` | int | all | Limit the number of samples evaluated |
|
||||
| `--max-workers` | `-w` | int | `4` | Parallel evaluation workers |
|
||||
| `--judge-model` | | str | `gpt-4o` | LLM used for judge-based scoring |
|
||||
| `--output` | `-o` | path | auto-generated | Output JSONL file path |
|
||||
| `--backend` | | choice | `jarvis-direct` | `jarvis-direct`, `jarvis-agent`, `hermes`, `openclaw`, or `terminalbench-native` |
|
||||
| `--base-url` | | str | — | OpenAI-compatible endpoint URL (env: `JARVIS_BACKEND_BASE_URL`) |
|
||||
| `--api-key` | | str | — | API key for the endpoint (env: `JARVIS_BACKEND_API_KEY`) |
|
||||
| `--agent` | | str | — | Agent name for `jarvis-agent` backend (e.g., `orchestrator`) |
|
||||
| `--engine` | `-e` | str | auto | Engine key (`ollama`, `vllm`, `cloud`, ...) |
|
||||
| `--tools` | | str | `""` | Comma-separated tool names (e.g., `calculator,file_read`) |
|
||||
| `--telemetry/--no-telemetry` | | flag | off | Enable telemetry collection during eval |
|
||||
| `--gpu-metrics/--no-gpu-metrics` | | flag | off | Enable GPU metric polling |
|
||||
| `--seed` | | int | `42` | Random seed for dataset shuffling |
|
||||
| `--split` | | str | dataset default | Override the dataset split |
|
||||
| `--temperature` | | float | `0.0` | Generation temperature |
|
||||
| `--max-tokens` | | int | `2048` | Maximum output tokens |
|
||||
| `--model-filter` | | str | — | Filter models by name substring (multi-model configs) |
|
||||
| `--output` | `-o` | path | auto-generated | Output JSONL file path |
|
||||
| `--wandb-project` / `--wandb-entity` / `--wandb-tags` / `--wandb-group` | | str | `""` | Weights & Biases tracking (requires `eval-wandb` extra) |
|
||||
| `--sheets-id` / `--sheets-worksheet` / `--sheets-creds` | | str | `""` | Google Sheets export (requires `eval-sheets` extra) |
|
||||
| `--verbose` | `-v` | flag | off | Enable debug logging |
|
||||
|
||||
*Required when `--config` is not provided.
|
||||
|
||||
#### Research-only options (`python -m openjarvis.evals run`)
|
||||
|
||||
The module CLI accepts everything above plus research-grade options that `jarvis eval run` does not expose:
|
||||
|
||||
| Option | Short | Type | Default | Description |
|
||||
|--------|-------|------|---------|-------------|
|
||||
| `--max-workers` | `-w` | int | `4` | Parallel evaluation workers |
|
||||
| `--judge-model` | | str | `gpt-5-mini-2025-08-07` | LLM used for judge-based scoring (see `--help` for the current default) |
|
||||
| `--judge-engine` | | str | `cloud` | Engine key for the LLM judge; use `vllm` to judge locally |
|
||||
| `--split` | | str | dataset default | Override the dataset split |
|
||||
| `--compact` | | flag | off | Dense single-table output |
|
||||
| `--trace-detail` | | flag | off | Full per-step trace listing |
|
||||
| `--agentic` | | flag | off | Use `AgenticRunner` for multi-turn agent execution |
|
||||
| `--episode-mode` | | flag | off | Sequential episode processing with lifelong learning (required for `lifelong-agent` and similar benchmarks) |
|
||||
| `--concurrency` | | int | `1` | Parallel query execution (AgenticRunner only) |
|
||||
| `--query-timeout` | | float | — | Per-query wall-clock timeout in seconds (AgenticRunner only) |
|
||||
|
||||
Note: the module CLI's `--backend` choice covers `jarvis-direct`, `jarvis-agent`, `hermes`, and `openclaw`; `terminalbench-native` as a backend is available via `jarvis eval run` and TOML configs.
|
||||
|
||||
### Run all benchmarks at once
|
||||
|
||||
The `run-all` command evaluates a single model against all four benchmarks sequentially and writes results to an output directory:
|
||||
The `run-all` command (module CLI only) evaluates a single model against **every registered benchmark** sequentially and writes results to an output directory:
|
||||
|
||||
```bash
|
||||
openjarvis-eval run-all -m qwen3:8b
|
||||
uv run python -m openjarvis.evals run-all -m qwen3:8b
|
||||
|
||||
# With options
|
||||
openjarvis-eval run-all -m gpt-4o -n 100 --output-dir results/gpt4o/
|
||||
uv run python -m openjarvis.evals run-all -m gpt-5-mini -n 100 --output-dir results/gpt5mini/
|
||||
```
|
||||
|
||||
Output files are written as `{output_dir}/{benchmark}_{model-slug}.jsonl`. The model slug replaces `/` and `:` with `-`, so `qwen3:8b` becomes `qwen3-8b`.
|
||||
@@ -235,7 +305,7 @@ Output files are written as `{output_dir}/{benchmark}_{model-slug}.jsonl`. The m
|
||||
After a run, inspect a JSONL results file:
|
||||
|
||||
```bash
|
||||
openjarvis-eval summarize results/supergpqa_qwen3-8b.jsonl
|
||||
uv run python -m openjarvis.evals summarize results/supergpqa_qwen3-8b.jsonl
|
||||
```
|
||||
|
||||
Output:
|
||||
@@ -251,6 +321,55 @@ Accuracy: 0.7222
|
||||
Errors: 2
|
||||
```
|
||||
|
||||
The module CLI also provides `reparse-judge`, which re-parses stored judge output in a results file and recovers records whose judge verdicts initially failed to parse — useful after improving the judge-output parser without re-running inference.
|
||||
|
||||
### Compare and report
|
||||
|
||||
`jarvis eval` adds two post-processing commands for result files:
|
||||
|
||||
```bash
|
||||
# Side-by-side metric comparison across runs
|
||||
uv run jarvis eval compare results/supergpqa_qwen3-8b.jsonl results/supergpqa_gpt-5-mini.jsonl
|
||||
|
||||
# Detailed report (accuracy, latency, cost, per-subject breakdown) for one run
|
||||
uv run jarvis eval report results/supergpqa_qwen3-8b.jsonl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Evaluating an Already-Running Endpoint
|
||||
|
||||
If you already have an OpenAI-compatible server running — `jarvis serve`, vLLM, SGLang, llama.cpp's server, or a hosted endpoint — point an eval directly at it with `--base-url` and `--api-key`:
|
||||
|
||||
```bash
|
||||
# A vLLM server is already serving Qwen/Qwen3-8B on a GPU node:
|
||||
# vllm serve Qwen/Qwen3-8B --port 8000
|
||||
uv run jarvis eval run -b supergpqa -m Qwen/Qwen3-8B \
|
||||
--base-url http://gpu-node:8000/v1 \
|
||||
--api-key local-key \
|
||||
-n 50
|
||||
```
|
||||
|
||||
The `-m` value must match a model id the server reports at `GET /v1/models`. Both flags fall back to the `JARVIS_BACKEND_BASE_URL` and `JARVIS_BACKEND_API_KEY` environment variables, so CI jobs can set them once:
|
||||
|
||||
```bash
|
||||
export JARVIS_BACKEND_BASE_URL=http://gpu-node:8000/v1
|
||||
export JARVIS_BACKEND_API_KEY=local-key
|
||||
uv run jarvis eval run -b gaia -m Qwen/Qwen3-8B --backend jarvis-agent -n 25
|
||||
```
|
||||
|
||||
For the external `hermes` and `openclaw` backends these values are **required** (the foreign frameworks need an endpoint to send model calls to).
|
||||
|
||||
!!! tip "Engine-level alternative for vLLM"
|
||||
The vLLM engine also honors the `VLLM_HOST` environment variable (default `http://localhost:8000`):
|
||||
|
||||
```bash
|
||||
VLLM_HOST=http://gpu-node:8000 uv run python -m openjarvis.evals run \
|
||||
-b supergpqa -m Qwen/Qwen3-8B -e vllm -n 50
|
||||
```
|
||||
|
||||
`VLLM_HOST` is process-global — if the candidate and the judge both use the `vllm` engine, they share the same endpoint. Prefer `--base-url` when you need them separate.
|
||||
|
||||
---
|
||||
|
||||
## TOML Config System
|
||||
@@ -260,7 +379,7 @@ For research workflows that compare multiple models across multiple benchmarks,
|
||||
### Running from a config
|
||||
|
||||
```bash
|
||||
openjarvis-eval run --config src/openjarvis/evals/configs/full-suite.toml
|
||||
uv run jarvis eval run --config src/openjarvis/evals/configs/full-suite.toml
|
||||
```
|
||||
|
||||
When `--config` is provided, the `-b`/`--benchmark` and `-m`/`--model` options are not required. All settings come from the config file. The CLI expands the matrix, prints a progress table, and writes results to the configured `output_dir`.
|
||||
@@ -269,7 +388,7 @@ When `--config` is provided, the `-b`/`--benchmark` and `-m`/`--model` options a
|
||||
|
||||
A config file has six sections: `[meta]`, `[defaults]`, `[judge]`, `[run]`, `[[models]]`, and `[[benchmarks]]`. Only `[[models]]` and `[[benchmarks]]` are required — all other sections are optional and fall back to built-in defaults.
|
||||
|
||||
```toml title="evals/configs/full-suite.toml"
|
||||
```toml title="src/openjarvis/evals/configs/full-suite.toml"
|
||||
# Suite-level metadata (optional)
|
||||
[meta]
|
||||
name = "full-suite-v1"
|
||||
@@ -353,7 +472,7 @@ For example, `temperature` is resolved as: use `[defaults].temperature` (0.0), t
|
||||
|
||||
A config requires only one `[[models]]` and one `[[benchmarks]]` entry:
|
||||
|
||||
```toml title="evals/configs/minimal.toml"
|
||||
```toml title="src/openjarvis/evals/configs/minimal.toml"
|
||||
[[models]]
|
||||
name = "qwen3:8b"
|
||||
|
||||
@@ -365,7 +484,7 @@ This runs SuperGPQA against qwen3:8b with all default settings. Use this as a st
|
||||
|
||||
### Single-run config with full options
|
||||
|
||||
```toml title="evals/configs/single-run.toml"
|
||||
```toml title="src/openjarvis/evals/configs/single-run.toml"
|
||||
[meta]
|
||||
name = "single-run-example"
|
||||
description = "Evaluate SuperGPQA with a single model and full configuration"
|
||||
@@ -425,7 +544,8 @@ Configuration for the LLM used as a judge in GAIA, FRAMES, and WildChat scoring.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `model` | str | `"gpt-4o"` | Judge model identifier |
|
||||
| `model` | str | `"gpt-5-mini-2025-08-07"` | Judge model identifier |
|
||||
| `engine` | str | `None` | Engine key for the judge (e.g., `"vllm"` to judge locally; defaults to cloud) |
|
||||
| `provider` | str | `None` | Provider override (e.g., `"openai"`) |
|
||||
| `temperature` | float | `0.0` | Judge sampling temperature |
|
||||
| `max_tokens` | int | `1024` | Maximum judge output tokens |
|
||||
@@ -444,6 +564,20 @@ Execution settings that apply to the entire suite.
|
||||
| `seed` | int | `42` | Random seed for dataset shuffling |
|
||||
| `telemetry` | bool | `false` | Enable GPU telemetry capture (energy, power, utilization, throughput) |
|
||||
| `gpu_metrics` | bool | `false` | Enable GPU metric polling via `pynvml` (requires `pynvml` or `nvidia-ml-py`) |
|
||||
| `warmup_samples` | int | `0` | Untimed warmup samples before measurement |
|
||||
| `energy_vendor` | str | `""` | GPU energy vendor override |
|
||||
| `max_turns` | int | `None` | Maximum agent turns per query |
|
||||
| `wandb_project` / `wandb_entity` / `wandb_tags` / `wandb_group` | str | `""` | Weights & Biases tracking |
|
||||
| `sheets_spreadsheet_id` / `sheets_worksheet` / `sheets_credentials_path` | str | `""` / `"Results"` / `""` | Google Sheets export |
|
||||
|
||||
### `[backend.external]`
|
||||
|
||||
Endpoint settings for the `hermes` and `openclaw` backends. Environment variables override TOML values.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `base_url` | str | `None` | OpenAI-compatible endpoint URL (env: `JARVIS_BACKEND_BASE_URL`) |
|
||||
| `api_key` | str | `None` | API key for the endpoint (env: `JARVIS_BACKEND_API_KEY`) |
|
||||
|
||||
### `[[models]]`
|
||||
|
||||
@@ -451,7 +585,7 @@ One block per model. The `name` field is required.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | str | required | Model identifier (e.g., `"qwen3:8b"`, `"gpt-4o"`) |
|
||||
| `name` | str | required | Model identifier (e.g., `"qwen3:8b"`, `"gpt-5-mini"`) |
|
||||
| `engine` | str | `None` | Engine key to use (`"ollama"`, `"vllm"`, `"cloud"`, ...) |
|
||||
| `provider` | str | `None` | Provider override for cloud models (e.g., `"openai"`) |
|
||||
| `temperature` | float | `None` | Override `[defaults].temperature` for this model |
|
||||
@@ -468,10 +602,12 @@ One block per benchmark. The `name` field is required.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | str | required | Benchmark key: `supergpqa`, `gaia`, `frames`, or `wildchat` |
|
||||
| `backend` | str | `"jarvis-direct"` | Inference backend: `jarvis-direct` or `jarvis-agent` |
|
||||
| `name` | str | required | Any registered benchmark key (see `uv run python -m openjarvis.evals list`) |
|
||||
| `backend` | str | `"jarvis-direct"` | `jarvis-direct`, `jarvis-agent`, `hermes`, `openclaw`, or `terminalbench-native` |
|
||||
| `max_samples` | int | `None` | Limit number of samples; `None` evaluates the full dataset |
|
||||
| `split` | str | `None` | Override the default dataset split |
|
||||
| `subset` | str | `None` | Dataset subset/variant (benchmark-specific) |
|
||||
| `record_ids` | list[str] | `None` | Evaluate only these record ids |
|
||||
| `agent` | str | `None` | Agent name for `jarvis-agent` backend (e.g., `"orchestrator"`) |
|
||||
| `tools` | list[str] | `[]` | Tool names for `jarvis-agent` backend |
|
||||
| `judge_model` | str | `None` | Override `[judge].model` for this benchmark only |
|
||||
@@ -647,7 +783,7 @@ The `EvalRunner` processes samples concurrently using a `ThreadPoolExecutor`. Re
|
||||
|
||||
```bash
|
||||
# Use more workers for faster evaluation (if the engine supports concurrent requests)
|
||||
openjarvis-eval run -b supergpqa -m qwen3:8b -w 8 -n 500
|
||||
uv run python -m openjarvis.evals run -b supergpqa -m qwen3:8b -w 8 -n 500
|
||||
```
|
||||
|
||||
!!! warning "Worker count and engine load"
|
||||
|
||||
@@ -392,7 +392,7 @@ enforce_tool_confirmation = true
|
||||
| `secret_scanner` | `bool` | `true` | Run `SecretScanner` on all text |
|
||||
| `pii_scanner` | `bool` | `true` | Run `PIIScanner` on all text |
|
||||
| `audit_log_path` | `str` | `~/.openjarvis/audit.db` | Path to the SQLite audit log |
|
||||
| `enforce_tool_confirmation` | `bool` | `true` | Require explicit confirmation before tool execution |
|
||||
| `enforce_tool_confirmation` | `bool` | `true` | Accepted by the loader but **not currently enforced**. See [System Access](system-access.md#confirmation-behaviour) for when prompts actually happen |
|
||||
|
||||
!!! tip "Start with warn, tighten later"
|
||||
`mode = "warn"` is a good starting point. It lets you observe what patterns are being triggered without disrupting normal usage. Switch to `"redact"` once you are satisfied that the scanner isn't producing too many false positives for your workload.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# System Access
|
||||
|
||||
How to give an agent access to the machine it runs on, and where the real
|
||||
limits are.
|
||||
|
||||
!!! warning
|
||||
`shell_exec` runs arbitrary commands as your user. There is no command
|
||||
allowlist, no denylist, and no sandbox unless you turn one on. An agent
|
||||
holding this tool can do anything you can do from a terminal.
|
||||
|
||||
---
|
||||
|
||||
## Start here: you probably have no tools enabled
|
||||
|
||||
If the agent tells you it can't run commands or read files, that's usually not
|
||||
a permissions problem. It means no tools were enabled in the first place.
|
||||
|
||||
Tools come from `tools.enabled`, falling back to `agent.tools`. Both default to
|
||||
empty, and an empty value builds the agent with **zero tools**. Nothing is
|
||||
enabled by default.
|
||||
|
||||
First check whether you have a config file at all:
|
||||
|
||||
```bash
|
||||
cat ~/.openjarvis/config.toml
|
||||
```
|
||||
|
||||
If it isn't there, that's your answer. Create it:
|
||||
|
||||
```toml
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[intelligence]
|
||||
default_model = "qwen3.5:9b"
|
||||
|
||||
[agent]
|
||||
default_agent = "orchestrator"
|
||||
|
||||
[tools]
|
||||
enabled = ["shell_exec", "file_read", "file_write", "think"]
|
||||
```
|
||||
|
||||
There's a fuller version at
|
||||
`configs/openjarvis/examples/full-system-access.toml`.
|
||||
|
||||
Then confirm the list actually resolved:
|
||||
|
||||
```bash
|
||||
python -c "from openjarvis.core.config import load_config; print(load_config().tools.enabled)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What the tools reach
|
||||
|
||||
| Tool | Scope |
|
||||
|------|-------|
|
||||
| `shell_exec` | Any command, as your user. 30s default timeout, 300s max, output capped at 100 KB per stream. |
|
||||
| `file_read` | Any readable path. 1 MB cap. |
|
||||
| `file_write` | Any writable path. 10 MB cap, can create parent directories. |
|
||||
| `apply_patch` | Applies unified diffs to any path. |
|
||||
| `code_interpreter` | Python in a subprocess, behind a coarse pattern blocklist. |
|
||||
|
||||
`file_read` and `file_write` take an `allowed_dirs` argument that limits them to
|
||||
a set of directories, but no config key populates it. When it's empty every path
|
||||
is allowed. If you want a filesystem jail today, use the container sandbox
|
||||
instead of relying on these tools to enforce one.
|
||||
|
||||
### Sensitive filenames
|
||||
|
||||
`file_read` and `file_write` refuse names matching a short glob list: `.env`,
|
||||
`*.pem`, `id_rsa`, `credentials.*` and a dozen or so others. It matches on the
|
||||
filename only, not the path or the contents, and only those two tools consult
|
||||
it. `shell_exec`, `apply_patch` and `code_interpreter` skip it entirely, so
|
||||
`cat ~/.ssh/id_rsa` through `shell_exec` works fine. Treat it as protection
|
||||
against fat fingers, not as a security boundary.
|
||||
|
||||
---
|
||||
|
||||
## Confirmation behaviour
|
||||
|
||||
`shell_exec`, `git_commit` and `agent_kill` are marked `requires_confirmation`.
|
||||
What that translates to depends entirely on how you launched the agent:
|
||||
|
||||
| Entry point | Behaviour |
|
||||
|-------------|-----------|
|
||||
| `jarvis chat` | Prompts before each call. |
|
||||
| `jarvis ask` | Auto-approves. |
|
||||
| `jarvis agent ask` | Auto-approves. Pass `--no-yes` if you want prompts. |
|
||||
| HTTP server, desktop app | Auto-approves. Tools you added to an agent's toolkit count as pre-approved. |
|
||||
| Embedded via `SystemBuilder` | No callback is wired, so these tools fail closed. |
|
||||
|
||||
That last row catches people out. If `shell_exec` returns "requires
|
||||
confirmation but no confirmation callback is available", you're constructing the
|
||||
agent yourself and need to pass a `confirm_callback`.
|
||||
|
||||
!!! note "`enforce_tool_confirmation` doesn't do anything"
|
||||
The config loader accepts `security.enforce_tool_confirmation`, but nothing
|
||||
on the tool execution path reads it. Setting it won't change confirmation
|
||||
behaviour anywhere. Use the table above instead.
|
||||
|
||||
---
|
||||
|
||||
## macOS: Full Disk Access
|
||||
|
||||
On macOS the operating system is the real boundary, not the config. Shell
|
||||
access and ordinary file access start working as soon as you enable the tools.
|
||||
TCC-protected data does not: Messages, Mail, Photos, Safari history, Contacts
|
||||
and Calendar all stay locked, and no config key will change that.
|
||||
|
||||
Grant Full Disk Access to whichever process hosts the backend. Child processes
|
||||
inherit it:
|
||||
|
||||
| How you run OpenJarvis | Grant access to |
|
||||
|------------------------|-----------------|
|
||||
| CLI (`jarvis ask`, `jarvis chat`) | Your terminal (Terminal, iTerm, Warp) |
|
||||
| Desktop app | `OpenJarvis.app`, which spawns `jarvis serve` beneath it |
|
||||
| launchd (`deploy/launchd/com.openjarvis.plist`) | The `jarvis` binary, as its own entry |
|
||||
|
||||
System Settings, then Privacy & Security, then Full Disk Access, then **+**.
|
||||
|
||||
A launchd daemon gets its own TCC context, so granting access to Terminal does
|
||||
nothing for it. Add `/usr/local/bin/jarvis` separately.
|
||||
|
||||
To check whether the grant took:
|
||||
|
||||
```bash
|
||||
head -c 16 ~/Library/Messages/chat.db >/dev/null 2>&1 \
|
||||
&& echo "granted" || echo "denied"
|
||||
```
|
||||
|
||||
Restart the host process after you change the setting.
|
||||
|
||||
### Driving Mac apps
|
||||
|
||||
AppleScript works through `shell_exec`:
|
||||
|
||||
```
|
||||
osascript -e 'tell application "Music" to play'
|
||||
```
|
||||
|
||||
macOS asks for Automation permission once per target app, the first time you
|
||||
touch it.
|
||||
|
||||
---
|
||||
|
||||
## What you can't do
|
||||
|
||||
There's no computer use. OpenJarvis can't see your screen, move the pointer or
|
||||
send keystrokes. No tool for it is registered and no input automation library
|
||||
appears anywhere in the codebase, so granting Accessibility or Screen Recording
|
||||
buys you nothing on its own.
|
||||
|
||||
The `click` and `type` actions you'll find are Playwright, scoped to a browser
|
||||
page rather than the desktop.
|
||||
|
||||
Some of this is reachable through `shell_exec` if you bring the tooling
|
||||
yourself. `screencapture` will take screenshots once you've granted Screen
|
||||
Recording, and something like `cliclick` will move the pointer. That gets you
|
||||
scripted actions. It doesn't get you an agent that looks at the screen and
|
||||
works out where to click.
|
||||
|
||||
---
|
||||
|
||||
## Narrowing access
|
||||
|
||||
Access widens and narrows through `tools.enabled`. Drop entries to take
|
||||
capabilities away. That list is the whole grant.
|
||||
|
||||
Two stronger isolation options exist. Both are off by default:
|
||||
|
||||
```toml
|
||||
[sandbox]
|
||||
enabled = true # run tools inside a container
|
||||
runtime = "docker"
|
||||
|
||||
[security.capabilities]
|
||||
enabled = true # RBAC over declared tool capabilities
|
||||
policy_path = "~/.openjarvis/policy.yaml"
|
||||
```
|
||||
|
||||
!!! note "Capabilities are open by default even once enabled"
|
||||
`CapabilityPolicy` is built with `default_deny=False` and no config key
|
||||
exposes that flag, so an agent with no explicit policy entry gets every
|
||||
capability. Write entries for every agent you mean to restrict.
|
||||
|
||||
For anything untrusted, reach for `docker_shell_exec` and
|
||||
`code_interpreter_docker` rather than the host-side versions.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [Security](security.md) for scanners, the audit log and guardrails
|
||||
- [Tools](tools.md) for the full registry
|
||||
- [Code Assistant](code-assistant.md) for a narrower shell-enabled setup
|
||||
- [External MCP Servers](mcp-external-servers.md) for capabilities OpenJarvis doesn't ship
|
||||
Generated
+510
-55
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "openjarvis-chat",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openjarvis-chat",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"@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,13 +42,14 @@
|
||||
"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",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -3719,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",
|
||||
@@ -3729,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": {
|
||||
@@ -3745,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"
|
||||
],
|
||||
@@ -3776,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"
|
||||
],
|
||||
@@ -3793,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"
|
||||
],
|
||||
@@ -3810,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": [
|
||||
@@ -3827,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": [
|
||||
@@ -3844,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": [
|
||||
@@ -3861,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": [
|
||||
@@ -3878,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": [
|
||||
@@ -3895,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"
|
||||
],
|
||||
@@ -3912,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"
|
||||
],
|
||||
@@ -3929,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"
|
||||
],
|
||||
@@ -4064,6 +4080,17 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
@@ -4136,6 +4163,13 @@
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -4274,6 +4308,131 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
|
||||
"integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.6",
|
||||
"@vitest/utils": "3.2.6",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz",
|
||||
"integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.6",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz",
|
||||
"integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz",
|
||||
"integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.6",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz",
|
||||
"integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.6",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz",
|
||||
"integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz",
|
||||
"integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.6",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -4414,6 +4573,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
|
||||
@@ -4654,6 +4823,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
@@ -4741,6 +4920,23 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -4793,6 +4989,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
@@ -5390,6 +5596,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -5749,6 +5965,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
@@ -5975,6 +6198,16 @@
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
@@ -8156,6 +8389,13 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
||||
@@ -9742,6 +9982,23 @@
|
||||
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -10987,6 +11244,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -11072,6 +11336,13 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -11081,6 +11352,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stdin-discarder": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
||||
@@ -11294,6 +11572,26 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal/node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||
@@ -11459,6 +11757,20 @@
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -11475,6 +11787,36 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.27",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
|
||||
@@ -12164,6 +12506,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.4.1",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"pathe": "^2.0.3",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-pwa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz",
|
||||
@@ -12195,6 +12560,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
|
||||
"integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.6",
|
||||
"@vitest/mocker": "3.2.6",
|
||||
"@vitest/pretty-format": "^3.2.6",
|
||||
"@vitest/runner": "3.2.6",
|
||||
"@vitest/snapshot": "3.2.6",
|
||||
"@vitest/spy": "3.2.6",
|
||||
"@vitest/utils": "3.2.6",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"std-env": "^3.9.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tinypool": "^1.1.1",
|
||||
"tinyrainbow": "^2.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||
"vite-node": "3.2.4",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.6",
|
||||
"@vitest/ui": "3.2.6",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/debug": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
@@ -12343,6 +12781,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-background-sync": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz",
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"build:tauri": "tsc -b && vite build --outDir dist",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
@@ -48,12 +49,13 @@
|
||||
"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",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1134
-1015
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ license = "MIT"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
toml_edit = "0.22"
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
@@ -23,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"]
|
||||
|
||||
+1653
-252
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,12 @@ export default function App() {
|
||||
const [setupDone, setSetupDone] = useState(!isTauri());
|
||||
const handleSetupReady = useCallback(() => {
|
||||
setSetupDone(true);
|
||||
track('setup_completed', { preset: 'default' });
|
||||
// Only fire once per install — guard against setup screen re-appearing
|
||||
// on reinstalls or dev reloads.
|
||||
if (!localStorage.getItem('oj-setup-completed')) {
|
||||
localStorage.setItem('oj-setup-completed', '1');
|
||||
track('setup_completed', { preset: 'default' });
|
||||
}
|
||||
}, []);
|
||||
const prevModelRef = useRef<string>('');
|
||||
const setModels = useAppStore((s) => s.setModels);
|
||||
@@ -84,7 +89,7 @@ export default function App() {
|
||||
setSavings(data);
|
||||
if (optInEnabled && optInDisplayName && data) {
|
||||
const claudeEntry = data.per_provider.find(
|
||||
(p) => p.provider === 'claude-opus-4.6',
|
||||
(p) => p.provider === 'claude-fable-5',
|
||||
);
|
||||
const dollarSavings = claudeEntry ? claudeEntry.total_cost : 0;
|
||||
const energySaved = data.per_provider.reduce(
|
||||
|
||||
@@ -22,6 +22,8 @@ export function ChatArea() {
|
||||
const navigate = useNavigate();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -34,15 +36,34 @@ export function ChatArea() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (streamState.isStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = streamState.isStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content]);
|
||||
}, [messages, streamState.content, streamState.isStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
const distance = scrollHeight - scrollTop - clientHeight;
|
||||
const scrolledUp = scrollTop < lastScrollTop.current;
|
||||
lastScrollTop.current = scrollTop;
|
||||
if (scrolledUp && distance >= 1) {
|
||||
// Any upward scroll away from the bottom stops autoscroll immediately,
|
||||
// so streaming content never fights the user (no jitter). Sub-1px
|
||||
// upward movement (elastic bounce settling at the bottom) is ignored.
|
||||
shouldAutoScroll.current = false;
|
||||
} else if (!scrolledUp) {
|
||||
// Re-engage when scrolled back to the bottom. < 2 rather than < 1:
|
||||
// at fractional zoom levels the at-bottom residual can reach 1px,
|
||||
// which would otherwise leave autoscroll permanently disengaged.
|
||||
shouldAutoScroll.current = distance < 2;
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Send, Square, Paperclip, Search } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore, generateId } from '../../lib/store';
|
||||
import { streamChat, streamResearch } from '../../lib/sse';
|
||||
import { fetchSavings, getBase } from '../../lib/api';
|
||||
@@ -96,7 +97,13 @@ export function InputArea() {
|
||||
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
|
||||
const corpusSync = useResearchCorpusSync(deepResearch);
|
||||
|
||||
const { state: speechState, available: speechAvailable, startRecording, stopRecording } = useSpeech();
|
||||
const {
|
||||
state: speechState,
|
||||
error: speechError,
|
||||
available: speechAvailable,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
} = useSpeech();
|
||||
|
||||
// Abort in-flight stream when the user switches models mid-generation.
|
||||
// This prevents errors from trying to continue a stream with a stale model.
|
||||
@@ -121,6 +128,12 @@ export function InputArea() {
|
||||
: streamState.isStreaming ? 'streaming'
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (speechError) {
|
||||
toast.error(speechError, { duration: 8000 });
|
||||
}
|
||||
}, [speechError]);
|
||||
|
||||
const handleMicClick = useCallback(async () => {
|
||||
if (speechState === 'recording') {
|
||||
try {
|
||||
@@ -155,6 +168,10 @@ export function InputArea() {
|
||||
const sendMessage = useCallback(async () => {
|
||||
const content = input.trim();
|
||||
if (!content || streamState.isStreaming) return;
|
||||
if (!selectedModel) {
|
||||
toast.error('Pick a model first (⌘K)');
|
||||
return;
|
||||
}
|
||||
|
||||
setInput('');
|
||||
|
||||
@@ -226,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(),
|
||||
@@ -303,6 +324,23 @@ export function InputArea() {
|
||||
energy_j: ev.energy_j,
|
||||
duration_s: ev.duration_s,
|
||||
});
|
||||
} else if (ev.type === 'error') {
|
||||
// Backend setup/worker failure (Ollama down, planner model
|
||||
// missing, KnowledgeStore locked, etc.). Without surfacing the
|
||||
// message, the user sees only the generic "No response was
|
||||
// generated" fallback and has no way to self-diagnose.
|
||||
const msg = ev.message || 'Research failed (no detail provided)';
|
||||
accumulatedContent = accumulatedContent
|
||||
? `${accumulatedContent}\n\n**Research stopped:** ${msg}`
|
||||
: `**Research failed:** ${msg}`;
|
||||
setStreamState({ content: accumulatedContent, phase: '' });
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(),
|
||||
level: 'error',
|
||||
category: 'chat',
|
||||
message: `Deep Research error: ${msg}`,
|
||||
});
|
||||
toast.error(msg, { duration: 8000 });
|
||||
} else if (ev.type === 'done') {
|
||||
if (ev.usage) {
|
||||
usage = {
|
||||
@@ -555,7 +593,7 @@ export function InputArea() {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message OpenJarvis..."
|
||||
placeholder={selectedModel ? 'Message OpenJarvis...' : 'Pick a model first (⌘K)...'}
|
||||
rows={1}
|
||||
className="flex-1 bg-transparent outline-none resize-none text-sm leading-relaxed"
|
||||
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
|
||||
@@ -580,13 +618,13 @@ export function InputArea() {
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || modelLoading}
|
||||
disabled={!input.trim() || modelLoading || !selectedModel}
|
||||
title={selectedModel ? 'Send message' : 'Pick a model first (⌘K)'}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
style={{
|
||||
background: input.trim() ? 'var(--color-accent)' : 'var(--color-bg-tertiary)',
|
||||
color: input.trim() ? 'white' : 'var(--color-text-tertiary)',
|
||||
}}
|
||||
title="Send message"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
|
||||
@@ -29,8 +29,8 @@ interface TelemetryStats {
|
||||
}
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-5.3', input: 2.00, output: 10.00, primary: true },
|
||||
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00, primary: false },
|
||||
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00, primary: true },
|
||||
{ name: 'Claude Fable 5', input: 10.00, output: 50.00, primary: false },
|
||||
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00, primary: false },
|
||||
];
|
||||
|
||||
|
||||
@@ -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]);
|
||||
@@ -139,8 +149,7 @@ export function CommandPalette() {
|
||||
setCommandPaletteOpen(false);
|
||||
|
||||
if (modelId !== previousModel) {
|
||||
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
createConversation(modelId);
|
||||
const { setModelLoading, addLogEntry } = useAppStore.getState();
|
||||
setModelLoading(true);
|
||||
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
|
||||
try {
|
||||
@@ -210,24 +219,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 +337,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 +450,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 +481,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>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { DollarSign, TrendingDown, Cloud, HardDrive } from 'lucide-react';
|
||||
import { useAppStore } from '../../lib/store';
|
||||
|
||||
const CLOUD_PRICING = [
|
||||
{ name: 'GPT-5.3', input: 2.00, output: 10.00 },
|
||||
{ name: 'Claude Opus 4.6', input: 5.00, output: 25.00 },
|
||||
{ name: 'GPT-5.6 Sol', input: 5.00, output: 30.00 },
|
||||
{ name: 'Claude Fable 5', input: 10.00, output: 50.00 },
|
||||
{ name: 'Gemini 3.1 Pro', input: 2.00, output: 12.00 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -221,8 +222,8 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
};
|
||||
|
||||
const PROVIDER_COLORS: Record<string, string> = {
|
||||
'gpt-5.3': colors.green,
|
||||
'claude-opus-4.6': colors.yellow,
|
||||
'gpt-5.6-sol': colors.green,
|
||||
'claude-fable-5': colors.yellow,
|
||||
'gemini-3.1-pro': colors.accent,
|
||||
};
|
||||
|
||||
@@ -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,6 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Loader2, CheckCircle2, XCircle, Cpu, Server, Database } from 'lucide-react';
|
||||
import { getSetupStatus, type SetupStatus } from '../lib/api';
|
||||
import {
|
||||
getSetupStatus,
|
||||
fetchModels,
|
||||
fetchRecommendedModel,
|
||||
type SetupStatus,
|
||||
} from '../lib/api';
|
||||
import { useAppStore } from '../lib/store';
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'ollama_ready', label: 'Inference Engine', icon: Cpu, detail: 'Starting Ollama...' },
|
||||
@@ -70,10 +76,33 @@ function StepRow({
|
||||
|
||||
export function SetupScreen({ onReady }: { onReady: () => void }) {
|
||||
const [status, setStatus] = useState<SetupStatus | null>(null);
|
||||
const handedOffRef = useRef(false);
|
||||
const poll = useCallback(async () => {
|
||||
const s = await getSetupStatus();
|
||||
if (s) setStatus(s);
|
||||
if (s?.phase === 'ready') {
|
||||
if (s?.phase === 'ready' && !handedOffRef.current) {
|
||||
handedOffRef.current = true;
|
||||
// Pre-select a model BEFORE handing off so the chat is usable on
|
||||
// first send. Without this, the main app's post-mount fetch can
|
||||
// lose a race to a fast first message and Ollama 400s.
|
||||
try {
|
||||
const [models, rec] = await Promise.all([
|
||||
fetchModels().catch(() => []),
|
||||
fetchRecommendedModel().catch(() => ({ model: '', reason: '' })),
|
||||
]);
|
||||
const store = useAppStore.getState();
|
||||
store.setModels(models);
|
||||
store.setModelsLoading(false);
|
||||
const recommended = rec.model && models.some((m) => m.id === rec.model)
|
||||
? rec.model
|
||||
: models[0]?.id || '';
|
||||
if (recommended && !store.selectedModel) {
|
||||
store.setSelectedModel(recommended);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: store.setModels auto-selects on later fetch, and
|
||||
// the InputArea guards the empty-model case with a toast.
|
||||
}
|
||||
setTimeout(() => onReady(), 600);
|
||||
}
|
||||
}, [onReady]);
|
||||
@@ -117,7 +146,14 @@ export function SetupScreen({ onReady }: { onReady: () => void }) {
|
||||
|
||||
{/* Steps */}
|
||||
<div className="flex flex-col gap-2 mb-8">
|
||||
{STEPS.map((step) => (
|
||||
{(status?.source === 'custom'
|
||||
? [
|
||||
{ key: 'ollama_ready' as const, label: 'Inference Engine', icon: Cpu, detail: 'Connecting to your server...' },
|
||||
{ key: 'model_ready' as const, label: 'Endpoint', icon: Database, detail: 'Checking endpoint...' },
|
||||
{ key: 'server_ready' as const, label: 'API Server', icon: Server, detail: 'Starting server...' },
|
||||
]
|
||||
: STEPS
|
||||
).map((step) => (
|
||||
<StepRow
|
||||
key={step.key}
|
||||
icon={step.icon}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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
|
||||
// every data-plane call. These tests cover the pure helpers (getApiKey,
|
||||
// authHeaders) that source the key and build the header.
|
||||
|
||||
const SETTINGS_KEY = 'openjarvis-settings';
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
// Minimal in-memory localStorage stub so the helpers can run under node
|
||||
// (no jsdom dependency).
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
getItem(k: string): string | null {
|
||||
return this.store.has(k) ? (this.store.get(k) as string) : null;
|
||||
}
|
||||
setItem(k: string, v: string): void {
|
||||
this.store.set(k, String(v));
|
||||
}
|
||||
removeItem(k: string): void {
|
||||
this.store.delete(k);
|
||||
}
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||
fetchMock.mockReset();
|
||||
globalThis.fetch = fetchMock;
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
async function freshApi() {
|
||||
// Re-import to pick up the current localStorage stub.
|
||||
return await import('./api');
|
||||
}
|
||||
|
||||
describe('getApiKey', () => {
|
||||
it('returns empty string when no key is configured', async () => {
|
||||
const { getApiKey } = await freshApi();
|
||||
expect(getApiKey()).toBe('');
|
||||
});
|
||||
|
||||
it('reads apiKey from the openjarvis-settings localStorage blob', async () => {
|
||||
localStorage.setItem(
|
||||
SETTINGS_KEY,
|
||||
JSON.stringify({ apiUrl: 'http://x', apiKey: 'sk-local-123' }),
|
||||
);
|
||||
const { getApiKey } = await freshApi();
|
||||
expect(getApiKey()).toBe('sk-local-123');
|
||||
});
|
||||
|
||||
it('returns empty string when the blob has no apiKey field', async () => {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiUrl: 'http://x' }));
|
||||
const { getApiKey } = await freshApi();
|
||||
expect(getApiKey()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authHeaders', () => {
|
||||
it('omits Authorization when no key is set (keyless default unchanged)', async () => {
|
||||
const { authHeaders } = await freshApi();
|
||||
expect(authHeaders()).toEqual({});
|
||||
});
|
||||
|
||||
it('adds a Bearer Authorization header when a key is set', async () => {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiKey: 'sk-local-123' }));
|
||||
const { authHeaders } = await freshApi();
|
||||
expect(authHeaders()).toEqual({ Authorization: 'Bearer sk-local-123' });
|
||||
});
|
||||
|
||||
it('merges extra headers alongside Authorization', async () => {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiKey: 'sk-local-123' }));
|
||||
const { authHeaders } = await freshApi();
|
||||
expect(authHeaders({ 'Content-Type': 'application/json' })).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer sk-local-123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool credentials', () => {
|
||||
it('reads credential status from the local server', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ TAVILY_API_KEY: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const { fetchToolCredentialStatus } = await freshApi();
|
||||
|
||||
await expect(fetchToolCredentialStatus('web_search')).resolves.toEqual({
|
||||
TAVILY_API_KEY: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/status',
|
||||
{ headers: {} },
|
||||
);
|
||||
});
|
||||
|
||||
it('saves a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { saveToolCredentials } = await freshApi();
|
||||
|
||||
await saveToolCredentials('web_search', {
|
||||
TAVILY_API_KEY: 'tvly-test',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/tools/web_search/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ TAVILY_API_KEY: 'tvly-test' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { deleteToolCredential } = await freshApi();
|
||||
|
||||
await deleteToolCredential('web_search', 'TAVILY_API_KEY');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/TAVILY_API_KEY',
|
||||
{ method: 'DELETE', headers: {} },
|
||||
);
|
||||
});
|
||||
});
|
||||
+234
-56
@@ -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.
|
||||
@@ -52,6 +75,50 @@ export const getBase = (): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
// Resolve the local server API key (OPENJARVIS_API_KEY). When `jarvis serve`
|
||||
// is started with a key, AuthMiddleware 401s every /v1 and /api request that
|
||||
// lacks a Bearer token — so the frontend must send it (#266). Sourced from the
|
||||
// same settings blob as the API URL, with an optional build-time env override.
|
||||
// Returns '' when unset, so a keyless local server keeps working unchanged.
|
||||
export const getApiKey = (): string => {
|
||||
try {
|
||||
const raw = localStorage.getItem('openjarvis-settings');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.apiKey) return String(parsed.apiKey);
|
||||
}
|
||||
} catch {}
|
||||
if (import.meta.env.VITE_OPENJARVIS_API_KEY) {
|
||||
return import.meta.env.VITE_OPENJARVIS_API_KEY as string;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Build request headers with the Bearer Authorization token when a local key
|
||||
// is configured, merging any caller-supplied headers. Adds no Authorization
|
||||
// header when no key is set, so keyless local dev is byte-for-byte unchanged.
|
||||
export const authHeaders = (
|
||||
extra: Record<string, string> = {},
|
||||
): Record<string, string> => {
|
||||
const key = getApiKey();
|
||||
return key ? { ...extra, Authorization: `Bearer ${key}` } : { ...extra };
|
||||
};
|
||||
|
||||
// Centralized fetch for the local server: prepends getBase() and injects the
|
||||
// Bearer auth header (when a key is set) on every call. Using this everywhere
|
||||
// guarantees no /v1 or /api request is sent without auth — the bug in #266 was
|
||||
// that direct fetch() calls omitted the header and 401'd. `path` is the
|
||||
// server-relative path (e.g. "/v1/savings").
|
||||
export const apiFetch = (
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> => {
|
||||
const headers = authHeaders(
|
||||
(init.headers as Record<string, string> | undefined) ?? {},
|
||||
);
|
||||
return fetch(`${getBase()}${path}`, { ...init, headers });
|
||||
};
|
||||
|
||||
async function tauriInvoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const apiUrl = getBase();
|
||||
@@ -69,6 +136,7 @@ export interface SetupStatus {
|
||||
server_ready: boolean;
|
||||
model_ready: boolean;
|
||||
error: string | null;
|
||||
source?: 'ollama' | 'custom'; // drives source-aware setup labels
|
||||
}
|
||||
|
||||
export async function getSetupStatus(): Promise<SetupStatus | null> {
|
||||
@@ -94,14 +162,14 @@ export async function fetchModels(): Promise<ModelInfo[]> {
|
||||
// Fall through to fetch
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/models`);
|
||||
const res = await apiFetch(`/v1/models`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.data || [];
|
||||
}
|
||||
|
||||
export async function fetchRecommendedModel(): Promise<{ model: string; reason: string }> {
|
||||
const res = await fetch(`${getBase()}/v1/recommended-model`);
|
||||
const res = await apiFetch(`/v1/recommended-model`);
|
||||
if (!res.ok) return { model: '', reason: 'Failed to fetch' };
|
||||
return res.json();
|
||||
}
|
||||
@@ -118,7 +186,7 @@ export async function pullModel(modelName: string): Promise<void> {
|
||||
throw new Error(e?.message || e || 'Download failed');
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/models/pull`, {
|
||||
const res = await apiFetch(`/v1/models/pull`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: modelName }),
|
||||
@@ -139,7 +207,7 @@ export async function deleteModel(modelName: string): Promise<void> {
|
||||
throw new Error(e?.message || e || 'Delete failed');
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/models/${encodeURIComponent(modelName)}`, {
|
||||
const res = await apiFetch(`/v1/models/${encodeURIComponent(modelName)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -172,13 +240,13 @@ export async function preloadModel(modelName: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function fetchSavings(): Promise<SavingsData> {
|
||||
const res = await fetch(`${getBase()}/v1/savings`);
|
||||
const res = await apiFetch(`/v1/savings`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch savings: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchServerInfo(): Promise<ServerInfo> {
|
||||
const res = await fetch(`${getBase()}/v1/info`);
|
||||
const res = await apiFetch(`/v1/info`);
|
||||
if (!res.ok) throw new Error(`Failed to fetch server info: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -220,7 +288,7 @@ export async function fetchEnergy(): Promise<unknown> {
|
||||
return await tauriInvoke('fetch_energy', { apiUrl: getBase() });
|
||||
} catch {}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/telemetry/energy`);
|
||||
const res = await apiFetch(`/v1/telemetry/energy`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -231,7 +299,7 @@ export async function fetchTelemetry(): Promise<unknown> {
|
||||
return await tauriInvoke('fetch_telemetry', { apiUrl: getBase() });
|
||||
} catch {}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/telemetry/stats`);
|
||||
const res = await apiFetch(`/v1/telemetry/stats`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -242,7 +310,7 @@ export async function fetchTraces(limit: number = 50): Promise<unknown> {
|
||||
return await tauriInvoke('fetch_traces', { apiUrl: getBase(), limit });
|
||||
} catch {}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/traces?limit=${limit}`);
|
||||
const res = await apiFetch(`/v1/traces?limit=${limit}`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -272,17 +340,27 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
|
||||
audioData: Array.from(new Uint8Array(buffer)),
|
||||
filename,
|
||||
});
|
||||
} catch {
|
||||
// Fall through to fetch
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(msg || 'Transcription failed');
|
||||
}
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioBlob, filename);
|
||||
const res = await fetch(`${getBase()}/v1/speech/transcribe`, {
|
||||
const res = await apiFetch(`/v1/speech/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Transcription failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = typeof body.detail === 'string' ? body.detail : "";
|
||||
} catch {
|
||||
// Keep the status-only message below when the body is not JSON.
|
||||
}
|
||||
throw new Error(detail || `Transcription failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -294,7 +372,7 @@ export async function fetchSpeechHealth(): Promise<SpeechHealth> {
|
||||
return { available: false };
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${getBase()}/v1/speech/health`);
|
||||
const res = await apiFetch(`/v1/speech/health`);
|
||||
if (!res.ok) return { available: false };
|
||||
return res.json();
|
||||
}
|
||||
@@ -378,14 +456,14 @@ export interface AgentMessage {
|
||||
}
|
||||
|
||||
export async function fetchManagedAgents(): Promise<ManagedAgent[]> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents`);
|
||||
const res = await apiFetch(`/v1/managed-agents`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.agents || [];
|
||||
}
|
||||
|
||||
export async function fetchManagedAgent(agentId: string): Promise<ManagedAgent> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}`);
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -396,7 +474,7 @@ export async function createManagedAgent(body: {
|
||||
template_id?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}): Promise<ManagedAgent> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents`, {
|
||||
const res = await apiFetch(`/v1/managed-agents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -409,7 +487,7 @@ export async function updateManagedAgent(
|
||||
agentId: string,
|
||||
body: Partial<{ name: string; agent_type: string; config: Record<string, unknown> }>,
|
||||
): Promise<ManagedAgent> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}`, {
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -419,29 +497,29 @@ export async function updateManagedAgent(
|
||||
}
|
||||
|
||||
export async function deleteManagedAgent(agentId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}`, { method: 'DELETE' });
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function pauseManagedAgent(agentId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/pause`, { method: 'POST' });
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/pause`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function resumeManagedAgent(agentId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/resume`, { method: 'POST' });
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/resume`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchAgentTasks(agentId: string): Promise<AgentTask[]> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/tasks`);
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/tasks`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.tasks || [];
|
||||
}
|
||||
|
||||
export async function createAgentTask(agentId: string, description: string): Promise<AgentTask> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/tasks`, {
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description }),
|
||||
@@ -451,7 +529,7 @@ export async function createAgentTask(agentId: string, description: string): Pro
|
||||
}
|
||||
|
||||
export async function fetchAgentChannels(agentId: string): Promise<ChannelBinding[]> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/channels`);
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/channels`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.bindings || [];
|
||||
@@ -495,7 +573,7 @@ export async function sendblueVerify(
|
||||
apiKeyId: string,
|
||||
apiSecretKey: string,
|
||||
): Promise<{ valid: boolean; numbers: string[]; raw: unknown }> {
|
||||
const res = await fetch(`${getBase()}/v1/channels/sendblue/verify`, {
|
||||
const res = await apiFetch(`/v1/channels/sendblue/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ api_key_id: apiKeyId, api_secret_key: apiSecretKey }),
|
||||
@@ -512,7 +590,7 @@ export async function sendblueRegisterWebhook(
|
||||
apiSecretKey: string,
|
||||
webhookUrl: string,
|
||||
): Promise<{ registered: boolean; status: number }> {
|
||||
const res = await fetch(`${getBase()}/v1/channels/sendblue/register-webhook`, {
|
||||
const res = await apiFetch(`/v1/channels/sendblue/register-webhook`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -534,7 +612,7 @@ export async function sendblueTest(
|
||||
fromNumber: string,
|
||||
toNumber: string,
|
||||
): Promise<{ sent: boolean; status: number }> {
|
||||
const res = await fetch(`${getBase()}/v1/channels/sendblue/test`, {
|
||||
const res = await apiFetch(`/v1/channels/sendblue/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -552,20 +630,20 @@ export async function sendblueTest(
|
||||
}
|
||||
|
||||
export async function sendblueHealth(): Promise<{ channel_connected: boolean; bridge_wired: boolean; ready: boolean }> {
|
||||
const res = await fetch(`${getBase()}/v1/channels/sendblue/health`);
|
||||
const res = await apiFetch(`/v1/channels/sendblue/health`);
|
||||
if (!res.ok) return { channel_connected: false, bridge_wired: false, ready: false };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchTemplates(): Promise<AgentTemplate[]> {
|
||||
const res = await fetch(`${getBase()}/v1/templates`);
|
||||
const res = await apiFetch(`/v1/templates`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.templates || [];
|
||||
}
|
||||
|
||||
export async function runManagedAgent(agentId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/run`, { method: 'POST' });
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/run`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(body.detail || `Failed: ${res.status}`);
|
||||
@@ -573,7 +651,7 @@ export async function runManagedAgent(agentId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function recoverManagedAgent(agentId: string): Promise<{ recovered: boolean; checkpoint: unknown }> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/recover`, { method: 'POST' });
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/recover`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(body.detail || `Failed: ${res.status}`);
|
||||
@@ -588,7 +666,7 @@ export async function fetchAgentState(agentId: string): Promise<{
|
||||
messages: AgentMessage[];
|
||||
checkpoint: unknown;
|
||||
}> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/state`);
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/state`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -617,7 +695,7 @@ export async function sendAgentMessage(
|
||||
onDone?: (fullContent: string, usage?: Record<string, number>, telemetry?: Record<string, unknown>) => void;
|
||||
},
|
||||
): Promise<AgentMessage> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`, {
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mode, stream: true }),
|
||||
@@ -722,15 +800,34 @@ export async function sendAgentMessage(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the agent a question by triggering an ad-hoc run.
|
||||
*
|
||||
* Posts the question as an `immediate`, non-streamed message — the backend
|
||||
* stores it and spawns a real agent tick (`execute_tick`) that consumes it as
|
||||
* the run's input (tools, trace, and all), rather than a raw one-shot chat.
|
||||
* Returns immediately with the stored user message; progress is observed via
|
||||
* the `/v1/agents/events` WebSocket and the resulting trace.
|
||||
*/
|
||||
export async function askAgent(agentId: string, content: string): Promise<AgentMessage> {
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mode: 'immediate', stream: false }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchAgentMessages(agentId: string): Promise<AgentMessage[]> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`);
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/messages`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
export async function fetchErrorAgents(): Promise<ManagedAgent[]> {
|
||||
const res = await fetch(`${getBase()}/v1/agents/errors`);
|
||||
const res = await apiFetch(`/v1/agents/errors`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.agents || [];
|
||||
@@ -770,7 +867,7 @@ export interface ToolInfo {
|
||||
}
|
||||
|
||||
export async function fetchAvailableTools(): Promise<ToolInfo[]> {
|
||||
const res = await fetch(`${getBase()}/v1/tools`);
|
||||
const res = await apiFetch(`/v1/tools`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.tools || [];
|
||||
@@ -780,7 +877,7 @@ export async function saveToolCredentials(
|
||||
toolName: string,
|
||||
credentials: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/tools/${toolName}/credentials`, {
|
||||
const res = await apiFetch(`/v1/tools/${toolName}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
@@ -788,6 +885,25 @@ export async function saveToolCredentials(
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchToolCredentialStatus(
|
||||
toolName: string,
|
||||
): Promise<Record<string, boolean>> {
|
||||
const res = await apiFetch(`/v1/tools/${toolName}/credentials/status`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function deleteToolCredential(
|
||||
toolName: string,
|
||||
keyName: string,
|
||||
): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`/v1/tools/${encodeURIComponent(toolName)}/credentials/${encodeURIComponent(keyName)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export interface AgentTraceDetail {
|
||||
id: string;
|
||||
agent: string;
|
||||
@@ -804,26 +920,26 @@ export interface AgentTraceDetail {
|
||||
}
|
||||
|
||||
export async function fetchLearningLog(agentId: string): Promise<LearningLogEntry[]> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/learning`);
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/learning`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.learning_log || [];
|
||||
}
|
||||
|
||||
export async function triggerLearning(agentId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/learning/run`, { method: 'POST' });
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/learning/run`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchAgentTraces(agentId: string, limit = 20): Promise<AgentTrace[]> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/traces?limit=${limit}`);
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/traces?limit=${limit}`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.traces || [];
|
||||
}
|
||||
|
||||
export async function fetchAgentTrace(agentId: string, traceId: string): Promise<AgentTraceDetail> {
|
||||
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/traces/${traceId}`);
|
||||
const res = await apiFetch(`/v1/managed-agents/${agentId}/traces/${traceId}`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -884,20 +1000,39 @@ export interface MemoryStats {
|
||||
|
||||
export interface MemoryConfig {
|
||||
backend: string;
|
||||
// Set by the server when the native `openjarvis_rust` extension is missing,
|
||||
// so the UI can show the real cause instead of a healthy-looking config.
|
||||
available?: boolean;
|
||||
detail?: string | null;
|
||||
context_from_memory: boolean;
|
||||
context_top_k: number;
|
||||
context_min_score: number;
|
||||
context_max_tokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the server's `detail` message from a failed JSON response so the UI
|
||||
* surfaces the real cause (e.g. "openjarvis_rust extension is not installed")
|
||||
* instead of a blanket fallback string (#502).
|
||||
*/
|
||||
async function memoryErrorDetail(res: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data && typeof data.detail === 'string' && data.detail) return data.detail;
|
||||
} catch {
|
||||
// Non-JSON body — fall through to the generic message below.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function getMemoryStats(): Promise<MemoryStats> {
|
||||
const res = await fetch(`${getBase()}/v1/memory/stats`);
|
||||
const res = await apiFetch(`/v1/memory/stats`);
|
||||
if (!res.ok) throw new Error('Failed to fetch memory stats');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function searchMemory(query: string, topK: number = 5): Promise<MemorySearchResult[]> {
|
||||
const res = await fetch(`${getBase()}/v1/memory/search`, {
|
||||
const res = await apiFetch(`/v1/memory/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, top_k: topK }),
|
||||
@@ -908,26 +1043,26 @@ export async function searchMemory(query: string, topK: number = 5): Promise<Mem
|
||||
}
|
||||
|
||||
export async function storeMemory(content: string, metadata?: Record<string, unknown>): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/memory/store`, {
|
||||
const res = await apiFetch(`/v1/memory/store`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, metadata }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to store memory');
|
||||
if (!res.ok) throw new Error(await memoryErrorDetail(res, 'Failed to store memory'));
|
||||
}
|
||||
|
||||
export async function indexMemoryPath(path: string): Promise<{ chunks_indexed: number }> {
|
||||
const res = await fetch(`${getBase()}/v1/memory/index`, {
|
||||
export async function indexMemoryPath(path: string): Promise<{ chunks_indexed: number; note?: string }> {
|
||||
const res = await apiFetch(`/v1/memory/index`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to index path');
|
||||
if (!res.ok) throw new Error(await memoryErrorDetail(res, 'Failed to index path'));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getMemoryConfig(): Promise<MemoryConfig> {
|
||||
const res = await fetch(`${getBase()}/v1/memory/config`);
|
||||
const res = await apiFetch(`/v1/memory/config`);
|
||||
if (!res.ok) throw new Error('Failed to fetch memory config');
|
||||
return res.json();
|
||||
}
|
||||
@@ -949,18 +1084,61 @@ export interface PendingApproval {
|
||||
}
|
||||
|
||||
export async function fetchPendingApprovals(): Promise<PendingApproval[]> {
|
||||
const res = await fetch(`${getBase()}/v1/approvals/pending`);
|
||||
const res = await apiFetch(`/v1/approvals/pending`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.actions || [];
|
||||
}
|
||||
|
||||
export async function approveAction(actionId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/approve`, { method: 'POST' });
|
||||
const res = await apiFetch(`/v1/approvals/${actionId}/approve`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function denyAction(actionId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/deny`, { method: 'POST' });
|
||||
const res = await apiFetch(`/v1/approvals/${actionId}/deny`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inference source (desktop only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type InferenceSource = {
|
||||
kind: 'ollama' | 'custom';
|
||||
model?: string;
|
||||
host?: string;
|
||||
engine?: string;
|
||||
};
|
||||
|
||||
export async function getInferenceSource(): Promise<InferenceSource> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return await invoke<InferenceSource>('get_inference_source');
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message ?? e ?? 'Failed to read inference source');
|
||||
}
|
||||
}
|
||||
return { kind: 'ollama' };
|
||||
}
|
||||
|
||||
export async function setInferenceSource(
|
||||
src: InferenceSource & { apiKey?: string },
|
||||
): Promise<void> {
|
||||
if (!isTauri()) throw new Error('Inference source is configurable in the desktop app only.');
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke<void>('set_inference_source', {
|
||||
kind: src.kind,
|
||||
model: src.model ?? null,
|
||||
host: src.host ?? null,
|
||||
engine: src.engine ?? null,
|
||||
apiKey: src.apiKey ?? null,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Surface the backend's actionable error strings (e.g. "A server URL is
|
||||
// required…", "Could not store the API key…") as proper Error instances.
|
||||
throw new Error(e?.message ?? e ?? 'Failed to save inference source');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getBase } from './api';
|
||||
import type { ConnectorInfo, SyncStatus, ConnectRequest } from '../types/connectors';
|
||||
import type { ConnectorInfo, SyncStatus, ConnectRequest, ConnectResponse } from '../types/connectors';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connectors API
|
||||
@@ -18,16 +18,47 @@ export async function getConnector(id: string): Promise<ConnectorInfo> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function connectSource(id: string, req: ConnectRequest): Promise<ConnectorInfo> {
|
||||
export async function connectSource(id: string, req: ConnectRequest): Promise<ConnectResponse> {
|
||||
const res = await fetch(`${getBase()}/v1/connectors/${encodeURIComponent(id)}/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to connect ${id}: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// Surface the backend's actionable detail (e.g. malformed Client ID /
|
||||
// Secret) instead of a bare status code so the UI can render it.
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `Failed to connect ${id}: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Open the server-side OAuth consent flow in a popup and resolve once the
|
||||
* connector reports connected (or reject on timeout). Reused for any OAuth
|
||||
* connector whose /connect returned `oauth_required` (issue #512). */
|
||||
export function startServerOAuth(id: string, oauthStartPath?: string): Promise<void> {
|
||||
const path = oauthStartPath || `/v1/connectors/${encodeURIComponent(id)}/oauth/start`;
|
||||
window.open(`${getBase()}${path}`, '_blank', 'width=600,height=700');
|
||||
return new Promise((resolve, reject) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const info = await getConnector(id);
|
||||
if (info.connected) {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
} catch {
|
||||
// ignore transient polling errors
|
||||
}
|
||||
}, 2000);
|
||||
const timer = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
reject(new Error('Authorization timed out — please try again.'));
|
||||
}, 180000);
|
||||
});
|
||||
}
|
||||
|
||||
export async function disconnectSource(id: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/connectors/${encodeURIComponent(id)}/disconnect`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ResearchEvent, SSEEvent } from '../types';
|
||||
import { getBase } from './api';
|
||||
import { getBase, authHeaders } from './api';
|
||||
|
||||
export interface ChatRequest {
|
||||
model: string;
|
||||
@@ -16,7 +16,7 @@ export async function* streamChat(
|
||||
const base = getBase();
|
||||
const response = await fetch(`${base}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
@@ -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
|
||||
@@ -67,8 +68,8 @@ export async function* streamResearch(
|
||||
const base = getBase().replace(/\/v1\/?$/, '');
|
||||
const response = await fetch(`${base}/api/research`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ query, ...(model ? { model } : {}) }),
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
interface Settings {
|
||||
theme: ThemeMode;
|
||||
apiUrl: string;
|
||||
// Local server API key (OPENJARVIS_API_KEY). Sent as a Bearer token on
|
||||
// /v1 + /api requests so a key-protected `jarvis serve` doesn't 401 the
|
||||
// frontend (#266). Empty = no auth header (keyless local default).
|
||||
apiKey: string;
|
||||
fontSize: 'small' | 'default' | 'large';
|
||||
defaultModel: string;
|
||||
defaultAgent: string;
|
||||
@@ -82,6 +86,7 @@ function loadSettings(): Settings {
|
||||
const defaults: Settings = {
|
||||
theme: 'system',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
fontSize: 'default',
|
||||
defaultModel: '',
|
||||
defaultAgent: '',
|
||||
@@ -438,7 +443,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
|
||||
// ── Models & server ────────────────────────────────────────────
|
||||
|
||||
setModels: (models: ModelInfo[]) => set({ models }),
|
||||
setModels: (models: ModelInfo[]) =>
|
||||
set((state) =>
|
||||
!state.selectedModel && models.length > 0
|
||||
? { models, selectedModel: models[0].id }
|
||||
: { models },
|
||||
),
|
||||
setModelsLoading: (loading: boolean) => set({ modelsLoading: loading }),
|
||||
setSelectedModel: (model: string) => set({ selectedModel: model }),
|
||||
setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
|
||||
|
||||
@@ -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;
|
||||
+391
-468
@@ -9,7 +9,6 @@ import {
|
||||
fetchAgentChannels,
|
||||
bindAgentChannel,
|
||||
unbindAgentChannel,
|
||||
fetchAgentMessages,
|
||||
fetchTemplates,
|
||||
createManagedAgent,
|
||||
pauseManagedAgent,
|
||||
@@ -17,13 +16,13 @@ import {
|
||||
deleteManagedAgent,
|
||||
runManagedAgent,
|
||||
recoverManagedAgent,
|
||||
sendAgentMessage,
|
||||
askAgent,
|
||||
fetchLearningLog,
|
||||
triggerLearning,
|
||||
fetchAgentTraces,
|
||||
fetchAgentTrace,
|
||||
fetchManagedAgent,
|
||||
fetchAvailableTools,
|
||||
saveToolCredentials,
|
||||
fetchModels,
|
||||
updateManagedAgent,
|
||||
fetchRecommendedModel,
|
||||
@@ -32,8 +31,9 @@ import {
|
||||
sendblueTest,
|
||||
sendblueHealth,
|
||||
} from '../lib/api';
|
||||
import type { AgentTask, ChannelBinding, AgentTemplate, AgentMessage, ManagedAgent, LearningLogEntry, AgentTrace, ToolInfo } from '../lib/api';
|
||||
import type { AgentTask, ChannelBinding, AgentTemplate, ManagedAgent, LearningLogEntry, AgentTrace, AgentTraceDetail, ToolInfo } from '../lib/api';
|
||||
import { useAgentEvents } from '../lib/useAgentEvents';
|
||||
import type { AgentEvent } from '../lib/useAgentEvents';
|
||||
import {
|
||||
Plus,
|
||||
Bot,
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
Pencil,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { SOURCE_CATALOG } from '../types/connectors';
|
||||
import type { ConnectRequest } from '../types/connectors';
|
||||
@@ -1403,20 +1404,20 @@ function AgentConfigGrid({ agent, onAgentUpdated }: { agent: ManagedAgent; onAge
|
||||
let cancelled = false;
|
||||
async function checkModel() {
|
||||
try {
|
||||
const res = await fetch('http://localhost:11434/api/tags');
|
||||
if (!res.ok) { setModelAvailable('unknown'); return; }
|
||||
const data = await res.json();
|
||||
const loadedNames: string[] = (data.models || []).map((m: { name: string }) => m.name);
|
||||
if (!cancelled) {
|
||||
setOllamaModels(loadedNames);
|
||||
if (currentModel === '(default)') {
|
||||
setModelAvailable(loadedNames.length > 0 ? 'available' : 'unknown');
|
||||
} else {
|
||||
const isLoaded = loadedNames.some(
|
||||
(n) => n === currentModel || n.startsWith(currentModel + ':') || currentModel.startsWith(n.split(':')[0])
|
||||
);
|
||||
setModelAvailable(isLoaded ? 'available' : 'unavailable');
|
||||
}
|
||||
// Ask the backend which models are installed rather than hitting
|
||||
// Ollama directly from the browser: the backend always knows where
|
||||
// Ollama lives (incl. remote) and there's no cross-origin/CORS issue,
|
||||
// which is what made the check spuriously report "Not available".
|
||||
const installed = (await fetchModels()).map((m) => m.id);
|
||||
if (cancelled) return;
|
||||
setOllamaModels(installed);
|
||||
if (currentModel === '(default)') {
|
||||
setModelAvailable(installed.length > 0 ? 'available' : 'unknown');
|
||||
} else {
|
||||
const isInstalled = installed.some(
|
||||
(n) => n === currentModel || n.startsWith(currentModel + ':') || currentModel.startsWith(n.split(':')[0])
|
||||
);
|
||||
setModelAvailable(isInstalled ? 'available' : 'unavailable');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setModelAvailable('unknown');
|
||||
@@ -1428,21 +1429,15 @@ function AgentConfigGrid({ agent, onAgentUpdated }: { agent: ManagedAgent; onAge
|
||||
|
||||
async function startEditingModel() {
|
||||
try {
|
||||
const fetched = await fetchModels();
|
||||
setModels(fetched.map((m) => m.id));
|
||||
} catch { /* ignore */ }
|
||||
// Also refresh Ollama models for availability indication
|
||||
try {
|
||||
const res = await fetch('http://localhost:11434/api/tags');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setOllamaModels((data.models || []).map((m: { name: string }) => m.name));
|
||||
}
|
||||
const fetched = (await fetchModels()).map((m) => m.id);
|
||||
setModels(fetched);
|
||||
// Same backend list drives both the dropdown and the availability dots.
|
||||
setOllamaModels(fetched);
|
||||
} catch { /* ignore */ }
|
||||
setEditingModel(true);
|
||||
}
|
||||
|
||||
function isModelLoaded(modelId: string): boolean {
|
||||
function isModelInstalled(modelId: string): boolean {
|
||||
return ollamaModels.some(
|
||||
(n) => n === modelId || n.startsWith(modelId + ':') || modelId.startsWith(n.split(':')[0])
|
||||
);
|
||||
@@ -1480,10 +1475,10 @@ function AgentConfigGrid({ agent, onAgentUpdated }: { agent: ManagedAgent; onAge
|
||||
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }}
|
||||
>
|
||||
{models.map((m) => {
|
||||
const loaded = isModelLoaded(m);
|
||||
const installed = isModelInstalled(m);
|
||||
return (
|
||||
<option key={m} value={m} style={!loaded ? { color: 'var(--color-text-tertiary)' } : undefined}>
|
||||
{m}{!loaded ? ' (not loaded)' : ''}
|
||||
<option key={m} value={m} style={!installed ? { color: 'var(--color-text-tertiary)' } : undefined}>
|
||||
{m}{!installed ? ' (not installed)' : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
@@ -1546,498 +1541,421 @@ function AgentConfigGrid({ agent, onAgentUpdated }: { agent: ManagedAgent; onAge
|
||||
// Detail view — Interact tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** AgentMessage extended with optional response metadata for the footer. */
|
||||
type InteractMessage = AgentMessage & {
|
||||
_elapsed?: string;
|
||||
_toolCalls?: number;
|
||||
_usage?: Record<string, number>;
|
||||
_telemetry?: Record<string, unknown>;
|
||||
_toolCallDetails?: ToolCallInfo[];
|
||||
};
|
||||
/** One entry in the live activity feed assembled from agent events. */
|
||||
type LiveItem =
|
||||
| { kind: 'note'; id: string; label: string }
|
||||
| { kind: 'tool'; id: string; tool: ToolCallInfo };
|
||||
|
||||
function AgentResponseFooter({
|
||||
msg, copiedId, onCopy,
|
||||
}: {
|
||||
msg: InteractMessage;
|
||||
copiedId: string | null;
|
||||
onCopy: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const u = msg._usage;
|
||||
const t = msg._telemetry as Record<string, unknown> | undefined;
|
||||
const elapsed = msg._elapsed;
|
||||
const toolCallDetails = msg._toolCallDetails || [];
|
||||
const toolCalls = msg._toolCalls ?? toolCallDetails.length;
|
||||
|
||||
// Build summary line like Chat: "ollama - qwen3.5:9b - 18.3s - 50 tokens"
|
||||
const parts: string[] = [];
|
||||
if (t?.engine) parts.push(String(t.engine));
|
||||
if (t?.model_id) parts.push(String(t.model_id));
|
||||
if (elapsed) parts.push(`${elapsed}s`);
|
||||
if (u?.prompt_tokens) parts.push(`${u.prompt_tokens} input tokens`);
|
||||
if (u?.completion_tokens) parts.push(`${u.completion_tokens} output tokens`);
|
||||
if (toolCalls > 0) parts.push(`${toolCalls} tool ${toolCalls === 1 ? 'call' : 'calls'}`);
|
||||
|
||||
const summary = parts.length > 0 ? parts.join(' - ') : elapsed ? `${elapsed}s` : '';
|
||||
|
||||
// Build expanded rows
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
if (t?.engine) rows.push({ label: 'Engine', value: `${t.engine}${t.model_id ? ` (${t.model_id})` : ''}` });
|
||||
if (u) {
|
||||
const tokenParts = [];
|
||||
if (u.completion_tokens) tokenParts.push(`${u.completion_tokens} generated`);
|
||||
if (u.prompt_tokens) tokenParts.push(`${u.prompt_tokens} prompt`);
|
||||
if (tokenParts.length) rows.push({ label: 'Tokens', value: tokenParts.join(' · ') });
|
||||
}
|
||||
if (toolCallDetails.length > 0) {
|
||||
toolCallDetails.forEach((tc, i) => {
|
||||
const prefix = toolCallDetails.length > 1 ? `Tool ${i + 1}` : 'Tool';
|
||||
const args = tc.arguments ? ` ${tc.arguments}` : '';
|
||||
rows.push({ label: prefix, value: `${tc.tool}(${args.trim()})` });
|
||||
});
|
||||
} else if (toolCalls > 0) {
|
||||
rows.push({ label: 'Tool calls', value: `${toolCalls}` });
|
||||
}
|
||||
if (t?.tokens_per_sec) rows.push({ label: 'Speed', value: `${Math.round(Number(t.tokens_per_sec))} tok/s` });
|
||||
if (t?.total_ms) rows.push({ label: 'Latency', value: `${(Number(t.total_ms) / 1000).toFixed(1)}s total` });
|
||||
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--color-border-subtle)', marginTop: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', paddingTop: 4 }}>
|
||||
<button
|
||||
onClick={() => rows.length > 0 && setExpanded(!expanded)}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: 'none', cursor: rows.length > 0 ? 'pointer' : 'default',
|
||||
padding: 0, textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 4, height: 4, borderRadius: '50%', background: 'var(--color-accent)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-tertiary)', fontFamily: 'system-ui' }}>
|
||||
{summary}
|
||||
</span>
|
||||
{rows.length > 0 && (
|
||||
<span style={{ fontSize: 10, color: 'var(--color-text-tertiary)' }}>
|
||||
{expanded ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCopy(msg.id)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--color-text-tertiary)', padding: 2,
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}
|
||||
title="Copy response"
|
||||
>
|
||||
{copiedId === msg.id ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && rows.length > 0 && (
|
||||
<div style={{
|
||||
borderRadius: 6, marginTop: 4, padding: '6px 10px',
|
||||
background: 'rgba(0, 0, 0, 0.15)',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: 'auto 1fr',
|
||||
columnGap: 12, rowGap: 2,
|
||||
}}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} style={{ display: 'contents' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-tertiary)', fontFamily: 'monospace' }}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', fontFamily: 'monospace' }}>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
/** Convert a persisted trace step into a ToolCallInfo for ToolCallCard. */
|
||||
function stepToToolCall(
|
||||
step: AgentTraceDetail['steps'][number],
|
||||
idx: number,
|
||||
): ToolCallInfo {
|
||||
const input = (step.input ?? {}) as { tool?: string; args?: unknown };
|
||||
const out = step.output as unknown;
|
||||
const result =
|
||||
typeof out === 'string'
|
||||
? out
|
||||
: out && typeof out === 'object' && 'result' in out
|
||||
? String((out as { result: unknown }).result ?? '')
|
||||
: out != null
|
||||
? JSON.stringify(out)
|
||||
: '';
|
||||
const args = input.args;
|
||||
return {
|
||||
id: `step-${idx}`,
|
||||
tool: input.tool || step.step_type || 'step',
|
||||
arguments:
|
||||
typeof args === 'string' ? args : args != null ? JSON.stringify(args) : '',
|
||||
status: 'success',
|
||||
result,
|
||||
latency: step.duration ? step.duration * 1000 : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: string }) {
|
||||
const [messages, setMessages] = useState<InteractMessage[]>([]);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interact tab — trace viewer (top) + follow-up chat (bottom).
|
||||
//
|
||||
// The chat input doesn't open a side-channel chat; it triggers a real ad-hoc
|
||||
// agent run (execute_tick) with the user's question as input. The trace area
|
||||
// shows that run live (tick + tool calls over the events WebSocket) and, when
|
||||
// idle, the last run's trace steps plus the agent's resulting findings — so
|
||||
// users can interrogate the agent about its work ("tell me more about X").
|
||||
// ---------------------------------------------------------------------------
|
||||
function InteractTab({ agentId, agentStatus, onRunStateChange }: { agentId: string; agentStatus: string; onRunStateChange?: () => void }) {
|
||||
const [agent, setAgent] = useState<ManagedAgent | null>(null);
|
||||
const [activity, setActivity] = useState('');
|
||||
const [running, setRunning] = useState(agentStatus === 'running');
|
||||
const [liveItems, setLiveItems] = useState<LiveItem[]>([]);
|
||||
const [lastTrace, setLastTrace] = useState<AgentTraceDetail | null>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [waitingForResponse, setWaitingForResponse] = useState(false);
|
||||
const [progressLabel, setProgressLabel] = useState('');
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
|
||||
const [currentActivity, setCurrentActivity] = useState('');
|
||||
const [liveStatus, setLiveStatus] = useState(agentStatus);
|
||||
const [streamElapsedMs, setStreamElapsedMs] = useState(0);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [question, setQuestion] = useState(''); // question driving the current/last run
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
|
||||
const startRef = useRef(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Tail-mode flag: when the user is pinned to the bottom of the transcript
|
||||
// (within NEAR_BOTTOM_THRESHOLD px) we keep auto-scrolling as new content
|
||||
// streams in. If they manually scroll up, we stop following so the view
|
||||
// doesn't get yanked back down.
|
||||
const isNearBottomRef = useRef(true);
|
||||
const runningRef = useRef(running);
|
||||
runningRef.current = running;
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep a ref of local metadata so polling doesn't overwrite it
|
||||
const localMetaRef = useRef<Map<string, {
|
||||
_elapsed?: string;
|
||||
_toolCalls?: number;
|
||||
_usage?: Record<string, number>;
|
||||
_telemetry?: Record<string, unknown>;
|
||||
_toolCallDetails?: ToolCallInfo[];
|
||||
}>>(new Map());
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
// Load idle snapshot: agent record (status + findings) and the latest trace.
|
||||
const loadIdle = useCallback(async () => {
|
||||
try {
|
||||
const [msgs, agent] = await Promise.all([
|
||||
fetchAgentMessages(agentId),
|
||||
fetchManagedAgent(agentId),
|
||||
]);
|
||||
// Merge server messages with locally-stored metadata, and hydrate
|
||||
// server-persisted tool_calls into _toolCallDetails so they survive
|
||||
// page reloads.
|
||||
const merged: InteractMessage[] = msgs.map((m) => {
|
||||
const meta = localMetaRef.current.get(m.content?.slice(0, 100) || '');
|
||||
const base = meta ? { ...m, ...meta } : { ...m };
|
||||
if (!base._toolCallDetails && m.tool_calls && m.tool_calls.length > 0) {
|
||||
base._toolCallDetails = m.tool_calls.map((tc, i) => ({
|
||||
id: `${m.id}-tc-${i}`,
|
||||
tool: tc.tool,
|
||||
arguments: tc.arguments || '',
|
||||
status: tc.success === false ? 'error' : 'success',
|
||||
result: tc.result,
|
||||
latency: tc.latency,
|
||||
}));
|
||||
if (base._toolCalls == null) base._toolCalls = m.tool_calls.length;
|
||||
const a = await fetchManagedAgent(agentId);
|
||||
setAgent(a);
|
||||
setActivity(a.current_activity || '');
|
||||
try {
|
||||
const traces = await fetchAgentTraces(agentId, 1);
|
||||
if (traces.length > 0) {
|
||||
const detail = await fetchAgentTrace(agentId, traces[0].id);
|
||||
setLastTrace(detail);
|
||||
}
|
||||
return base;
|
||||
});
|
||||
setMessages(merged);
|
||||
setLiveStatus(agent.status);
|
||||
setCurrentActivity(agent.current_activity || '');
|
||||
} catch {
|
||||
/* trace store may be empty */
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
/* ignore */
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// Fallback slow poll — WS is primary, this catches missed events / dropped sockets
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
loadIdle();
|
||||
}, [loadIdle]);
|
||||
|
||||
// Event-driven refresh — fires when the server reports agent activity
|
||||
useAgentEvents(agentId, loadData, [
|
||||
'agent_tick_start',
|
||||
'agent_tick_end',
|
||||
'agent_tick_error',
|
||||
'agent_message_received',
|
||||
'tool_call_end',
|
||||
'inference_end',
|
||||
]);
|
||||
|
||||
useEffect(() => { setLiveStatus(agentStatus); }, [agentStatus]);
|
||||
|
||||
// Clean up elapsed-time timer on unmount
|
||||
// Tick the elapsed timer while running.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track whether the user is near the bottom. Called on every scroll
|
||||
// event; only flips the ref, never triggers a re-render.
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
isNearBottomRef.current = distance < 80; // px threshold
|
||||
}, []);
|
||||
|
||||
// Initial landing: jump to the bottom once the first batch of messages
|
||||
// arrives. Subsequent poll updates honor the tail-mode ref.
|
||||
const hasScrolled = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasScrolled.current && messages.length > 0) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
|
||||
hasScrolled.current = true;
|
||||
isNearBottomRef.current = true;
|
||||
if (!running) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
}, [messages]);
|
||||
if (!startRef.current) startRef.current = Date.now();
|
||||
timerRef.current = setInterval(
|
||||
() => setElapsedMs(Date.now() - startRef.current),
|
||||
100,
|
||||
);
|
||||
return clearTimer;
|
||||
}, [running, clearTimer]);
|
||||
|
||||
// Stream auto-follow: only scroll while the user is pinned to the bottom.
|
||||
// If they've scrolled up to re-read something, stay put.
|
||||
useEffect(() => {
|
||||
if (streamingContent && isNearBottomRef.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [streamingContent]);
|
||||
const finishRun = useCallback(() => {
|
||||
setRunning(false);
|
||||
startRef.current = 0;
|
||||
clearTimer();
|
||||
// Give the backend a beat to persist summary_memory + trace, then refresh
|
||||
// both this tab and the parent (so the detail/list status badge flips back
|
||||
// from "running" to "idle" without waiting for the slow background poll).
|
||||
setTimeout(() => {
|
||||
loadIdle();
|
||||
onRunStateChange?.();
|
||||
}, 500);
|
||||
}, [clearTimer, loadIdle, onRunStateChange]);
|
||||
|
||||
async function handleSend(mode: 'immediate' | 'queued') {
|
||||
if (!input.trim()) return;
|
||||
const text = input.trim();
|
||||
setInput('');
|
||||
setSending(true);
|
||||
|
||||
// Show user message immediately as a local bubble
|
||||
const localMsg: AgentMessage = {
|
||||
id: `local-${Date.now()}`,
|
||||
agent_id: agentId,
|
||||
direction: 'user_to_agent',
|
||||
content: text,
|
||||
mode,
|
||||
status: 'delivered',
|
||||
created_at: Date.now() / 1000,
|
||||
};
|
||||
setMessages((prev) => [localMsg, ...prev]);
|
||||
setSending(false);
|
||||
setWaitingForResponse(true);
|
||||
setProgressLabel('Initializing agent...');
|
||||
setStreamingContent('');
|
||||
setStreamingToolCalls([]);
|
||||
// Sending is explicit user intent — always scroll and re-engage
|
||||
// tail-mode so the subsequent stream follows along.
|
||||
isNearBottomRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
// Start elapsed-time timer
|
||||
const startTime = Date.now();
|
||||
setStreamElapsedMs(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setStreamElapsedMs(Date.now() - startTime);
|
||||
}, 100);
|
||||
|
||||
let toolCount = 0;
|
||||
let responseUsage: Record<string, number> | undefined;
|
||||
let responseTelemetry: Record<string, unknown> | undefined;
|
||||
const collectedToolCalls: ToolCallInfo[] = [];
|
||||
try {
|
||||
const response = await sendAgentMessage(agentId, text, mode, {
|
||||
onProgress: (label) => {
|
||||
setProgressLabel(label);
|
||||
toolCount++;
|
||||
},
|
||||
onContentDelta: (_delta, full) => setStreamingContent(full),
|
||||
onToolCallStart: ({ tool, arguments: args }) => {
|
||||
toolCount++;
|
||||
// Live trace: assemble events from the agent events WebSocket.
|
||||
const onEvent = useCallback(
|
||||
(ev: AgentEvent) => {
|
||||
const data = ev.data || {};
|
||||
switch (ev.type) {
|
||||
case 'agent_tick_start': {
|
||||
startRef.current = Date.now();
|
||||
setElapsedMs(0);
|
||||
setRunning(true);
|
||||
setErrorMsg('');
|
||||
setLiveItems([{ kind: 'note', id: `start-${ev.timestamp}`, label: 'Run started' }]);
|
||||
break;
|
||||
}
|
||||
case 'tool_call_start': {
|
||||
const id = `tc-${ev.timestamp}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const args = data.arguments;
|
||||
const tc: ToolCallInfo = {
|
||||
id: `tc-${Date.now()}-${collectedToolCalls.length}`,
|
||||
tool,
|
||||
arguments: args,
|
||||
id,
|
||||
tool: String(data.tool || 'tool'),
|
||||
arguments:
|
||||
typeof args === 'string' ? args : args != null ? JSON.stringify(args) : '',
|
||||
status: 'running',
|
||||
};
|
||||
collectedToolCalls.push(tc);
|
||||
setStreamingToolCalls([...collectedToolCalls]);
|
||||
setProgressLabel(`Calling ${tool}...`);
|
||||
},
|
||||
onToolCallEnd: ({ tool, success, latency, result }) => {
|
||||
const match = [...collectedToolCalls]
|
||||
.reverse()
|
||||
.find((t) => t.tool === tool && t.status === 'running');
|
||||
if (match) {
|
||||
match.status = success ? 'success' : 'error';
|
||||
match.latency = latency;
|
||||
match.result = result;
|
||||
setLiveItems((prev) => [...prev, { kind: 'tool', id, tool: tc }]);
|
||||
break;
|
||||
}
|
||||
case 'tool_call_end': {
|
||||
setLiveItems((prev) => {
|
||||
const next = [...prev];
|
||||
for (let i = next.length - 1; i >= 0; i--) {
|
||||
const it = next[i];
|
||||
if (
|
||||
it.kind === 'tool' &&
|
||||
it.tool.tool === String(data.tool) &&
|
||||
it.tool.status === 'running'
|
||||
) {
|
||||
next[i] = {
|
||||
...it,
|
||||
tool: {
|
||||
...it.tool,
|
||||
status: data.success === false ? 'error' : 'success',
|
||||
result:
|
||||
typeof data.result === 'string' ? data.result : it.tool.result,
|
||||
latency:
|
||||
typeof data.latency === 'number'
|
||||
? data.latency * 1000
|
||||
: it.tool.latency,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'agent_tick_end':
|
||||
case 'agent_tick_error': {
|
||||
if (ev.type === 'agent_tick_error') {
|
||||
setErrorMsg(String(data.error || 'The run failed.'));
|
||||
}
|
||||
setStreamingToolCalls([...collectedToolCalls]);
|
||||
setProgressLabel('');
|
||||
},
|
||||
onDone: (_content, usage, telemetry) => {
|
||||
setStreamingContent('');
|
||||
responseUsage = usage;
|
||||
responseTelemetry = telemetry;
|
||||
},
|
||||
});
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
// Add the agent's response as a local bubble immediately
|
||||
if (response && response.content) {
|
||||
const meta = {
|
||||
_elapsed: elapsed,
|
||||
_toolCalls: toolCount,
|
||||
_usage: responseUsage,
|
||||
_telemetry: responseTelemetry,
|
||||
_toolCallDetails: collectedToolCalls.length > 0 ? [...collectedToolCalls] : undefined,
|
||||
};
|
||||
// Store metadata keyed by content prefix so polling preserves it
|
||||
localMetaRef.current.set(response.content.slice(0, 100), meta);
|
||||
setMessages((prev) => [
|
||||
{
|
||||
...response,
|
||||
id: response.id || `response-${Date.now()}`,
|
||||
direction: 'agent_to_user' as const,
|
||||
...meta,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
finishRun();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Also refresh from server to sync any persisted messages
|
||||
await loadData();
|
||||
},
|
||||
[finishRun],
|
||||
);
|
||||
|
||||
useAgentEvents(agentId, onEvent, [
|
||||
'agent_tick_start',
|
||||
'tool_call_start',
|
||||
'tool_call_end',
|
||||
'agent_tick_end',
|
||||
'agent_tick_error',
|
||||
]);
|
||||
|
||||
// Fallback poll — WS is primary, but this catches missed tick_end events and
|
||||
// runs started elsewhere (e.g. the scheduler or the Overview "Run" button).
|
||||
useEffect(() => {
|
||||
const iv = setInterval(async () => {
|
||||
try {
|
||||
const a = await fetchManagedAgent(agentId);
|
||||
setActivity(a.current_activity || '');
|
||||
if (a.status === 'running' && !runningRef.current) {
|
||||
setRunning(true);
|
||||
} else if (a.status !== 'running' && runningRef.current) {
|
||||
finishRun();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(iv);
|
||||
}, [agentId, finishRun]);
|
||||
|
||||
// Keep pinned to the newest live item.
|
||||
useEffect(() => {
|
||||
if (running) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [liveItems, running]);
|
||||
|
||||
async function handleAsk() {
|
||||
const q = input.trim();
|
||||
if (!q || running || sending) return;
|
||||
setInput('');
|
||||
setQuestion(q);
|
||||
setErrorMsg('');
|
||||
setSending(true);
|
||||
setLiveItems([{ kind: 'note', id: 'queued', label: 'Starting run…' }]);
|
||||
startRef.current = Date.now();
|
||||
setElapsedMs(0);
|
||||
try {
|
||||
// immediate, non-streamed → triggers a real agent run that consumes the
|
||||
// question as input. tick_start over the WS confirms; poll is the backstop.
|
||||
await askAgent(agentId, q);
|
||||
setRunning(true);
|
||||
onRunStateChange?.(); // flip the parent status badge to "running" now
|
||||
} catch {
|
||||
// ignore
|
||||
setErrorMsg('Could not start the agent run.');
|
||||
setLiveItems([]);
|
||||
} finally {
|
||||
setWaitingForResponse(false);
|
||||
setStreamingContent('');
|
||||
setStreamingToolCalls([]);
|
||||
setProgressLabel('');
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setStreamElapsedMs(0);
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse so newest messages appear at the bottom (closest to input).
|
||||
// Filter out agent responses with empty content.
|
||||
const displayMessages = [...messages]
|
||||
.filter((m) => m.direction === 'user_to_agent' || m.content.trim())
|
||||
.reverse();
|
||||
const isBusy = running || sending;
|
||||
const findings = agent?.summary_memory?.trim() || '';
|
||||
const traceSteps = lastTrace?.steps ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" style={{ minHeight: 320 }}>
|
||||
<div className="flex flex-col" style={{ minHeight: 360 }}>
|
||||
{/* ── Trace area header ──────────────────────────────── */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm font-medium"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
<Activity size={14} style={{ color: 'var(--color-accent)' }} />
|
||||
Activity trace
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
/>
|
||||
Running{elapsedMs > 0 ? ` · ${(elapsedMs / 1000).toFixed(1)}s` : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{agent?.last_run_at
|
||||
? `Last run ${new Date(agent.last_run_at * 1000).toLocaleString()}`
|
||||
: 'Idle'}
|
||||
{lastTrace && ` · ${lastTrace.outcome}`}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Trace area body ────────────────────────────────── */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto space-y-3 pb-4"
|
||||
style={{ maxHeight: 'calc(100vh - 400px)' }}
|
||||
className="flex-1 overflow-y-auto rounded-lg p-3 space-y-3"
|
||||
style={{
|
||||
background: 'var(--color-bg-secondary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
maxHeight: 'calc(100vh - 360px)',
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
{displayMessages.length === 0 && !waitingForResponse && (
|
||||
<div className="text-sm text-center py-8" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
No messages yet. Send a message to interact with this agent.
|
||||
{question && (
|
||||
<div className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>Question:</span> {question}
|
||||
</div>
|
||||
)}
|
||||
{displayMessages.map((msg) => (
|
||||
<div key={msg.id} className="space-y-2">
|
||||
{/* Tool calls rendered as their own full-width entries (like Claude Code) */}
|
||||
{msg.direction === 'agent_to_user' && msg._toolCallDetails && msg._toolCallDetails.length > 0 && (
|
||||
<div className="flex flex-col items-start gap-2 max-w-[75%]">
|
||||
{msg._toolCallDetails.map((tc) => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||
|
||||
{errorMsg && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(255,80,80,0.08)',
|
||||
border: '1px solid var(--color-error)',
|
||||
color: 'var(--color-error)',
|
||||
}}
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBusy ? (
|
||||
/* LIVE view — current tick */
|
||||
<>
|
||||
{liveItems.map((it) =>
|
||||
it.kind === 'tool' ? (
|
||||
<ToolCallCard key={it.id} toolCall={it.tool} />
|
||||
) : (
|
||||
<div
|
||||
key={it.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
/>
|
||||
{it.label}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Loader2 size={13} className="animate-spin" style={{ color: 'var(--color-accent)' }} />
|
||||
{activity || 'Agent is working…'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* IDLE view — last run's trace + findings */
|
||||
<>
|
||||
{traceSteps.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{traceSteps.map((s, i) => (
|
||||
<ToolCallCard key={i} toolCall={stepToToolCall(s, i)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Message bubble */}
|
||||
<div className={`flex ${msg.direction === 'user_to_agent' ? 'justify-end' : 'justify-start'}`}>
|
||||
{findings ? (
|
||||
<div
|
||||
className="max-w-[75%] px-3 py-2 rounded-lg text-sm"
|
||||
className="px-3 py-2 rounded-lg text-sm"
|
||||
style={{
|
||||
background: msg.direction === 'user_to_agent' ? 'var(--color-accent)' : 'var(--color-bg-secondary)',
|
||||
color: msg.direction === 'user_to_agent' ? 'var(--color-on-accent)' : 'var(--color-text)',
|
||||
border: msg.direction === 'agent_to_user' ? '1px solid var(--color-border)' : 'none',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{msg.direction === 'agent_to_user' ? (
|
||||
<div className="prose prose-sm prose-invert max-w-none"><ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown></div>
|
||||
) : (
|
||||
<p>{msg.content}</p>
|
||||
)}
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{msg.status === 'pending' ? 'sending...' : new Date(msg.created_at * 1000).toLocaleTimeString()}
|
||||
</p>
|
||||
{msg.direction === 'agent_to_user' && (
|
||||
<AgentResponseFooter msg={msg} copiedId={copiedId} onCopy={(id) => {
|
||||
navigator.clipboard.writeText(msg.content);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Progress indicator — shown when waiting but no streamed content or tool calls yet */}
|
||||
{(waitingForResponse || sending) && !streamingContent && streamingToolCalls.length === 0 && (
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
className="px-3 py-2 rounded-lg text-sm"
|
||||
style={{
|
||||
background: 'var(--color-bg-secondary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block w-2 h-2 rounded-full animate-pulse" style={{ background: 'var(--color-accent)' }} />
|
||||
{sending
|
||||
? 'Sending message...'
|
||||
: progressLabel || 'Agent is thinking...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Live tool call cards rendered as their own entries in the flow */}
|
||||
{waitingForResponse && streamingToolCalls.length > 0 && (
|
||||
<div className="flex flex-col items-start gap-2 max-w-[75%]">
|
||||
{streamingToolCalls.map((tc) => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Streaming content bubble — real-time response */}
|
||||
{waitingForResponse && streamingContent && (
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
className="max-w-[75%] px-3 py-2 rounded-lg text-sm"
|
||||
style={{
|
||||
background: 'var(--color-bg-secondary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{progressLabel && (
|
||||
<div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
<span className="inline-block w-2 h-2 rounded-full animate-pulse" style={{ background: 'var(--color-accent)' }} />
|
||||
{progressLabel}
|
||||
<div className="text-xs mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Result
|
||||
</div>
|
||||
<div className="prose prose-sm prose-invert max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{findings}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
<div className="prose prose-sm prose-invert max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{streamingContent}</ReactMarkdown>
|
||||
</div>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{streamElapsedMs > 0 && `${(streamElapsedMs / 1000).toFixed(1)}s elapsed`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
traceSteps.length === 0 && (
|
||||
<div
|
||||
className="text-sm text-center py-8"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
No runs yet. Ask a question below to run the agent.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{/* Input area */}
|
||||
<div
|
||||
className="mt-3 pt-3"
|
||||
style={{ borderTop: '1px solid var(--color-border)' }}
|
||||
>
|
||||
|
||||
{/* ── Follow-up chat input ───────────────────────────── */}
|
||||
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--color-border)' }}>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend('immediate');
|
||||
handleAsk();
|
||||
}
|
||||
}}
|
||||
placeholder="Send a message to this agent..."
|
||||
placeholder={isBusy ? 'Agent is running…' : "Ask a follow-up about this agent's work…"}
|
||||
disabled={isBusy}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm bg-transparent outline-none resize-none"
|
||||
style={{ border: '1px solid var(--color-border)', color: 'var(--color-text)', minHeight: 72 }}
|
||||
style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
minHeight: 64,
|
||||
opacity: isBusy ? 0.6 : 1,
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Sends your question as an ad-hoc run — results appear in the trace above.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleSend('immediate')}
|
||||
disabled={sending || waitingForResponse || !input.trim()}
|
||||
onClick={handleAsk}
|
||||
disabled={isBusy || !input.trim()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm cursor-pointer font-medium"
|
||||
style={{ background: 'var(--color-accent)', color: 'var(--color-on-accent)', opacity: sending || !input.trim() ? 0.5 : 1 }}
|
||||
style={{
|
||||
background: 'var(--color-accent)',
|
||||
color: 'var(--color-on-accent)',
|
||||
opacity: isBusy || !input.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Send size={13} /> Send
|
||||
{isBusy ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
|
||||
{isBusy ? 'Running' : 'Ask'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3616,10 +3534,15 @@ export function AgentsPage() {
|
||||
}
|
||||
prevStatuses.current[agent.id] = agent.status;
|
||||
}
|
||||
// Keep the agent list — and the derived selectedAgent status badge —
|
||||
// live. This poll previously fetched statuses only to fire error
|
||||
// toasts and threw the result away, so a detail header could stay
|
||||
// stuck on "running" after a tick finished on the backend.
|
||||
setManagedAgents(agents);
|
||||
} catch {}
|
||||
}, 30000);
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [setManagedAgents]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -3816,8 +3739,8 @@ export function AgentsPage() {
|
||||
const paramsB = paramMatch ? parseFloat(paramMatch[1]) : 9;
|
||||
const flops = 2 * paramsB * 1e9 * (inTok + outTok);
|
||||
const providers = [
|
||||
{ label: 'GPT-5.3', inPer1M: 2.0, outPer1M: 10.0 },
|
||||
{ label: 'Claude Opus 4.6', inPer1M: 5.0, outPer1M: 25.0 },
|
||||
{ label: 'GPT-5.6 Sol', inPer1M: 5.0, outPer1M: 30.0 },
|
||||
{ label: 'Claude Fable 5', inPer1M: 10.0, outPer1M: 50.0 },
|
||||
{ label: 'Gemini 3.1 Pro', inPer1M: 2.0, outPer1M: 12.0 },
|
||||
];
|
||||
const energyWh = (inTok + outTok) / 1000 * 0.4;
|
||||
@@ -3903,7 +3826,7 @@ export function AgentsPage() {
|
||||
)}
|
||||
|
||||
{/* Tab: Interact */}
|
||||
{detailTab === 'interact' && <InteractTab agentId={selectedAgent.id} agentStatus={selectedAgent.status} />}
|
||||
{detailTab === 'interact' && <InteractTab agentId={selectedAgent.id} agentStatus={selectedAgent.status} onRunStateChange={refresh} />}
|
||||
|
||||
{/* Tab: Channels */}
|
||||
{detailTab === 'channels' && (
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { SOURCE_CATALOG } from '../types/connectors';
|
||||
import type { ConnectRequest } from '../types/connectors';
|
||||
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync } from '../lib/connectors-api';
|
||||
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync, startServerOAuth } from '../lib/connectors-api';
|
||||
import type { SyncStatus } from '../types/connectors';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -673,7 +673,19 @@ function DataSourcesSection() {
|
||||
setConnectStage('Connecting...');
|
||||
setConnectError('');
|
||||
try {
|
||||
await connectSource(id, req);
|
||||
const resp = await connectSource(id, req);
|
||||
|
||||
// OAuth connectors (Google Drive/Calendar/Contacts/Gmail/Tasks): pasting
|
||||
// a Client ID / Secret only registers the app credentials. The backend
|
||||
// returns `oauth_required` with the path to the in-process consent flow,
|
||||
// which is the only path that actually mints an access token. Open it now
|
||||
// and wait for the callback to flip the connector to connected. Without
|
||||
// this the connector would stay "pending" forever — the exact #512 bug.
|
||||
if (resp.status === 'oauth_required') {
|
||||
setConnectStage('Opening Google sign-in...');
|
||||
await startServerOAuth(id, resp.oauth_start);
|
||||
}
|
||||
|
||||
setConnectStage('Connected! Starting sync...');
|
||||
|
||||
// Wait for connector to show as connected
|
||||
|
||||
@@ -417,7 +417,7 @@ function SelfHostedView() {
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Launch the API server to get the full UI in your browser:
|
||||
</p>
|
||||
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra server\njarvis serve --port 8000"} />
|
||||
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra desktop\njarvis serve --port 8000"} />
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
The chat, dashboard, energy profiling, and cost comparison all run
|
||||
locally on your machine.
|
||||
|
||||
@@ -19,9 +19,24 @@ import {
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore, type ThemeMode } from '../lib/store';
|
||||
import { checkHealth, fetchSpeechHealth, getMemoryStats } from '../lib/api';
|
||||
import {
|
||||
checkHealth,
|
||||
fetchSpeechHealth,
|
||||
getMemoryStats,
|
||||
getInferenceSource,
|
||||
setInferenceSource,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
fetchToolCredentialStatus,
|
||||
saveToolCredentials,
|
||||
deleteToolCredential,
|
||||
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 +59,135 @@ 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,
|
||||
toolName,
|
||||
}: {
|
||||
keyName: string;
|
||||
placeholder: string;
|
||||
toolName?: 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 serverToolStorage = !desktopKeyStorage && !!toolName;
|
||||
const canManage = desktopKeyStorage || serverToolStorage;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!canManage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = desktopKeyStorage
|
||||
? await getCloudKeyStatus()
|
||||
: await fetchToolCredentialStatus(toolName!);
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [canManage, desktopKeyStorage, keyName, toolName]);
|
||||
|
||||
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 {
|
||||
if (desktopKeyStorage) {
|
||||
await saveCloudKey(keyName, next);
|
||||
} else if (toolName) {
|
||||
await saveToolCredentials(toolName, { [keyName]: next });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
if (desktopKeyStorage) {
|
||||
await saveCloudKey(keyName, '');
|
||||
} else if (toolName) {
|
||||
await deleteToolCredential(toolName, keyName);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
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 ? (desktopKeyStorage ? 'Saved in secure storage' : 'Saved by local server') : placeholder}
|
||||
disabled={!canManage}
|
||||
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={{
|
||||
@@ -162,6 +280,35 @@ export function SettingsPage() {
|
||||
try { return parseInt(localStorage.getItem('openjarvis-memory-max-tokens') || '2048'); } catch { return 2048; }
|
||||
});
|
||||
|
||||
const [srcKind, setSrcKind] = useState<InferenceSource['kind']>('ollama');
|
||||
const [customHost, setCustomHost] = useState('http://localhost:1234/v1');
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [customEngine, setCustomEngine] = useState('lmstudio');
|
||||
const [customKey, setCustomKey] = useState('');
|
||||
const [srcMsg, setSrcMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getInferenceSource().then((s) => {
|
||||
setSrcKind(s.kind);
|
||||
if (s.host) setCustomHost(s.host);
|
||||
if (s.model) setCustomModel(s.model);
|
||||
if (s.engine) setCustomEngine(s.engine);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const saveSource = useCallback(async () => {
|
||||
try {
|
||||
if (srcKind === 'custom') {
|
||||
await setInferenceSource({ kind: 'custom', host: customHost, model: customModel, engine: customEngine, apiKey: customKey || undefined });
|
||||
} else {
|
||||
await setInferenceSource({ kind: 'ollama' });
|
||||
}
|
||||
setSrcMsg('Saved — restart the app to apply.');
|
||||
} catch (e: any) {
|
||||
setSrcMsg(e?.message ?? 'Failed to save.');
|
||||
}
|
||||
}, [srcKind, customHost, customModel, customEngine, customKey]);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth().then(setHealthy);
|
||||
fetchSpeechHealth()
|
||||
@@ -316,6 +463,73 @@ export function SettingsPage() {
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="API key" description="Required only if the server was started with an API key">
|
||||
<input
|
||||
type="password"
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => { updateSettings({ apiKey: e.target.value }); showSaved(); }}
|
||||
placeholder="OPENJARVIS_API_KEY"
|
||||
autoComplete="off"
|
||||
className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
|
||||
style={{
|
||||
background: 'var(--color-bg-secondary)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Inference source */}
|
||||
<Section title="Inference source">
|
||||
<SettingRow label="Source" description="Where the app runs models. Applies after restart.">
|
||||
<select
|
||||
value={srcKind}
|
||||
onChange={(e) => { setSrcKind(e.target.value as InferenceSource['kind']); setSrcMsg(''); }}
|
||||
className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
|
||||
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<option value="ollama">Bundled Ollama (default)</option>
|
||||
<option value="custom">Custom OpenAI-compatible server</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
{srcKind === 'custom' && (
|
||||
<>
|
||||
<SettingRow label="Server URL" description="e.g. LM Studio: http://localhost:1234/v1">
|
||||
<input type="text" value={customHost} onChange={(e) => { setCustomHost(e.target.value); setSrcMsg(''); }} placeholder="http://localhost:1234/v1"
|
||||
className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
|
||||
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text)', border: '1px solid var(--color-border)' }} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Model" description="Model id served by your endpoint">
|
||||
<input type="text" value={customModel} onChange={(e) => { setCustomModel(e.target.value); setSrcMsg(''); }} placeholder="qwen2.5-7b-instruct"
|
||||
className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
|
||||
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text)', border: '1px solid var(--color-border)' }} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Server type" description="OpenAI-compatible engine">
|
||||
<select value={customEngine} onChange={(e) => { setCustomEngine(e.target.value); setSrcMsg(''); }}
|
||||
className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
|
||||
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text)', border: '1px solid var(--color-border)' }}>
|
||||
<option value="lmstudio">LM Studio</option>
|
||||
<option value="vllm">vLLM</option>
|
||||
<option value="sglang">SGLang</option>
|
||||
<option value="llamacpp">llama.cpp</option>
|
||||
<option value="mlx">MLX</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="API key (optional)" description="Only if your server requires one">
|
||||
<input type="password" value={customKey} onChange={(e) => { setCustomKey(e.target.value); setSrcMsg(''); }} placeholder="leave blank if none"
|
||||
className="text-sm px-3 py-1.5 rounded-lg outline-none w-56"
|
||||
style={{ background: 'var(--color-bg-secondary)', color: 'var(--color-text)', border: '1px solid var(--color-border)' }} />
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingRow label="" description={srcMsg}>
|
||||
<button onClick={saveSource}
|
||||
className="text-sm px-3 py-1.5 rounded-lg outline-none cursor-pointer"
|
||||
style={{ background: 'var(--color-accent, var(--color-bg-tertiary))', color: 'var(--color-text)', border: '1px solid var(--color-border)' }}>
|
||||
Save inference source
|
||||
</button>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Models */}
|
||||
@@ -328,10 +542,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>
|
||||
@@ -339,23 +553,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-..." toolName="web_search" />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
@@ -618,7 +832,7 @@ export function SettingsPage() {
|
||||
</p>
|
||||
<div className="flex gap-3 mt-3 text-xs">
|
||||
<a
|
||||
href="https://scalingintelligence.stanford.edu/blogs/openjarvis/"
|
||||
href="https://openjarvis.stanford.edu/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
|
||||
@@ -55,6 +55,19 @@ export interface ConnectRequest {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/** Response from POST /v1/connectors/{id}/connect.
|
||||
* For OAuth connectors, pasting a Client ID / Secret pair only registers the
|
||||
* app credentials; the backend returns `status: "oauth_required"` plus an
|
||||
* `oauth_start` path the UI must open to run the browser consent flow that
|
||||
* actually mints an access token (see issue #512). */
|
||||
export interface ConnectResponse {
|
||||
connector_id: string;
|
||||
connected: boolean;
|
||||
status: "connected" | "pending" | "oauth_required" | "disconnected";
|
||||
oauth_start?: string;
|
||||
sync_status?: string | null;
|
||||
}
|
||||
|
||||
export type WizardStep = "pick" | "connect" | "ingest" | "ready";
|
||||
|
||||
// Backward-compatible alias
|
||||
@@ -257,12 +270,12 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
urlLabel: 'Enable Drive API',
|
||||
},
|
||||
{
|
||||
label: 'Create OAuth credentials: go to Credentials (link below) → click "+ Create Credentials" → choose "OAuth client ID" → Application type: "Desktop app" → click "Create"',
|
||||
label: 'Create OAuth credentials: go to Credentials (link below) → click "+ Create Credentials" → choose "OAuth client ID" → Application type: "Web application". Under "Authorized redirect URIs" add this server\'s callback (e.g. http://localhost:1313/v1/connectors/gdrive/oauth/callback — match the host/port your OpenJarvis server is bound to) → click "Create".',
|
||||
url: 'https://console.cloud.google.com/apis/credentials',
|
||||
urlLabel: 'Open Credentials',
|
||||
},
|
||||
{
|
||||
label: 'A dialog will show your Client ID and Client Secret. Copy both and paste them below. (If you miss it, click the download icon next to your OAuth client to see them again)',
|
||||
label: 'A dialog will show your Client ID and Client Secret. Copy both and paste them below, then click Connect — a Google sign-in window opens to finish authorization. (If you miss the dialog, click the download icon next to your OAuth client to see them again.)',
|
||||
},
|
||||
],
|
||||
inputFields: [
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/optinmodal.tsx","./src/components/setupscreen.tsx","./src/components/systempulse.tsx","./src/components/chat/audioplayer.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/micbutton.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/chat/xrayfooter.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/components/setup/ingestdashboard.tsx","./src/components/setup/readyscreen.tsx","./src/components/setup/setupwizard.tsx","./src/components/setup/sourceconnectflow.tsx","./src/components/setup/sourcepicker.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tooltip.tsx","./src/hooks/usespeech.ts","./src/lib/analytics.ts","./src/lib/api.ts","./src/lib/connectors-api.ts","./src/lib/deep-link.ts","./src/lib/profanity.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/lib/useagentevents.ts","./src/lib/utils.ts","./src/pages/agentspage.tsx","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/datasourcespage.tsx","./src/pages/getstartedpage.tsx","./src/pages/logspage.tsx","./src/pages/settingspage.tsx","./src/types/connectors.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/approvalbell.tsx","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/optinmodal.tsx","./src/components/setupscreen.tsx","./src/components/systempulse.tsx","./src/components/chat/audioplayer.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/micbutton.tsx","./src/components/chat/researchtimeline.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/chat/xrayfooter.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/components/setup/ingestdashboard.tsx","./src/components/setup/readyscreen.tsx","./src/components/setup/setupwizard.tsx","./src/components/setup/sourceconnectflow.tsx","./src/components/setup/sourcepicker.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tooltip.tsx","./src/hooks/usespeech.ts","./src/lib/analytics.ts","./src/lib/api.ts","./src/lib/connectors-api.ts","./src/lib/deep-link.ts","./src/lib/profanity.ts","./src/lib/rehype-citations.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/lib/useagentevents.ts","./src/lib/utils.ts","./src/pages/agentspage.tsx","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/datasourcespage.tsx","./src/pages/getstartedpage.tsx","./src/pages/logspage.tsx","./src/pages/settingspage.tsx","./src/types/connectors.ts","./src/types/index.ts"],"version":"5.7.3"}
|
||||
@@ -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: {
|
||||
|
||||
+12
@@ -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
|
||||
@@ -150,6 +151,14 @@ nav:
|
||||
- Quick Start: getting-started/quickstart.md
|
||||
- Code Snippets: getting-started/snippets.md
|
||||
- Configuration: getting-started/configuration.md
|
||||
- Showcase:
|
||||
- Overview: showcase/index.md
|
||||
- Morning Brief: showcase/morning-brief.md
|
||||
- Memory That Doesn't Reset: showcase/persistent-memory.md
|
||||
- Track Your Savings: showcase/cost-savings.md
|
||||
- Discord Companion: showcase/discord-companion.md
|
||||
- Offline Code Reviewer: showcase/coding-assistant.md
|
||||
- Contributing: showcase/CONTRIBUTING.md
|
||||
- Tutorials:
|
||||
- Overview: tutorials/index.md
|
||||
- Deep Research Assistant: tutorials/deep-research.md
|
||||
@@ -185,6 +194,9 @@ nav:
|
||||
- External MCP Servers: user-guide/mcp-external-servers.md
|
||||
- Scheduler: user-guide/scheduler.md
|
||||
- Telemetry: user-guide/telemetry.md
|
||||
- Evaluations: user-guide/evaluations.md
|
||||
- Benchmarks: user-guide/benchmarks.md
|
||||
- System Access: user-guide/system-access.md
|
||||
- Security: user-guide/security.md
|
||||
- LLM-guided spec search: user-guide/llm-guided-spec-search.md
|
||||
- Leaderboard: leaderboard.md
|
||||
|
||||
+65
-17
@@ -1,13 +1,17 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "OpenJarvis"
|
||||
version = "1.0.2"
|
||||
dynamic = ["version"]
|
||||
description = "OpenJarvis — modular AI assistant backend with composable intelligence primitives"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
# Upper bound: numpy 2.2.x (pinned transitively via datasets/pandas) ships no
|
||||
# cp314 Windows wheel, so under Python 3.14 uv would compile numpy from source
|
||||
# (Meson) and fail on Windows boxes without a C toolchain (#350). Cap to the
|
||||
# range that has prebuilt wheels; matches the classifiers (3.10–3.13).
|
||||
requires-python = ">=3.10,<3.14"
|
||||
license = {text = "Apache-2.0"}
|
||||
authors = [
|
||||
{name = "Open Jarvis Contributors"},
|
||||
@@ -29,12 +33,13 @@ dependencies = [
|
||||
"ddgs>=9.11.4",
|
||||
"httpx>=0.27",
|
||||
"openai>=1.30",
|
||||
"posthog>=3.0",
|
||||
"nvidia-ml-py>=12.560.30",
|
||||
"posthog>=3.0",
|
||||
"python-telegram-bot>=22.6",
|
||||
"rich>=13",
|
||||
"tomli>=2.0; python_version < '3.11'",
|
||||
"tomlkit>=0.12",
|
||||
"websockets>=15.0.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -43,6 +48,7 @@ dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cov>=5",
|
||||
"pytest-xdist>=3",
|
||||
"respx>=0.22",
|
||||
"ruff>=0.4",
|
||||
"pre-commit>=3.0",
|
||||
@@ -79,20 +85,21 @@ server = [
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
desktop = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn>=0.30",
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"faster-whisper>=1.0",
|
||||
]
|
||||
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
|
||||
gpu-metrics = ["nvidia-ml-py>=12.560.30"]
|
||||
gpu-metrics = ["pynvml>=12.0"]
|
||||
energy-amd = ["amdsmi>=6.1"]
|
||||
energy-apple = ["zeus-ml[apple]"]
|
||||
energy-all = ["nvidia-ml-py>=12.560.30", "amdsmi>=6.1", "zeus-ml[apple]"]
|
||||
energy-all = ["pynvml>=12.0", "amdsmi>=6.1", "zeus-ml[apple]"]
|
||||
orchestrator-training = ["torch>=2.0", "transformers>=4.40"]
|
||||
learning-dspy = ["dspy>=2.6"]
|
||||
learning-gepa = ["gepa>=0.1"]
|
||||
# ACE (Agentic Context Engineering) is supported via
|
||||
# ``openjarvis.learning.agents.ace_optimizer`` but ACE upstream isn't on
|
||||
# PyPI and isn't structured as an installable Python package as of
|
||||
# v1.0.1, so there's no ``learning-ace`` extra. To use ACE, follow the
|
||||
# manual setup in docs/learning/ace.md (clone the upstream repo, add
|
||||
# its ``src/`` to PYTHONPATH).
|
||||
channel-telegram = ["python-telegram-bot>=21.0"]
|
||||
channel-discord = ["discord.py>=2.3"]
|
||||
channel-slack = ["slack-sdk>=3.27"]
|
||||
@@ -153,6 +160,34 @@ Issues = "https://github.com/open-jarvis/OpenJarvis/issues"
|
||||
|
||||
[project.scripts]
|
||||
jarvis = "openjarvis.cli:main"
|
||||
openjarvis-eval = "openjarvis.evals.cli:main"
|
||||
|
||||
# Version is derived from git tags by hatch-vcs (see #526). For source/editable
|
||||
# checkouts this yields the true `git describe` version (e.g. 1.0.3.dev109+g<sha>)
|
||||
# rather than a stale static string. CI release builds override this with
|
||||
# SETUPTOOLS_SCM_PRETEND_VERSION so the published version equals the pushed tag.
|
||||
#
|
||||
# setuptools_scm cannot bump custom `.devN` tags (only `.dev0`), so the autotag
|
||||
# `vX.Y.Z.devN` tags are deliberately EXCLUDED from version derivation here; the
|
||||
# base is taken from the latest plain release tag (vX.Y.Z) and the dev distance
|
||||
# is computed from commit count since that release.
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.version.raw-options]
|
||||
tag_regex = '^v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$'
|
||||
git_describe_command = [
|
||||
"git", "describe", "--dirty", "--tags", "--long",
|
||||
"--match", "v[0-9]*", "--exclude", "*dev*", "--exclude", "*rc*", "--exclude", "desktop-*",
|
||||
]
|
||||
# Builds without a git checkout (e.g. the `COPY src/ src/` Docker stages, which
|
||||
# never include .git) can't run `git describe`. Without a fallback that would
|
||||
# hard-fail the build. Mirror the runtime sentinel in src/openjarvis/__init__.py.
|
||||
# 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"]
|
||||
@@ -161,6 +196,7 @@ packages = ["src/openjarvis"]
|
||||
"src/openjarvis/agents/claude_code_runner" = "_node_modules/claude_code_runner"
|
||||
"src/openjarvis/channels/whatsapp_baileys_bridge" = "_node_modules/whatsapp_baileys_bridge"
|
||||
"scripts/install" = "_install_scripts"
|
||||
"deploy/windows" = "_deploy/windows"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -174,7 +210,9 @@ markers = [
|
||||
"live_channel: requires real channel credentials (env vars)",
|
||||
"nvidia: requires NVIDIA GPU",
|
||||
"slow: long-running test",
|
||||
"hub: downloads real datasets from the HuggingFace Hub at runtime; excluded from the default CI lane (run with -m hub)",
|
||||
"live_external: requires HERMES_AGENT_PATH and OPENCLAW_PATH; spawns real foreign-framework subprocesses",
|
||||
"modal: requires Modal token + network; runs real swebench harness on Modal",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
@@ -188,14 +226,24 @@ select = ["E", "F", "I", "W"]
|
||||
"src/openjarvis/evals/datasets/*.py" = ["E501"]
|
||||
"src/openjarvis/evals/scorers/*.py" = ["E501"]
|
||||
# hybrid/ is research code with long prompt strings and paradigm-specific
|
||||
# config dicts — same relaxation as evals research code above.
|
||||
"src/openjarvis/agents/hybrid/*.py" = ["E501"]
|
||||
# research_loop.py carries the multi-paragraph planner system prompt as
|
||||
# inline string literals; line-length wrapping would harm readability of
|
||||
# the prompt itself.
|
||||
# config dicts — same relaxation as evals research code above. The ``**`` glob
|
||||
# also covers subpackages (e.g. hybrid/skillorchestra/), which the prior
|
||||
# ``hybrid/*.py`` glob missed.
|
||||
"src/openjarvis/agents/hybrid/**/*.py" = ["E501"]
|
||||
# research_loop.py carries long prompt strings too (documented in CLAUDE.md);
|
||||
# the ignore was missing here.
|
||||
"src/openjarvis/agents/research_loop.py" = ["E501"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"maturin>=1.12.6",
|
||||
]
|
||||
# openjarvis_rust is the native PyO3 extension, built from the local Rust
|
||||
# workspace. It lives in a uv dependency group (PEP 735) — not the published
|
||||
# `desktop` extra — so `uv sync --group desktop-native` builds it from source
|
||||
# for the desktop app, while `pip install openjarvis[desktop]` from PyPI does
|
||||
# NOT try to resolve openjarvis-rust from PyPI, where it isn't published
|
||||
# (dependency groups are excluded from wheel metadata). See #584 / #615.
|
||||
desktop-native = [
|
||||
"openjarvis-rust",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,14 @@ members = [
|
||||
"crates/openjarvis-scheduler",
|
||||
]
|
||||
|
||||
# Minimum supported Rust version. The workspace uses let-chains (via rig-core)
|
||||
# and `is_multiple_of` (openjarvis-skills), both stabilized in Rust 1.88 —
|
||||
# building on 1.86/1.87 fails with cryptic E0658 errors deep in dependencies
|
||||
# (see #252). Declared here and pinned in rust-toolchain.toml so users get a
|
||||
# clear "requires rustc 1.88" signal instead.
|
||||
[workspace.package]
|
||||
rust-version = "1.88"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -32,8 +32,7 @@ impl LoopGuard {
|
||||
// Check identical calls
|
||||
if self.seen_hashes.contains(&hash) {
|
||||
return Some(format!(
|
||||
"Loop detected: identical call to '{}' with same arguments",
|
||||
tool_name
|
||||
"Loop detected: identical call to '{tool_name}' with same arguments"
|
||||
));
|
||||
}
|
||||
self.seen_hashes.insert(hash);
|
||||
|
||||
@@ -355,7 +355,7 @@ impl<M: CompletionModel + 'static> OjAgent for MonitorOperativeAgent<M> {
|
||||
// Loop guard check
|
||||
if let Some(loop_msg) = guard.check(&action, &action_input) {
|
||||
return Ok(AgentResult {
|
||||
content: format!("Agent stopped: {}", loop_msg),
|
||||
content: format!("Agent stopped: {loop_msg}"),
|
||||
tool_results: all_tool_results,
|
||||
turns: turn,
|
||||
metadata: self.strategy_metadata(),
|
||||
@@ -379,7 +379,7 @@ impl<M: CompletionModel + 'static> OjAgent for MonitorOperativeAgent<M> {
|
||||
let compressed = self.compress_observation(&tool_result.content);
|
||||
|
||||
history.push(RigMessage::assistant(&text));
|
||||
current_input = format!("Observation: {}", compressed);
|
||||
current_input = format!("Observation: {compressed}");
|
||||
|
||||
all_tool_results.push(tool_result);
|
||||
} else {
|
||||
|
||||
@@ -202,7 +202,7 @@ impl<M: CompletionModel + 'static> OjAgent for NativeOpenHandsAgent<M> {
|
||||
|
||||
if let Some(loop_msg) = guard.check(tool_name, &args_str) {
|
||||
return Ok(AgentResult {
|
||||
content: format!("Agent stopped: {}", loop_msg),
|
||||
content: format!("Agent stopped: {loop_msg}"),
|
||||
tool_results: all_tool_results,
|
||||
turns: turn,
|
||||
metadata: HashMap::new(),
|
||||
@@ -221,7 +221,7 @@ impl<M: CompletionModel + 'static> OjAgent for NativeOpenHandsAgent<M> {
|
||||
|
||||
let obs = Self::truncate_observation(&tool_result.content, 4000);
|
||||
history.push(RigMessage::assistant(&text));
|
||||
current_input = format!("Output:\n{}", obs);
|
||||
current_input = format!("Output:\n{obs}");
|
||||
|
||||
all_tool_results.push(tool_result);
|
||||
continue;
|
||||
@@ -231,7 +231,7 @@ impl<M: CompletionModel + 'static> OjAgent for NativeOpenHandsAgent<M> {
|
||||
if let Some((action, action_input)) = Self::parse_action(&text) {
|
||||
if let Some(loop_msg) = guard.check(&action, &action_input) {
|
||||
return Ok(AgentResult {
|
||||
content: format!("Agent stopped: {}", loop_msg),
|
||||
content: format!("Agent stopped: {loop_msg}"),
|
||||
tool_results: all_tool_results,
|
||||
turns: turn,
|
||||
metadata: HashMap::new(),
|
||||
@@ -253,7 +253,7 @@ impl<M: CompletionModel + 'static> OjAgent for NativeOpenHandsAgent<M> {
|
||||
|
||||
let obs = Self::truncate_observation(&tool_result.content, 4000);
|
||||
history.push(RigMessage::assistant(&text));
|
||||
current_input = format!("Result: {}", obs);
|
||||
current_input = format!("Result: {obs}");
|
||||
|
||||
all_tool_results.push(tool_result);
|
||||
continue;
|
||||
|
||||
@@ -143,7 +143,7 @@ impl<M: CompletionModel + 'static> OjAgent for NativeReActAgent<M> {
|
||||
if let Some((action, action_input)) = Self::parse_action(&text) {
|
||||
if let Some(loop_msg) = guard.check(&action, &action_input) {
|
||||
return Ok(AgentResult {
|
||||
content: format!("Agent stopped: {}", loop_msg),
|
||||
content: format!("Agent stopped: {loop_msg}"),
|
||||
tool_results: all_tool_results,
|
||||
turns: turn,
|
||||
metadata: HashMap::new(),
|
||||
|
||||
@@ -104,8 +104,7 @@ pub fn get_engine_static(
|
||||
))),
|
||||
other => Err(OpenJarvisError::Engine(
|
||||
openjarvis_core::error::EngineError::ModelNotFound(format!(
|
||||
"Unknown engine: {}",
|
||||
other
|
||||
"Unknown engine: {other}"
|
||||
)),
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ impl LlamaCppEngine {
|
||||
let host = if host.starts_with("http") {
|
||||
host
|
||||
} else {
|
||||
format!("http://{}", host)
|
||||
format!("http://{host}")
|
||||
};
|
||||
let host = host.trim_end_matches('/').to_string();
|
||||
let timeout = std::time::Duration::from_secs_f64(timeout_secs);
|
||||
@@ -120,8 +120,7 @@ impl InferenceEngine for LlamaCppEngine {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
return Err(OpenJarvisError::Engine(EngineError::Http(format!(
|
||||
"llama.cpp returned {}: {}",
|
||||
status, body
|
||||
"llama.cpp returned {status}: {body}"
|
||||
))));
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user