mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
205
Commits
v1.0.0
...
v1.0.3.dev856
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7e8db280e4 | ||
|
|
e3f2b008d2 | ||
|
|
cdae426d48 | ||
|
|
ae9727599b | ||
|
|
a86d022947 | ||
|
|
8e6bc343d8 | ||
|
|
56c9a59f8d | ||
|
|
bed3524b62 | ||
|
|
eb5d1e467f | ||
|
|
b43f4d2291 | ||
|
|
658effb964 | ||
|
|
0c34817ecd | ||
|
|
c06633e26f | ||
|
|
deb0dd509c | ||
|
|
a76319841a | ||
|
|
cf8d3e2cbe | ||
|
|
bced6cc274 | ||
|
|
612d3e1f88 | ||
|
|
58f7b753bf | ||
|
|
158fc83feb | ||
|
|
52e830e1a4 | ||
|
|
0483f5377d | ||
|
|
2d6d6ed7bd | ||
|
|
71c5b2bce1 | ||
|
|
440915c822 | ||
|
|
c84f1fddee | ||
|
|
fecbc51767 | ||
|
|
07b6cba6df | ||
|
|
7b6fe86c91 | ||
|
|
a6f18dff43 | ||
|
|
5a74fd82b8 | ||
|
|
9d0bb25516 | ||
|
|
e963321114 | ||
|
|
363bdbfa41 | ||
|
|
75cb70526e | ||
|
|
e087773b36 | ||
|
|
9c9c883f24 | ||
|
|
ef267abcc1 | ||
|
|
5f685a2e3b | ||
|
|
1d37528637 | ||
|
|
86c42e4e4a | ||
|
|
a65cd12ea2 | ||
|
|
36482b1a27 | ||
|
|
333cb9a370 | ||
|
|
4c37aa83df | ||
|
|
c8f1b3c54a | ||
|
|
855ed51780 | ||
|
|
4652b6e8a8 | ||
|
|
30f8d57398 | ||
|
|
9a42e62172 | ||
|
|
90f009e906 | ||
|
|
0217a901dd | ||
|
|
c7f451fbc8 | ||
|
|
30ac635e83 | ||
|
|
31efd9e672 | ||
|
|
8b5a304222 | ||
|
|
df566d4186 | ||
|
|
4177b5c50e | ||
|
|
f21eec6c86 | ||
|
|
4a7817509b | ||
|
|
2b0e6e7130 | ||
|
|
289fcb00e2 | ||
|
|
4668ea0b7f | ||
|
|
a58d6b6c48 | ||
|
|
af21bc18ea | ||
|
|
fea8d3e872 | ||
|
|
5b847cd081 | ||
|
|
a5029e297f | ||
|
|
9af0dfe336 | ||
|
|
b863cbb07b | ||
|
|
7081be7bd3 | ||
|
|
df4332b0e8 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "59,867",
|
||||
"message": "130,895",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 59867,
|
||||
"last_updated": "2026-05-16T06:49:33Z",
|
||||
"total_clones": 130895,
|
||||
"last_updated": "2026-06-24T07:14:43Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -50,6 +50,46 @@
|
||||
"2026-05-11": 1008,
|
||||
"2026-05-12": 1390,
|
||||
"2026-05-13": 1397,
|
||||
"2026-05-14": 846
|
||||
"2026-05-14": 846,
|
||||
"2026-05-15": 1671,
|
||||
"2026-05-16": 2264,
|
||||
"2026-05-17": 654,
|
||||
"2026-05-18": 1425,
|
||||
"2026-05-19": 850,
|
||||
"2026-05-20": 954,
|
||||
"2026-05-21": 1605,
|
||||
"2026-05-22": 612,
|
||||
"2026-05-23": 2437,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
name: Auto-tag on main push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute dev version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 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.
|
||||
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].*$//')
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
BUILD=$(git rev-list --count HEAD)
|
||||
VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}.dev${BUILD}"
|
||||
TAG="v${VERSION}"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed ${TAG} (base=${BASE})"
|
||||
|
||||
- name: Create and push tag
|
||||
id: tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
EXISTING_SHA=$(git rev-parse "$TAG")
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
if [[ "$EXISTING_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "::error::Tag $TAG already exists at $EXISTING_SHA but HEAD is $HEAD_SHA"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag $TAG already exists at HEAD, skipping creation"
|
||||
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
echo "Created and pushed $TAG"
|
||||
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Tag pushes made with the default GITHUB_TOKEN do NOT trigger other
|
||||
# workflows (recursion prevention). workflow_dispatch is the documented
|
||||
# exception, so we explicitly dispatch the downstream CD workflows here.
|
||||
# See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication
|
||||
- name: Dispatch downstream workflows
|
||||
if: steps.tag.outputs.created == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Dispatching pypi-publish.yml @ ${TAG}"
|
||||
gh workflow run pypi-publish.yml \
|
||||
--ref "${TAG}" \
|
||||
-f tag="${TAG}"
|
||||
echo "Dispatching desktop.yml @ ${TAG}"
|
||||
gh workflow run desktop.yml \
|
||||
--ref "${TAG}" \
|
||||
-f tag="${TAG}"
|
||||
@@ -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
|
||||
@@ -55,6 +57,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 +67,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 \
|
||||
@@ -72,12 +81,73 @@ jobs:
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
timeout-minutes: 5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-xml
|
||||
path: coverage.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
# Windows job — empirically exercises the platform-specific code paths that
|
||||
# the Ubuntu `test` job can never reach: GlobalMemoryStatusEx RAM detection
|
||||
# (#373) and the cp9xx -> UTF-8 stdout reconfigure (#293). Also the only CI
|
||||
# job that builds + imports the mandatory `openjarvis_rust` PyO3 extension
|
||||
# on Windows. Public repo -> Windows runner minutes are free.
|
||||
#
|
||||
# All `run:` steps use static commands only (no `github.event.*`
|
||||
# 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: ${{ 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
|
||||
|
||||
# Pure-Python check — runs before the Rust build so a flaky toolchain
|
||||
# install can never mask the actual RAM-detection verification.
|
||||
- name: Verify Windows RAM detection (#373)
|
||||
shell: bash
|
||||
run: |
|
||||
uv run python -c "from openjarvis.core.config import _total_ram_gb; ram = _total_ram_gb(); print(f'GlobalMemoryStatusEx RAM = {ram} GB'); assert ram > 0, f'Windows RAM detection returned {ram}, expected > 0'"
|
||||
|
||||
- name: Run Windows-specific tests (hardware + CLI)
|
||||
shell: bash
|
||||
run: |
|
||||
uv run pytest tests/hardware/test_hardware_profiles.py tests/cli/test_cli.py -v -m "not live and not cloud"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Build + import the PyO3 extension on Windows
|
||||
shell: bash
|
||||
run: |
|
||||
uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
|
||||
uv run python -c "import openjarvis_rust; print('openjarvis_rust imports on Windows OK')"
|
||||
|
||||
- name: Smoke-test CLI
|
||||
shell: bash
|
||||
run: |
|
||||
uv run jarvis --version
|
||||
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+114
-16
@@ -2,11 +2,8 @@ name: Desktop Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/desktop.yml'
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'desktop-v*'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -14,9 +11,14 @@ on:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/desktop.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to build (e.g. v1.0.2.dev500). If set, autotag dispatches use this. github.ref still controls the checkout.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: desktop-${{ github.ref }}
|
||||
group: desktop-${{ inputs.tag || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -38,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
|
||||
@@ -64,23 +67,27 @@ jobs:
|
||||
- name: Create frontend dist stub
|
||||
run: mkdir -p frontend/dist && echo '<html><body></body></html>' > frontend/dist/index.html
|
||||
|
||||
- name: Cargo check
|
||||
# `cargo test` builds the crate (same coverage as the old `cargo check`)
|
||||
# and runs the unit tests, including the #331 uv-sync error-formatting
|
||||
# helpers. Static command, no untrusted input — no injection surface.
|
||||
- name: Cargo test
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo check
|
||||
run: cargo test
|
||||
|
||||
# Remove stale artifacts from the desktop-latest pre-release so that
|
||||
# only the current build's files are available for download.
|
||||
# Remove stale artifacts from the desktop-edge rolling pre-release so that
|
||||
# only the current build's files are available for download. (The stable
|
||||
# `desktop-latest` channel the installed app polls is never cleaned here.)
|
||||
clean-release:
|
||||
needs: [validate]
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete old assets from desktop-latest
|
||||
- name: Delete old assets from desktop-edge
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="desktop-latest"
|
||||
TAG="desktop-edge"
|
||||
# List all asset IDs on the release and delete them
|
||||
ASSET_IDS=$(gh api "repos/${{ github.repository }}/releases/tags/${TAG}" \
|
||||
--jq '.assets[].id' 2>/dev/null || true)
|
||||
@@ -108,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'
|
||||
@@ -119,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
|
||||
@@ -156,14 +169,55 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/desktop-v* ]]; then
|
||||
# Explicit stable desktop release tag
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#desktop-v}"
|
||||
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop ${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
# Auto-tagged rolling build from autotag.yml — use the same
|
||||
# version as the CLI/PyPI release so all surfaces stay in sync.
|
||||
# Rolling/dev builds go to the `desktop-edge` channel, NOT the
|
||||
# `desktop-latest` channel the installed app polls — so users on
|
||||
# stable are never auto-updated onto an unvetted dev build.
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
echo "tag=desktop-edge" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Edge Build)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=desktop-latest" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Latest Build)" >> "$GITHUB_OUTPUT"
|
||||
# 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.
|
||||
# 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].*$//')
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
BUILD=$(git rev-list --count HEAD)
|
||||
VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}.dev${BUILD}"
|
||||
# Manual dispatches are also dev builds -> the edge channel.
|
||||
echo "tag=desktop-edge" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Edge Build)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
# Tauri's build script requires strict SemVer (MAJOR.MINOR.PATCH[-pre][+build]).
|
||||
# PEP 440 dev releases (`1.0.2.dev661`) are NOT valid SemVer, so we
|
||||
# translate `.devN` to the SemVer-equivalent `-dev.N` prerelease form.
|
||||
# PyPI keeps the PEP 440 form; only the Tauri bundle uses SemVer.
|
||||
TAURI_VERSION="${VERSION/.dev/-dev.}"
|
||||
echo "tauri_version=${TAURI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure Apple signing
|
||||
if: runner.os == 'macOS'
|
||||
@@ -199,7 +253,11 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_CONFIG: '{"bundle":{"externalBin":["binaries/ollama"]}}'
|
||||
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
|
||||
@@ -210,3 +268,43 @@ jobs:
|
||||
prerelease: ${{ steps.release-info.outputs.prerelease }}
|
||||
includeUpdaterJson: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# When a stable `desktop-v*` release is published, repoint the
|
||||
# `desktop-latest` auto-update channel (the endpoint the installed app
|
||||
# polls) at it. The stable release's own `latest.json` already references
|
||||
# this release's signed assets, so we copy it verbatim — installed apps are
|
||||
# only ever offered vetted stable builds, never `desktop-edge` dev builds.
|
||||
refresh-stable-channel:
|
||||
needs: [build-and-release]
|
||||
if: startsWith(github.ref, 'refs/tags/desktop-v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror stable latest.json into desktop-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
STABLE_TAG: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The stable release's updater manifest may take a moment to become
|
||||
# downloadable after tauri-action publishes it; retry briefly.
|
||||
URL="https://github.com/${REPO}/releases/download/${STABLE_TAG}/latest.json"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if curl -fsSL -o latest.json "$URL"; then
|
||||
echo "Fetched ${STABLE_TAG}/latest.json on attempt ${attempt}"
|
||||
break
|
||||
fi
|
||||
echo "latest.json not ready yet (attempt ${attempt}); sleeping 15s"
|
||||
sleep 15
|
||||
done
|
||||
test -s latest.json || { echo "::error::Could not fetch ${URL}"; exit 1; }
|
||||
# Ensure the channel release exists (prerelease so it never usurps
|
||||
# the stable "Latest" badge), then replace its manifest in place.
|
||||
if ! gh release view desktop-latest --repo "$REPO" >/dev/null 2>&1; then
|
||||
gh release create desktop-latest --repo "$REPO" \
|
||||
--prerelease \
|
||||
--title "Desktop Auto-Update Channel" \
|
||||
--notes "Auto-update channel pointer for the desktop app. Mirrors the latest stable \`desktop-v*\` release; the in-app updater polls this \`latest.json\`. Download the app from the latest stable release, not here."
|
||||
fi
|
||||
gh release upload desktop-latest latest.json --repo "$REPO" --clobber
|
||||
echo "desktop-latest now mirrors ${STABLE_TAG}"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -7,6 +7,16 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
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
|
||||
@@ -17,13 +27,88 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- name: Resolve target ref
|
||||
id: ref
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
DEFAULT_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$INPUT_TAG" ]]; then
|
||||
echo "ref=${INPUT_TAG}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=${DEFAULT_REF}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
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
|
||||
npm ci
|
||||
# Vite is configured (frontend/vite.config.ts) with
|
||||
# `outDir: '../src/openjarvis/server/static'` and
|
||||
# `emptyOutDir: true`, so the build writes directly into the
|
||||
# Python package's static dir and clears stale assets itself.
|
||||
# No rm/cp is needed — and the previous `dist/`-assuming logic
|
||||
# was broken because `frontend/dist/` is never produced.
|
||||
npm run build
|
||||
STATIC=../src/openjarvis/server/static
|
||||
test -s "$STATIC/index.html" || {
|
||||
echo "::error::${STATIC}/index.html missing or empty after build"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Resolve build version from tag
|
||||
env:
|
||||
REF: ${{ steps.ref.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Strip leading "v" (e.g. v1.0.3.dev825 -> 1.0.3.dev825).
|
||||
VERSION="${REF#v}"
|
||||
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
|
||||
# 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
|
||||
|
||||
+16
-1
@@ -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
|
||||
@@ -48,7 +51,11 @@ Thumbs.db
|
||||
*.log
|
||||
results/
|
||||
logs/
|
||||
traces/
|
||||
# Anchored to repo root — DO NOT use the unanchored form `traces/`.
|
||||
# hatchling honors .gitignore when building the wheel; an unanchored
|
||||
# `traces/` pattern matches src/openjarvis/traces/ and silently drops
|
||||
# the runtime module from the wheel (issue #372).
|
||||
/traces/
|
||||
coding_task_*
|
||||
get-pip.py
|
||||
# Junk from mocked-path tests that write to their mock's __repr__ as a path
|
||||
@@ -99,6 +106,9 @@ src/openjarvis/channels/whatsapp_baileys_bridge/node_modules/
|
||||
# SQLite in-memory artifacts
|
||||
:memory:
|
||||
|
||||
# Dogfood reports (regenerated locally; not for VCS)
|
||||
dogfood_report*.md
|
||||
|
||||
# Second Repos
|
||||
Inline/
|
||||
scratch/
|
||||
@@ -115,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
|
||||
|
||||
+145
@@ -8,6 +8,151 @@ 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
|
||||
wheel on PyPI, silences a noisy startup warning, restores a working
|
||||
install path while `openjarvis.ai` is down, improves desktop
|
||||
first-boot diagnostics on Windows, and ships the RAM-detection fix
|
||||
for Windows that missed the v1.0.1 cutoff.
|
||||
|
||||
### Fixed
|
||||
|
||||
**`openjarvis/traces/` missing from the v1.0.1 PyPI wheel** (#372).
|
||||
The `.gitignore` carried an unanchored `traces/` pattern, which
|
||||
hatchling honored at wheel-build time and matched the runtime module
|
||||
`src/openjarvis/traces/` — silently dropping the whole package. Every
|
||||
fresh `pip install openjarvis==1.0.1` then failed at import with
|
||||
`ModuleNotFoundError: No module named 'openjarvis.traces'` on the
|
||||
first `jarvis ask`, learning, or server call. Anchored the pattern to
|
||||
`/traces/`. Verified: a clean `uv build` now produces a wheel
|
||||
containing all four `traces/` files.
|
||||
|
||||
**`pynvml` deprecation `FutureWarning` on every command** (#389).
|
||||
Switched the dependency from the legacy `pynvml` package to NVIDIA's
|
||||
official `nvidia-ml-py` (same `pynvml` module name, no warning shim),
|
||||
and added defensive `warnings.filterwarnings` at every `import pynvml`
|
||||
site to suppress the warning even when `pynvml` is pulled in
|
||||
transitively.
|
||||
|
||||
**Windows RAM detection returning `0.0 GB`** (#373). The Windows
|
||||
branch of `_total_ram_gb()` (via `GlobalMemoryStatusEx`) landed after
|
||||
the v1.0.1 cutoff, so v1.0.1 users still saw `0.0 GB` from `jarvis
|
||||
init`. Now shipping in the wheel. A new `windows-latest` CI job runs
|
||||
the real `GlobalMemoryStatusEx` path on every PR as a regression
|
||||
guard.
|
||||
|
||||
**Desktop first-boot hung on "did not become healthy in time"**
|
||||
(#331). The Tauri boot path ran `uv sync` with stderr discarded and
|
||||
the exit code ignored, so a failed dependency install surfaced only
|
||||
as a generic 600-second health-check timeout. Now captures stderr,
|
||||
checks the exit status, and surfaces the actual `uv sync` error
|
||||
(with the diagnostic tail) before the long wait. The error-formatting
|
||||
logic is covered by unit tests.
|
||||
|
||||
### Changed
|
||||
|
||||
**Install URL moved to GitHub Pages** (#337, #352). The documented
|
||||
`openjarvis.ai/install.sh` URL was failing with `sslv3 alert
|
||||
handshake failure` (the domain is community-operated and had a broken
|
||||
TLS config). The canonical installer is now served from the
|
||||
project-controlled GitHub Pages site at
|
||||
`https://open-jarvis.github.io/OpenJarvis/install.sh`, generated from
|
||||
the same `scripts/install/install.sh` at docs-build time. The README
|
||||
also documents the WSL2 path for Windows and the `uv` prerequisite
|
||||
for the desktop binary, and the installer bails early with a clear
|
||||
message when run under Git Bash / MSYS2 / Cygwin.
|
||||
|
||||
## [1.0.1] - 2026-05-17
|
||||
|
||||
A patch release that closes the auto-update gap so the analytics
|
||||
module added in #351 actually reaches users on the desktop, adds
|
||||
runtime opt-out for that analytics, fixes the misleading upgrade
|
||||
hint the CLI was printing, and lands the ACE optimizer alongside
|
||||
DSPy and GEPA.
|
||||
|
||||
### Added
|
||||
|
||||
**ACE agent optimizer** (`learning/agents/ace_optimizer.py`). Adds
|
||||
[ACE](https://github.com/ace-agent/ace) as a third agent-learning
|
||||
policy alongside DSPy and GEPA. Where DSPy bootstraps few-shot
|
||||
examples and GEPA evolves prompt populations, ACE evolves a textual
|
||||
*playbook* of strategies the agent reads at inference time, updated
|
||||
by a Generator / Reflector / Curator triad. Pick via
|
||||
`[learning.agent] policy = "ace"`. Setup is manual (ACE isn't on
|
||||
PyPI and isn't a properly-packaged Python project as of v1.0.1) —
|
||||
see `docs/learning/ace.md` for the install path and trace-adapter
|
||||
behavior.
|
||||
|
||||
**`jarvis self-update`** subcommand. Detects how OpenJarvis was
|
||||
installed (pip, uv tool, editable git checkout) by inspecting
|
||||
`openjarvis.__file__`, then runs the right upgrade command. Supports
|
||||
`--check` (print the command without running) and `-y` (skip the
|
||||
confirmation prompt). The post-command "new version available" hint
|
||||
now points users at this command instead of guessing at the right
|
||||
flow.
|
||||
|
||||
**Desktop auto-update endpoint wired to the rolling
|
||||
`desktop-latest` GitHub release.** The Tauri updater plugin was
|
||||
configured on the build side (`createUpdaterArtifacts: true`,
|
||||
`includeUpdaterJson: true`, signing key in `TAURI_SIGNING_PRIVATE_KEY`)
|
||||
but inert on the runtime side (`active: false`, `endpoints: []`). The
|
||||
installed desktop app would never check. Both are now fixed; the app
|
||||
polls `releases/download/desktop-latest/latest.json` every 30 minutes
|
||||
and signature-verifies downloads against the minisign pubkey baked
|
||||
into the app. Full flow, key-rotation runbook, and dev escape hatch
|
||||
(`OPENJARVIS_NO_UPDATER=1`) documented in `docs/desktop-auto-update.md`.
|
||||
|
||||
**Analytics env-var opt-out** (`DO_NOT_TRACK`, `OPENJARVIS_NO_ANALYTICS`).
|
||||
Tanvir's analytics module (#351) only respected the
|
||||
`[analytics] enabled` config-file setting. Both env vars are now
|
||||
honored in `is_analytics_enabled()` and in the install.sh beacon
|
||||
script. Any truthy value (`1`, `true`, `yes`, `on`) disables for
|
||||
that process; env opt-out takes precedence over the config file.
|
||||
Documented under a new "Opting out" section in `docs/telemetry.md`.
|
||||
|
||||
### Changed
|
||||
|
||||
**Version-check trigger widened.** The "new version available" hint
|
||||
in `_version_check.py` used to fire only on `{ask, chat, serve}` and
|
||||
hardcoded the wrong upgrade command (`git pull && uv sync` — only
|
||||
correct for editable installs). Now fires on every interactive
|
||||
command (`doctor`, `init`, `quickstart`, `model`, `agents`, `skill`,
|
||||
`memory`, `bench`, `telemetry`, `config`, `eval`, `optimize`, plus
|
||||
the original three) and uses install-detection to print the right
|
||||
upgrade command. Honors `JARVIS_NO_UPDATE_CHECK=1` and `CI=true` to
|
||||
stay silent in automation.
|
||||
|
||||
**Desktop app version bumped 0.1.0 → 1.0.1** across
|
||||
`tauri.conf.json`, `frontend/package.json`, and
|
||||
`frontend/src-tauri/Cargo.toml` so the Python and desktop release
|
||||
streams are aligned and the auto-updater has a real version to
|
||||
compare against.
|
||||
|
||||
### Migration from 1.0.0
|
||||
|
||||
- **Importing `is_analytics_enabled`?** Same signature; behavior now
|
||||
short-circuits on env opt-out before checking the config. Callers
|
||||
that want the raw "is the config flag set" semantic should read
|
||||
`cfg.enabled` directly.
|
||||
- **Editable-git users running `jarvis self-update`** get the
|
||||
detected `git pull && uv sync` command pointed at their actual
|
||||
checkout, not `~/OpenJarvis`. If you'd come to rely on the
|
||||
hardcoded path, update your muscle memory.
|
||||
|
||||
## [1.0.0] - 2026-05-15
|
||||
|
||||
The five-primitive architecture (Intelligence, Engine, Agents,
|
||||
|
||||
-1
Submodule Inline deleted from 03673aaa42
@@ -8,16 +8,25 @@
|
||||
<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/)**
|
||||
>
|
||||
> **[Paper](https://arxiv.org/abs/2605.17172)**
|
||||
>
|
||||
> **[Leaderboard](https://open-jarvis.github.io/OpenJarvis/leaderboard/)**
|
||||
>
|
||||
> **[Roadmap](https://open-jarvis.github.io/OpenJarvis/development/roadmap/)**
|
||||
@@ -26,66 +35,48 @@
|
||||
|
||||
Personal AI agents are exploding in popularity, but nearly all of them still route intelligence through cloud APIs. Your "personal" AI continues to depend on someone else's server. At the same time, our [Intelligence Per Watt](https://www.intelligence-per-watt.ai/) research showed that local language models already handle 88.7% of single-turn chat and reasoning queries, with intelligence efficiency improving 5.3× from 2023 to 2025. The models and hardware are increasingly ready. What has been missing is the software stack to make local-first personal AI practical.
|
||||
|
||||
OpenJarvis is that stack. It is an opinionated framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
OpenJarvis is that stack. It is a framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
```
|
||||
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.
|
||||
|
||||
That's it. The installer handles everything: uv, the Python venv, Ollama, and pulling a small starter model. About 3 minutes on a typical broadband connection. Then:
|
||||
| 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) |
|
||||
|
||||
```bash
|
||||
jarvis
|
||||
```
|
||||
Then `jarvis` to start. The Rust extension and larger models continue downloading in the background; `jarvis doctor` shows status.
|
||||
|
||||
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.
|
||||
|
||||
**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://openjarvis.ai/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
|
||||
|
||||
@@ -112,6 +103,8 @@ See the [Skills User Guide](https://open-jarvis.github.io/OpenJarvis/user-guide/
|
||||
|
||||
### Built-in Agents
|
||||
|
||||
OpenJarvis ships with eight built-in agents across three execution modes (on-demand, scheduled, continuous):
|
||||
|
||||
| Agent | Type | What it does |
|
||||
|-------|------|-------------|
|
||||
| `morning_digest` | Scheduled | Daily briefing from email, calendar, health, news — with TTS audio |
|
||||
@@ -127,6 +120,13 @@ See the [User Guide](https://open-jarvis.github.io/OpenJarvis/user-guide/morning
|
||||
|
||||
Full documentation — including Docker deployment, cloud engines, development setup, and tutorials — at **[open-jarvis.github.io/OpenJarvis](https://open-jarvis.github.io/OpenJarvis/)**.
|
||||
|
||||
## Community
|
||||
|
||||
- **GitHub:** [github.com/open-jarvis/OpenJarvis](https://github.com/open-jarvis/OpenJarvis)
|
||||
- **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/)
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! See the [Contributing Guide](CONTRIBUTING.md) for incentives, contribution types, and the PR process.
|
||||
@@ -145,7 +145,7 @@ Browse the [Roadmap](https://open-jarvis.github.io/OpenJarvis/development/roadma
|
||||
|
||||
## About
|
||||
|
||||
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the efficiency of on-device AI systems. The project is developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
|
||||
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the intelligence efficiency of AI systems. The project is developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -161,11 +161,14 @@ OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.
|
||||
|
||||
## Citation
|
||||
```bibtex
|
||||
@misc{saadfalcon2026openjarvis,
|
||||
title={OpenJarvis: Personal AI, On Personal Devices},
|
||||
author={Jon Saad-Falcon and Avanika Narayan and Herumb Shandilya and Hakki Orhun Akengin and Robby Manihani and Gabriel Bo and John Hennessy and Christopher R\'{e} and Azalia Mirhoseini},
|
||||
year={2026},
|
||||
howpublished={\url{https://scalingintelligence.stanford.edu/blogs/openjarvis/}},
|
||||
@misc{saadfalcon2026openjarvispersonalaipersonal,
|
||||
title={OpenJarvis: Personal AI, On Personal Devices},
|
||||
author={Jon Saad-Falcon and Avanika Narayan and Robby Manihani and Tanvir Bhathal and Herumb Shandilya and Hakki Orhun Akengin and Gabriel Bo and Andrew Park and Matthew Hart and Caia Costello and Chuan Li and Christopher Ré and Azalia Mirhoseini},
|
||||
year={2026},
|
||||
eprint={2605.17172},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.LG},
|
||||
url={https://arxiv.org/abs/2605.17172},
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
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,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,5 +1,9 @@
|
||||
# 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
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
@@ -8,25 +12,48 @@ COPY frontend/ .
|
||||
RUN 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
|
||||
|
||||
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
|
||||
|
||||
# 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 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 .
|
||||
|
||||
# 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,5 +1,9 @@
|
||||
# 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
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
@@ -8,23 +12,31 @@ COPY frontend/ .
|
||||
RUN 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 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
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
|
||||
|
||||
COPY src/ src/
|
||||
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 .
|
||||
|
||||
# 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 +46,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,5 +1,9 @@
|
||||
# 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
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
@@ -8,23 +12,31 @@ COPY frontend/ .
|
||||
RUN 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 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
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
|
||||
|
||||
COPY src/ src/
|
||||
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 .
|
||||
|
||||
# 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 +46,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,42 @@
|
||||
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
|
||||
|
||||
# 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/*
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
COPY . .
|
||||
RUN pip install --no-cache-dir ".[server]"
|
||||
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps .
|
||||
|
||||
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>
|
||||
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# posthog-hetzner-prep.sh — one-shot Hetzner Cloud server prep for
|
||||
# OpenJarvis's self-hosted PostHog analytics backend.
|
||||
#
|
||||
# Run this on a fresh Ubuntu 22.04+ box (Hetzner CCX23 in Ashburn, or
|
||||
# similar) after pointing the desired domain at it. Idempotent: safe
|
||||
# to re-run if a step fails partway.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash posthog-hetzner-prep.sh analytics.openjarvis.ai you@openjarvis.ai
|
||||
#
|
||||
# After it finishes:
|
||||
# 1. Visit https://<DOMAIN>/ and create the admin account.
|
||||
# 2. Create project "OpenJarvis".
|
||||
# 3. Settings → Project → grab the Project API Key (phc_…).
|
||||
# 4. Update src/openjarvis/core/config.py AnalyticsConfig defaults:
|
||||
# host = "https://<DOMAIN>"
|
||||
# key = "phc_<new>"
|
||||
# 5. Ship a release. Frontend + backend + install.sh all read those
|
||||
# same defaults via load_config().
|
||||
#
|
||||
# Cost: ~$35/mo on Hetzner CCX23 (4 dedicated vCPU / 16 GB / 240 GB
|
||||
# NVMe) in US-East. Add Hetzner Cloud Backups (+20%) for production.
|
||||
#
|
||||
# Retention: post-install, set 365 days in PostHog UI →
|
||||
# Settings → Data Management → Event ingestion → Data retention.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- args ----
|
||||
if [[ $# -lt 2 ]]; then
|
||||
cat >&2 <<'USAGE'
|
||||
posthog-hetzner-prep.sh: missing arguments.
|
||||
|
||||
Usage:
|
||||
sudo bash posthog-hetzner-prep.sh <domain> <admin_email>
|
||||
|
||||
Examples:
|
||||
sudo bash posthog-hetzner-prep.sh analytics.openjarvis.ai team@openjarvis.ai
|
||||
|
||||
The domain must already resolve to this box (DNS A record) before
|
||||
the script runs — Let's Encrypt needs to reach this server on port 80.
|
||||
USAGE
|
||||
exit 2
|
||||
fi
|
||||
|
||||
DOMAIN="$1"
|
||||
ADMIN_EMAIL="$2"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "posthog-hetzner-prep.sh: must be run as root (use sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- step 1: system prep ----
|
||||
echo "[1/5] apt update + base packages..."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ufw ca-certificates gnupg \
|
||||
apt-transport-https software-properties-common
|
||||
|
||||
# ---- step 2: firewall ----
|
||||
echo "[2/5] firewall (UFW): 22/80/443 only..."
|
||||
ufw --force reset
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow 22/tcp
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw --force enable
|
||||
|
||||
# ---- step 3: swap (helps ClickHouse under load spikes) ----
|
||||
echo "[3/5] swap..."
|
||||
if [[ ! -f /swapfile ]]; then
|
||||
fallocate -l 4G /swapfile
|
||||
chmod 600 /swapfile
|
||||
mkswap /swapfile
|
||||
swapon /swapfile
|
||||
if ! grep -q "/swapfile" /etc/fstab; then
|
||||
echo "/swapfile none swap sw 0 0" >> /etc/fstab
|
||||
fi
|
||||
else
|
||||
echo " /swapfile already present"
|
||||
fi
|
||||
|
||||
# ---- step 4: docker ----
|
||||
echo "[4/5] docker..."
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
else
|
||||
echo " docker already installed"
|
||||
fi
|
||||
systemctl enable --now docker
|
||||
|
||||
# ---- step 5: posthog hobby deploy ----
|
||||
echo "[5/5] PostHog Hobby Deploy..."
|
||||
echo
|
||||
echo " Domain: $DOMAIN"
|
||||
echo " Admin email: $ADMIN_EMAIL"
|
||||
echo " DNS A record: verify it points to $(curl -fsS -m 5 https://api.ipify.org 2>/dev/null || echo "<this server>")"
|
||||
echo
|
||||
|
||||
# PostHog's official one-liner. It writes a .env file with random
|
||||
# secrets, configures Caddy with Let's Encrypt TLS for the domain,
|
||||
# and brings up the full stack via docker-compose.
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/deploy-hobby)" -s -- \
|
||||
--domain "$DOMAIN" \
|
||||
--email "$ADMIN_EMAIL" || {
|
||||
echo
|
||||
echo "PostHog deploy script exited with an error. Common causes:"
|
||||
echo " - DNS for $DOMAIN doesn't resolve to this server yet (wait + retry)"
|
||||
echo " - Port 80 not reachable from the public internet (firewall / cloud SG)"
|
||||
echo " - Out of disk on /var/lib/docker (need 20+ GB free)"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
cat <<EOF
|
||||
|
||||
================================================================
|
||||
PostHog is up at https://$DOMAIN/
|
||||
|
||||
Next steps:
|
||||
1. Open https://$DOMAIN/ in a browser.
|
||||
2. Create the first admin account (any email, your password).
|
||||
3. Create project "OpenJarvis".
|
||||
4. Settings → Project → Project API Key — copy the phc_… value.
|
||||
5. Update src/openjarvis/core/config.py AnalyticsConfig defaults:
|
||||
host = "https://$DOMAIN"
|
||||
key = "phc_<the-new-key>"
|
||||
6. Settings → Data Management → set retention to 365 days.
|
||||
7. Settings → Recordings → confirm Session Replay is OFF (default).
|
||||
8. Ship a release of OpenJarvis with the new config defaults.
|
||||
|
||||
Operational notes:
|
||||
- Updates: bash <(curl -fsSL https://raw.githubusercontent.com/posthog/posthog/HEAD/bin/upgrade-hobby)
|
||||
- Logs: docker compose -f /home/posthog/posthog/docker-compose.hobby.yml logs -f
|
||||
- Disk usage: df -h # bump VPS tier when /var/lib/docker > 70% full
|
||||
- Backups: enable Hetzner Cloud Backups in the Hetzner console
|
||||
================================================================
|
||||
EOF
|
||||
@@ -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` so the FastAPI server and speech backend 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
|
||||
```
|
||||
|
||||
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` so the FastAPI server and speech
|
||||
backend 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Running 'uv sync --extra desktop' in $srcDir (this can take a few minutes)..."
|
||||
Push-Location $srcDir
|
||||
try {
|
||||
& $uvExe sync --extra desktop
|
||||
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 }
|
||||
}
|
||||
@@ -116,7 +116,7 @@ All providers produce the same output format consumed by agents:
|
||||
| **LM Studio** | `lmstudio` | OpenAI-compatible | 1234 | No (GPU optional) | Desktop GUI, easy model management |
|
||||
| **Exo** | `exo` | OpenAI-compatible | 52415 | No (distributed) | Distributed inference across heterogeneous devices |
|
||||
| **Nexa** | `nexa` | OpenAI-compatible | 18181 | No (CPU/GPU) | On-device inference with GGUF models |
|
||||
| **Lemonade** | `lemonade` | OpenAI-compatible | 8000 | AMD GPU/NPU | AMD consumer GPUs (RDNA), Ryzen AI NPUs |
|
||||
| **Lemonade** | `lemonade` | OpenAI-compatible | 13305 | AMD GPU/NPU | AMD consumer GPUs (RDNA), Ryzen AI NPUs |
|
||||
| **Uzu** | `uzu` | OpenAI-compatible | 8000 | Varies | Uzu inference runtime |
|
||||
| **Apple FM** | `apple_fm` | OpenAI-compatible | 8079 | Apple Silicon | Apple Foundation Model on-device inference |
|
||||
| **LiteLLM** | `litellm` | OpenAI-compatible | — | No | Unified proxy to 100+ LLM providers |
|
||||
@@ -135,7 +135,7 @@ The Ollama backend communicates via Ollama's native HTTP API at `/api/chat` and
|
||||
|
||||
The vLLM backend uses the OpenAI-compatible `/v1/chat/completions` API. It is recommended for datacenter GPUs (NVIDIA A100, H100, L40, A10, A30 and AMD MI300, MI325, MI350, MI355).
|
||||
|
||||
- **Default host:** `http://localhost:8000`
|
||||
- **Default host:** `http://localhost:13305`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Tool fallback:** If the server returns HTTP 400 when tools are included, the engine automatically retries without tools
|
||||
|
||||
@@ -204,7 +204,7 @@ The Nexa backend connects to the Nexa SDK on-device inference server via a FastA
|
||||
|
||||
The Lemonade backend connects to the [Lemonade](https://lemonade-server.ai/) inference server, which is optimized for AMD consumer GPUs (RDNA architecture) and Ryzen AI Neural Processing Units (NPUs). It uses the OpenAI-compatible `/v1/chat/completions` API.
|
||||
|
||||
- **Default host:** `http://localhost:8000`
|
||||
- **Default host:** `http://localhost:13305`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Install:** Visit [lemonade-server.ai](https://lemonade-server.ai/) for platform-specific installation instructions
|
||||
- **Best for:** Ryzen AI GPUs and NPUs, and AMD-based desktop and laptop systems
|
||||
@@ -357,7 +357,7 @@ host = "http://localhost:30000"
|
||||
# binary_path = ""
|
||||
|
||||
# [engine.lemonade]
|
||||
# host = "http://localhost:8000"
|
||||
# host = "http://localhost:13305"
|
||||
```
|
||||
|
||||
The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settings:
|
||||
@@ -370,7 +370,7 @@ The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settin
|
||||
| `SGLangEngineConfig` | `host` | `http://localhost:30000` | SGLang server URL |
|
||||
| `LlamaCppEngineConfig` | `host` | `http://localhost:8080` | llama.cpp server URL |
|
||||
| `LlamaCppEngineConfig` | `binary_path` | `""` | Path to llama.cpp binary (for managed mode) |
|
||||
| `LemonadeEngineConfig` | `host` | `http://localhost:8000` | Lemonade server URL |
|
||||
| `LemonadeEngineConfig` | `host` | `http://localhost:13305` | Lemonade server URL |
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old flat field names `ollama_host`, `vllm_host`, `llamacpp_host`, `llamacpp_path`, `sglang_host`, and `lemonade_host` under `[engine]` are still accepted as backward-compatible properties on `EngineConfig`. New configurations should use the nested sub-section format.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Desktop auto-update
|
||||
|
||||
The OpenJarvis desktop app ships with [Tauri's updater
|
||||
plugin](https://v2.tauri.app/plugin/updater/), which checks for new
|
||||
versions on launch and every 30 minutes. When a newer signed build is
|
||||
available, the app prompts the user to download and install it.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
on launch / every 30 min
|
||||
│
|
||||
▼
|
||||
GET https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/latest.json
|
||||
│
|
||||
▼
|
||||
Parse manifest: { "version": "X.Y.Z", "platforms": { ... } }
|
||||
│
|
||||
▼
|
||||
If manifest.version > installed_version:
|
||||
download signed .dmg / .deb / .msi from manifest.platforms[target].url
|
||||
verify against the minisign pubkey baked into the app
|
||||
prompt user to install
|
||||
```
|
||||
|
||||
The frontend code lives in
|
||||
[`frontend/src/components/Desktop/UpdateChecker.tsx`](../frontend/src/components/Desktop/UpdateChecker.tsx);
|
||||
the Tauri wiring is in
|
||||
[`frontend/src-tauri/tauri.conf.json`](../frontend/src-tauri/tauri.conf.json)
|
||||
under `plugins.updater`.
|
||||
|
||||
## How releases reach the update endpoint
|
||||
|
||||
The `Desktop Build & Release` GitHub Action
|
||||
([`.github/workflows/desktop.yml`](../.github/workflows/desktop.yml))
|
||||
builds signed binaries plus a `latest.json` manifest with the
|
||||
`tauri-action` step (`includeUpdaterJson: true` generates the manifest
|
||||
automatically). Where it publishes depends on the trigger.
|
||||
|
||||
Three release streams exist:
|
||||
|
||||
- **`desktop-latest`** (stable auto-update channel): **this is the
|
||||
channel the installed app polls.** It is *not* built directly —
|
||||
instead, when a stable `desktop-vX.Y.Z` release is published, the
|
||||
`refresh-stable-channel` job copies that release's `latest.json`
|
||||
into `desktop-latest`. So the app is only ever offered vetted stable
|
||||
builds, and `latest.json` here points at the current `desktop-v*`
|
||||
assets.
|
||||
- **`desktop-vX.Y.Z`** (tagged stable): created when someone pushes a
|
||||
`desktop-v*` git tag. The user-facing stable release with full
|
||||
installers; also the source of truth the stable channel mirrors.
|
||||
- **`desktop-edge`** (rolling pre-release): rebuilt on every push to
|
||||
`main` (via the `autotag` → `desktop.yml` dispatch) and on manual
|
||||
`workflow_dispatch`. Carries the most recent CI build for testers.
|
||||
The shipped app does **not** poll this stream, so dev builds never
|
||||
auto-install onto stable users.
|
||||
|
||||
This split means security and telemetry-policy fixes reach users on
|
||||
the next **stable** `desktop-v*` tag — cut one to ship an update.
|
||||
Edge builds are available for anyone who wants to test `main` ahead of
|
||||
a stable tag, without risking the stable population.
|
||||
|
||||
## Signing
|
||||
|
||||
Binaries are signed by `tauri-action` using the minisign key pair
|
||||
referenced via these GitHub Actions secrets:
|
||||
|
||||
| Secret | Purpose |
|
||||
|---|---|
|
||||
| `TAURI_SIGNING_PRIVATE_KEY` | Private key (PEM-formatted minisign) |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Passphrase for the private key |
|
||||
|
||||
The matching public key is baked into the app at
|
||||
`tauri.conf.json:plugins.updater.pubkey`. If you ever need to rotate
|
||||
the key, replace the public key in the JSON file *and* update both
|
||||
secrets atomically — mismatched keys cause every update download to
|
||||
fail signature verification with no recovery path other than a manual
|
||||
reinstall.
|
||||
|
||||
## Disabling the updater locally
|
||||
|
||||
For frontend development, set `VITE_OPENJARVIS_NO_UPDATER=1` in your
|
||||
shell before running `npm run tauri dev`. Vite injects any
|
||||
`VITE_`-prefixed env var into `import.meta.env`, and the
|
||||
`UpdateChecker.tsx` component honors it to skip the 30-minute poll.
|
||||
|
||||
```bash
|
||||
export VITE_OPENJARVIS_NO_UPDATER=1
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
This is purely a dev escape hatch — it has no effect on production
|
||||
builds (where `import.meta.env.VITE_OPENJARVIS_NO_UPDATER` will be
|
||||
`undefined` unless you explicitly set it at build time).
|
||||
|
||||
## Verifying a release manually
|
||||
|
||||
```bash
|
||||
# Download the latest manifest and confirm it parses cleanly
|
||||
curl -fsSL https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/latest.json | jq .
|
||||
|
||||
# Fields:
|
||||
# version — semver string, must match the tag (without leading "v")
|
||||
# notes — release notes string
|
||||
# pub_date — RFC3339 timestamp
|
||||
# platforms — map keyed by "<target>-<arch>" e.g. "darwin-aarch64"
|
||||
# each entry has { signature: "...", url: "..." }
|
||||
```
|
||||
|
||||
A 404 on the manifest URL means the most recent desktop CI run
|
||||
didn't complete or didn't have signing secrets — check the
|
||||
`Desktop Build & Release` workflow logs.
|
||||
+7
-7
@@ -25,11 +25,11 @@ processing happens on your local machine — the app connects to the backend you
|
||||
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_aarch64.dmg) | M1/M2/M3/M4 Macs |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe) | Windows 10+ |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb) | Ubuntu, Debian |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm) | Fedora, RHEL |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.AppImage) | Any distro |
|
||||
| macOS (Universal) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_universal.dmg) | Apple Silicon + Intel |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe) | Windows 10+ |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.deb) | Ubuntu, Debian |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis-1.0.1-1.x86_64.rpm) | Fedora, RHEL |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.AppImage) | Any distro |
|
||||
|
||||
!!! tip "All releases"
|
||||
Browse all versions on the [GitHub Releases](https://github.com/open-jarvis/OpenJarvis/releases) page.
|
||||
@@ -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 ..
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""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)
|
||||
|
||||
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 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
|
||||
|
||||
# (source path, published URL path)
|
||||
_SCRIPTS = [
|
||||
(Path("scripts/install/install.sh"), "install.sh"),
|
||||
(Path("deploy/windows/install.ps1"), "install.ps1"),
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
# 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://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
|
||||
The installer downloads everything for you — including [uv](https://docs.astral.sh/uv/)
|
||||
(the Python package manager), the Python venv, Ollama, and a small starter
|
||||
model. **You don't need to install uv or any other prerequisite first.**
|
||||
|
||||
!!! info "Install URL"
|
||||
This script is served straight from the project's own GitHub Pages site,
|
||||
so HTTPS always works. You may also see `https://openjarvis.ai/install.sh`
|
||||
referenced in older docs — that domain is community-operated and has had
|
||||
intermittent TLS issues ([#337](https://github.com/open-jarvis/OpenJarvis/issues/337)).
|
||||
The `open-jarvis.github.io` URL above is the canonical one.
|
||||
|
||||
About 3 minutes on a typical broadband connection. Type `jarvis` to start chatting.
|
||||
|
||||
## What the installer does
|
||||
|
||||
@@ -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 ..
|
||||
```
|
||||
@@ -94,11 +94,11 @@ cd OpenJarvis
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_aarch64.dmg) |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe) |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb) |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm) |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.AppImage) |
|
||||
| macOS (Universal) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_universal.dmg) |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe) |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.deb) |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis-1.0.1-1.x86_64.rpm) |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.AppImage) |
|
||||
|
||||
The app connects to `http://localhost:8000` automatically.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Linux Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
|
||||
Tested on: Ubuntu 22.04 / 24.04, Fedora 40, Debian 12, Arch.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# macOS Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
|
||||
Works on Intel and Apple Silicon. The installer auto-detects your CPU/GPU.
|
||||
|
||||
@@ -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`.
|
||||
- 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`.
|
||||
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
|
||||
|
||||
@@ -15,7 +18,7 @@ Then open the Ubuntu (or Debian) shell that gets installed.
|
||||
## Install OpenJarvis
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openjarvis.ai/install.sh | bash
|
||||
curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
|
||||
```
|
||||
|
||||
About 3 minutes. Type `jarvis` to start.
|
||||
|
||||
+32
-13
@@ -14,13 +14,25 @@ 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?
|
||||
|
||||
Personal AI agents are exploding in popularity, but nearly all of them still route intelligence through cloud APIs. Your "personal" AI continues to depend on someone else's server. At the same time, our [Intelligence Per Watt](https://www.intelligence-per-watt.ai/) research showed that local language models already handle 88.7% of single-turn chat and reasoning queries, with intelligence efficiency improving 5.3× from 2023 to 2025. The models and hardware are increasingly ready. What has been missing is the software stack to make local-first personal AI practical.
|
||||
|
||||
OpenJarvis is that stack. It is an opinionated framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
OpenJarvis is that stack. It is a framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
|
||||
---
|
||||
|
||||
@@ -54,13 +66,15 @@ OpenJarvis is that stack. It is an opinionated framework for local-first persona
|
||||
|
||||
**Step 2.** Download and open the desktop app:
|
||||
|
||||
[Download for macOS](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_universal.dmg){ .md-button .md-button--primary }
|
||||
[Download for macOS](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_universal.dmg){ .md-button .md-button--primary }
|
||||
|
||||
Also available for [Windows](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe), [Linux (DEB)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb), and [Linux (RPM)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm). See the [Downloads](downloads.md) page for details.
|
||||
Also available for [Windows](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe), [Linux (DEB)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.deb), and [Linux (RPM)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis-1.0.1-1.x86_64.rpm). See the [Downloads](downloads.md) page for details.
|
||||
|
||||
The app connects to `http://localhost:8000` automatically.
|
||||
|
||||
!!! warning "macOS: run `xattr -cr /Applications/OpenJarvis.app` if the app shows as \"damaged\"."
|
||||
!!! warning "macOS first launch"
|
||||
|
||||
Run `xattr -cr /Applications/OpenJarvis.app` if the app shows as "damaged".
|
||||
|
||||
=== "Python SDK"
|
||||
|
||||
@@ -104,9 +118,9 @@ OpenJarvis is that stack. It is an opinionated framework for local-first persona
|
||||
OpenJarvis is built around five composable layers. Each has a clean interface and can be swapped independently.
|
||||
|
||||
1. **Intelligence** — Pick a model, or let OpenJarvis pick one for your hardware. Manages the full catalog of local models across providers.
|
||||
2. **Agents** — Multi-step reasoning with tool use. Seven built-in agent types from simple chat to orchestrated workflows.
|
||||
3. **Tools** — Web search, calculator, file I/O, code interpreter, retrieval, and any external MCP server.
|
||||
4. **Engine** — The inference runtime: [Ollama](https://ollama.com), [vLLM](https://github.com/vllm-project/vllm), [SGLang](https://github.com/sgl-project/sglang), [llama.cpp](https://github.com/ggerganov/llama.cpp), cloud APIs, and more. Auto-detects your hardware and recommends the best fit.
|
||||
2. **Engine** — The inference runtime: [Ollama](https://ollama.com), [vLLM](https://github.com/vllm-project/vllm), [SGLang](https://github.com/sgl-project/sglang), [llama.cpp](https://github.com/ggerganov/llama.cpp), cloud APIs, and more. Auto-detects your hardware and recommends the best fit.
|
||||
3. **Agents** — Multi-step reasoning with tool use. Eight built-in agent types from simple chat to orchestrated workflows.
|
||||
4. **Tools & Memory** — Web search, calculator, file I/O, code interpreter, retrieval, persistent local state, and any external MCP server.
|
||||
5. **Learning** — Your AI gets better over time. Every interaction generates traces that drive automatic improvements to model weights, prompts, and agent behavior.
|
||||
|
||||
---
|
||||
@@ -169,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)**
|
||||
|
||||
@@ -206,11 +220,14 @@ Read the [blog post](https://scalingintelligence.stanford.edu/blogs/openjarvis/)
|
||||
## Citation
|
||||
|
||||
```bibtex
|
||||
@misc{saadfalcon2026openjarvis,
|
||||
title={OpenJarvis: Personal AI, On Personal Devices},
|
||||
author={Jon Saad-Falcon and Avanika Narayan and Herumb Shandilya and Hakki Orhun Akengin and Robby Manihani and Gabriel Bo and John Hennessy and Christopher R\'{e} and Azalia Mirhoseini},
|
||||
year={2026},
|
||||
howpublished={\url{https://scalingintelligence.stanford.edu/blogs/openjarvis/}},
|
||||
@misc{saadfalcon2026openjarvispersonalaipersonal,
|
||||
title={OpenJarvis: Personal AI, On Personal Devices},
|
||||
author={Jon Saad-Falcon and Avanika Narayan and Robby Manihani and Tanvir Bhathal and Herumb Shandilya and Hakki Orhun Akengin and Gabriel Bo and Andrew Park and Matthew Hart and Caia Costello and Chuan Li and Christopher Ré and Azalia Mirhoseini},
|
||||
year={2026},
|
||||
eprint={2605.17172},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.LG},
|
||||
url={https://arxiv.org/abs/2605.17172},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -225,3 +242,5 @@ Read the [blog post](https://scalingintelligence.stanford.edu/blogs/openjarvis/)
|
||||
<a href="https://research.ibm.com/">IBM Research</a> •
|
||||
<a href="https://hai.stanford.edu/">Stanford HAI</a>
|
||||
</p>
|
||||
|
||||
Follow [@OpenJarvisAI](https://x.com/OpenJarvisAI) on X for updates.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# ACE optimizer (Agentic Context Engineering)
|
||||
|
||||
OpenJarvis supports [ACE](https://github.com/ace-agent/ace) as a third
|
||||
optimizer alongside DSPy and GEPA. Where DSPy bootstraps few-shot
|
||||
examples and GEPA evolves prompts via reflective mutation, **ACE
|
||||
evolves a textual *playbook*** — annotated natural-language strategies
|
||||
the agent reads at inference time. The playbook is updated by a
|
||||
Generator / Reflector / Curator triad of LLM calls.
|
||||
|
||||
## When to pick ACE
|
||||
|
||||
| Task shape | DSPy | GEPA | ACE |
|
||||
|---|---|---|---|
|
||||
| Single-turn QA with crisp metric | strong | strong | weaker |
|
||||
| Long-running agent that should accumulate guidance | weak | medium | strong |
|
||||
| Open-domain where strategies matter more than templates | weak | medium | strong |
|
||||
| When you want to *read* what the optimizer learned | medium | medium | strong |
|
||||
|
||||
ACE's headline artifact is `final_playbook.txt` — a human-readable
|
||||
file like:
|
||||
|
||||
```
|
||||
## STRATEGIES & INSIGHTS
|
||||
[str-00001] helpful=5 harmful=0 :: When the user asks for unit
|
||||
conversion, prefer the exact
|
||||
rational form before rounding.
|
||||
[str-00002] helpful=3 harmful=1 :: Cite a primary source before
|
||||
stating a date claim.
|
||||
```
|
||||
|
||||
If reading those strategies feels like the form of "what learning
|
||||
should produce" for your task, ACE is the right choice.
|
||||
|
||||
## Setup
|
||||
|
||||
ACE is **not on PyPI** as of OpenJarvis v1.0.1, and the upstream
|
||||
repository is structured as a research codebase (multiple top-level
|
||||
directories) rather than a Python package. There's no `learning-ace`
|
||||
extra for that reason. Install ACE manually instead:
|
||||
|
||||
```bash
|
||||
# 1. Clone ACE somewhere outside your OpenJarvis checkout
|
||||
git clone https://github.com/ace-agent/ace.git ~/code/ace
|
||||
cd ~/code/ace
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh # if you don't have uv
|
||||
uv sync
|
||||
|
||||
# 2. Make ACE's src/ importable from your OpenJarvis venv
|
||||
echo "$HOME/code/ace/src" > \
|
||||
"$(python -c 'import site; print(site.getsitepackages()[0])')/ace.pth"
|
||||
|
||||
# 3. Set the API key for whichever provider ACE will call
|
||||
cp ~/code/ace/.env.example ~/code/ace/.env
|
||||
# Edit ~/code/ace/.env to set API_KEY for your chosen provider.
|
||||
|
||||
# 4. Verify the import resolves from OpenJarvis's venv
|
||||
python -c "from openjarvis.learning.agents.ace_optimizer import HAS_ACE; print(HAS_ACE)"
|
||||
# True
|
||||
```
|
||||
|
||||
If `HAS_ACE` prints `False`, the `.pth` file isn't being picked up —
|
||||
verify the path matches `site.getsitepackages()[0]` for the same
|
||||
Python interpreter you're using to run OpenJarvis.
|
||||
|
||||
## Configuration
|
||||
|
||||
ACE is configured under `[learning.agent.ace]` in your OpenJarvis
|
||||
config TOML:
|
||||
|
||||
```toml
|
||||
[learning.agent]
|
||||
policy = "ace"
|
||||
|
||||
[learning.agent.ace]
|
||||
# ACE's three roles. Empty = inherit from the intelligence primitive's
|
||||
# default cloud model.
|
||||
generator_model = "claude-opus-4-7"
|
||||
reflector_model = "claude-opus-4-7"
|
||||
curator_model = "claude-sonnet-4-6"
|
||||
|
||||
api_provider = "openai" # sambanova | together | openai | commonstack
|
||||
|
||||
num_epochs = 1
|
||||
max_num_rounds = 3
|
||||
playbook_token_budget = 80000
|
||||
max_tokens = 4096
|
||||
|
||||
task_name = "openjarvis"
|
||||
save_dir = "" # default: ~/.openjarvis/learning/ace/<task>/
|
||||
|
||||
min_traces = 20
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
Once configured, the same orchestrator that runs DSPy / GEPA also runs
|
||||
ACE — pick it via the `policy` field above. To force a one-shot run:
|
||||
|
||||
```bash
|
||||
jarvis optimize agent --policy ace
|
||||
```
|
||||
|
||||
ACE writes intermediate state and the final playbook to `save_dir`.
|
||||
The OpenJarvis runtime will pick up the playbook on next agent start
|
||||
(via the same sidecar overlay mechanism the Skills System uses).
|
||||
|
||||
## Trace adapter behavior
|
||||
|
||||
OpenJarvis traces are adapted into ACE's `train_samples` /
|
||||
`val_samples` / `test_samples` format via a 70 / 15 / 15 split
|
||||
(order-preserving for reproducibility). Each trace becomes a
|
||||
`{question: trace.query, ground_truth_answer: trace.result}` sample.
|
||||
Traces with empty `query` or `result` are dropped before splitting.
|
||||
|
||||
The `DataProcessor` ACE expects is built from `_TraceDataProcessor`
|
||||
in `src/openjarvis/learning/agents/ace_optimizer.py` — it does a
|
||||
case-insensitive substring match for `answer_is_correct` and averages
|
||||
that for aggregate accuracy. If you're optimizing for a domain where
|
||||
substring matching is the wrong correctness signal (math problems,
|
||||
code, structured outputs), subclass `_TraceDataProcessor` and pass it
|
||||
through your own callsite to `ACEAgentOptimizer.optimize()`.
|
||||
|
||||
## Limitations in v1.0.1
|
||||
|
||||
- **No automatic install.** Document above is the only path.
|
||||
- **The trace adapter uses substring correctness.** Override for
|
||||
domain-specific scoring.
|
||||
- **Single provider per run.** ACE assigns the same `api_provider` to
|
||||
all three roles. To mix providers, run ACE outside OpenJarvis and
|
||||
hand-deliver the resulting playbook into `save_dir`.
|
||||
|
||||
These will get revisited once ACE publishes a PyPI package or stable
|
||||
provider interface — track
|
||||
[ace-agent/ace#issues](https://github.com/ace-agent/ace/issues) for
|
||||
upstream changes that would let us tighten the wrapper.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Telemetry
|
||||
|
||||
OpenJarvis ships **anonymous usage telemetry** by default so the team can
|
||||
see where the product breaks, what features people actually use, and
|
||||
how to make it better. This page documents exactly what is and isn't
|
||||
collected, where the data goes, and how to opt out.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **On by default**, anonymous, no chat content.
|
||||
- **Anonymous** — one random UUID per install, no email, no name, no IP.
|
||||
- **No chat content, ever.** Only counts, timings, and feature names.
|
||||
- **Self-hosted backend** on the OpenJarvis team's PostHog instance —
|
||||
data is not sold or shared with third parties.
|
||||
- **365-day retention**, after which events are deleted automatically.
|
||||
|
||||
## What we collect
|
||||
|
||||
### Lifecycle events
|
||||
|
||||
| Event | Source | Why we send it |
|
||||
|---|---|---|
|
||||
| `install_started` | `install.sh` | Top of install funnel |
|
||||
| `install_stage_completed` | `install.sh` | Per-stage timing — where do people drop off? |
|
||||
| `install_completed` | `install.sh` | Did the install succeed? |
|
||||
| `install_failed` | `install.sh` | Which stage failed, and on what OS |
|
||||
| `app_opened` | Backend + frontend | DAU / WAU / MAU |
|
||||
| `setup_completed` | Frontend | First-run wizard finished |
|
||||
| `first_chat_sent` | Backend | First-ever message — activation |
|
||||
| `uninstall_started` | `uninstall.sh` (if user runs it) | Churn signal |
|
||||
|
||||
### Usage events
|
||||
|
||||
| Event | Why we send it |
|
||||
|---|---|
|
||||
| `chat_session_ended` | Aggregated per-session: turn count, tokens, latency, tool count |
|
||||
| `tool_first_used` | Which built-in tools are actually adopted |
|
||||
| `model_changed` | How often users switch models |
|
||||
| `feature_used` | Which features get traffic, which don't |
|
||||
| `connector_auth_completed` | Which connectors people set up |
|
||||
| `error_shown_to_user` | User-visible error class (not stack trace) |
|
||||
| `feedback_submitted` | Was a rating given? Was a comment included? |
|
||||
| `settings_changed` | Which settings get toggled |
|
||||
| `usage_daily_summary` | Once-per-day aggregated counts |
|
||||
|
||||
The canonical, authoritative list with every property name and its
|
||||
type validator lives in
|
||||
[`src/openjarvis/analytics/events.py`](../src/openjarvis/analytics/events.py).
|
||||
That file is the only place new events can be added — PR review is
|
||||
the gate.
|
||||
|
||||
## What we never collect
|
||||
|
||||
Hard guardrails, enforced by code:
|
||||
|
||||
- **Chat content** — prompts, model outputs, system messages, tool args.
|
||||
- **File paths** — anything matching `~/`, `$HOME`, `/Users/<name>`, `/home/<name>`, `file://`.
|
||||
- **Emails, names, phone numbers, addresses.**
|
||||
- **IP addresses** (IPv4 + IPv6). PostHog's IP geo lookup is disabled server-side too.
|
||||
- **MAC addresses, hardware serials, drive UUIDs.**
|
||||
- **Stack traces** — only error class enums.
|
||||
- **API keys, OAuth tokens, JWTs, bearer tokens, password assignments** —
|
||||
matched and dropped at value level.
|
||||
- **Hostnames** that look personal (e.g. `alice-macbook.local`).
|
||||
- **Lists, dicts, sets** — composite values are never sent so PII can't
|
||||
smuggle through inside containers.
|
||||
|
||||
Two independent filters run before every event leaves the machine:
|
||||
|
||||
1. [`src/openjarvis/analytics/redaction.py`](../src/openjarvis/analytics/redaction.py) — value-level pattern matching (20+ regexes for PII).
|
||||
2. [`src/openjarvis/analytics/events.py`](../src/openjarvis/analytics/events.py) — structural allowlist (event name + property name + type validator).
|
||||
|
||||
Any failure at either layer → the event or property is silently
|
||||
dropped. Tests covering the patterns: [`tests/analytics/test_redaction.py`](../tests/analytics/test_redaction.py).
|
||||
|
||||
## Where the data goes
|
||||
|
||||
- **Today** (alpha): PostHog Cloud (US region) free tier. Disclosed
|
||||
here for transparency.
|
||||
- **Production target**: A self-hosted PostHog instance at
|
||||
`analytics.openjarvis.ai`, Hetzner US-East. Single-tenant, operated
|
||||
by the OpenJarvis team.
|
||||
- **Never** sold, shared with advertisers, or used for anything other
|
||||
than improving OpenJarvis.
|
||||
|
||||
## Retention
|
||||
|
||||
- Default retention: **365 days**, then events are deleted by PostHog
|
||||
automatically.
|
||||
- `jarvis analytics reset-id` lets you orphan all of your past events
|
||||
by generating a fresh anonymous ID for future events.
|
||||
|
||||
## How identity works
|
||||
|
||||
A single UUID v4 is generated on first install and stored at
|
||||
`~/.openjarvis/anon_id`. The install script, backend, and frontend all
|
||||
read the same file so events across the full lifecycle tie to one
|
||||
person — without us ever knowing who that person is.
|
||||
|
||||
Delete the file (`rm ~/.openjarvis/anon_id`) and a fresh UUID will be
|
||||
generated next time the app runs. The previous UUID and its events
|
||||
are then orphaned.
|
||||
|
||||
## For researchers and contributors
|
||||
|
||||
- **Adding an event**: edit `src/openjarvis/analytics/events.py`,
|
||||
declare the spec, then update this page. PR review enforces both.
|
||||
- **Adding a PII pattern**: edit `src/openjarvis/analytics/redaction.py`
|
||||
and add a test case in `tests/analytics/test_redaction.py`.
|
||||
- **Inspecting what your install sends**: run with
|
||||
`OPENJARVIS_LOG_LEVEL=DEBUG` and grep for `Analytics`. You'll see
|
||||
every event name and (redacted) property dict before it ships.
|
||||
|
||||
## Related
|
||||
|
||||
- Local telemetry (FLOPs, energy, latency stored in
|
||||
`~/.openjarvis/telemetry.db`) is a **separate** subsystem documented
|
||||
in [`src/openjarvis/telemetry/`](../src/openjarvis/telemetry/). It
|
||||
never leaves the machine and is controlled by `[telemetry]` (not
|
||||
`[analytics]`) in `config.toml`.
|
||||
- The leaderboard / contest opt-in (`OptInModal.tsx`) is a separate,
|
||||
voluntary feature that publicly shares your energy and savings on
|
||||
the OpenJarvis leaderboard. It is **not** the same as analytics and
|
||||
requires explicit opt-in with a display name and email.
|
||||
@@ -13,6 +13,7 @@ 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 |
|
||||
|
||||
@@ -383,6 +384,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"
|
||||
|
||||
@@ -148,6 +148,14 @@ jarvis skill sync openclaw --search "web3|crypto"
|
||||
jarvis skill install github:user/repo/path/to/skill --url https://github.com/user/repo
|
||||
```
|
||||
|
||||
For example, install the Hermes Tweet skill when you want an agent to search
|
||||
Twitter/X, read tweet replies, monitor tweets, export followers, and run
|
||||
gated post, reply, or DM workflows:
|
||||
|
||||
```bash
|
||||
jarvis skill install github:Xquik-dev/hermes-tweet/skills/hermes-tweet --url https://github.com/Xquik-dev/hermes-tweet
|
||||
```
|
||||
|
||||
### Config-Driven Auto Import
|
||||
|
||||
Add sources to `~/.openjarvis/config.toml` for automatic syncing:
|
||||
|
||||
Generated
+884
-4
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openjarvis-chat",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -11,7 +11,8 @@
|
||||
"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",
|
||||
@@ -30,6 +31,7 @@
|
||||
"katex": "^0.16.38",
|
||||
"lucide-react": "^0.576.0",
|
||||
"motion": "^12.38.0",
|
||||
"posthog-js": "^1.373.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -53,6 +55,7 @@
|
||||
"@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
+1222
-1010
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openjarvis-desktop"
|
||||
version = "0.1.0"
|
||||
version = "1.0.1"
|
||||
description = "OpenJarvis Desktop — Native AI assistant with energy monitoring, trace debugging, and learning visualization"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
@@ -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"]
|
||||
|
||||
+1456
-244
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenJarvis",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"identifier": "com.openjarvis.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -58,9 +58,11 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"active": false,
|
||||
"active": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFFNzUzMzhEOEY2MjNEMDMKUldRRFBXS1BqVE4xSG8vK0lkUWN4WnZQYVIrbmc4RmpoOGlJWTBLTE15RlIya3JvQisvdUR3a0QK",
|
||||
"endpoints": []
|
||||
"endpoints": [
|
||||
"https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/latest.json"
|
||||
]
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
|
||||
+43
-29
@@ -14,10 +14,21 @@ import { Toaster } from './components/ui/sonner';
|
||||
import { useAppStore } from './lib/store';
|
||||
import { fetchModels, fetchServerInfo, fetchSavings, submitSavings, isTauri } from './lib/api';
|
||||
import { OptInModal } from './components/OptInModal';
|
||||
import { UpdateChecker } from './components/Desktop/UpdateChecker';
|
||||
import { track, hashId } from './lib/analytics';
|
||||
|
||||
export default function App() {
|
||||
const [setupDone, setSetupDone] = useState(!isTauri());
|
||||
const handleSetupReady = useCallback(() => setSetupDone(true), []);
|
||||
const handleSetupReady = useCallback(() => {
|
||||
setSetupDone(true);
|
||||
// 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);
|
||||
const setModelsLoading = useAppStore((s) => s.setModelsLoading);
|
||||
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
|
||||
@@ -116,6 +127,36 @@ export default function App() {
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fire model_changed when the user switches models. First mount is
|
||||
// not a "change" — only emit when both prev and current are real and
|
||||
// differ.
|
||||
useEffect(() => {
|
||||
const prev = prevModelRef.current;
|
||||
const curr = selectedModel || '';
|
||||
prevModelRef.current = curr;
|
||||
if (!prev || !curr || prev === curr) return;
|
||||
void (async () => {
|
||||
const [fromHash, toHash] = await Promise.all([
|
||||
hashId(prev),
|
||||
hashId(curr),
|
||||
]);
|
||||
track('model_changed', {
|
||||
from_model_hash: fromHash,
|
||||
to_model_hash: toHash,
|
||||
});
|
||||
})();
|
||||
}, [selectedModel]);
|
||||
|
||||
// app_opened — one-shot per app launch, fires after analytics has had
|
||||
// a chance to initialize. platform + version are super-properties
|
||||
// registered in analytics.ts initAnalytics, so no per-call props needed.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
track('app_opened', {});
|
||||
}, 500);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
@@ -134,34 +175,6 @@ export default function App() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [commandPaletteOpen, setCommandPaletteOpen, toggleSystemPanel]);
|
||||
|
||||
// Desktop auto-update check — disabled during local development.
|
||||
// Re-enable for production releases by uncommenting below.
|
||||
// const updateChecked = useRef(false);
|
||||
// useEffect(() => {
|
||||
// if (!isTauri() || updateChecked.current) return;
|
||||
// updateChecked.current = true;
|
||||
// (async () => {
|
||||
// try {
|
||||
// const { check } = await import('@tauri-apps/plugin-updater');
|
||||
// const update = await check();
|
||||
// if (update) {
|
||||
// await update.downloadAndInstall();
|
||||
// const { toast } = await import('sonner');
|
||||
// toast.info('Update ready', {
|
||||
// description: 'A new version has been downloaded. Restart to apply.',
|
||||
// duration: Infinity,
|
||||
// action: {
|
||||
// label: 'Restart Now',
|
||||
// onClick: async () => {
|
||||
// const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
// await relaunch();
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// } catch {}
|
||||
// })();
|
||||
// }, []);
|
||||
|
||||
if (!setupDone) {
|
||||
return <SetupScreen onReady={handleSetupReady} />;
|
||||
@@ -169,6 +182,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<UpdateChecker />
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<ChatPage />} />
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Bell, CheckCircle, ChevronDown, ChevronUp, Clock, XCircle } from 'lucide-react';
|
||||
import { approveAction, denyAction, fetchPendingApprovals } from '../lib/api';
|
||||
import type { PendingApproval } from '../lib/api';
|
||||
|
||||
const TIER_STYLES: Record<string, { label: string; color: string; bg: string }> = {
|
||||
trivial: { label: 'Trivial', color: 'var(--color-text-secondary)', bg: 'color-mix(in srgb, var(--color-text-secondary) 10%, transparent)' },
|
||||
low: { label: 'Low', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' },
|
||||
medium: { label: 'Medium', color: 'var(--color-warning)', bg: 'color-mix(in srgb, var(--color-warning) 12%, transparent)' },
|
||||
high: { label: 'High', color: 'var(--color-error)', bg: 'color-mix(in srgb, var(--color-error) 12%, transparent)' },
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function ApprovalBell() {
|
||||
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [processing, setProcessing] = useState<Record<string, boolean>>({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setApprovals(await fetchPendingApprovals());
|
||||
} catch {
|
||||
// backend may not be running yet
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
setProcessing(p => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await approveAction(id);
|
||||
setApprovals(prev => prev.filter(a => a.id !== id));
|
||||
} finally {
|
||||
setProcessing(p => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async (id: string) => {
|
||||
setProcessing(p => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await denyAction(id);
|
||||
setApprovals(prev => prev.filter(a => a.id !== id));
|
||||
} finally {
|
||||
setProcessing(p => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const count = approvals.length;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fixed top-2 right-3 z-40">
|
||||
{/* Bell trigger */}
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="relative p-2 rounded-lg transition-colors cursor-pointer"
|
||||
title="Agent approvals"
|
||||
style={{
|
||||
color: count > 0 ? 'var(--color-text)' : 'var(--color-text-secondary)',
|
||||
background: open
|
||||
? 'var(--color-bg-tertiary)'
|
||||
: count > 0
|
||||
? 'color-mix(in srgb, var(--color-error) 8%, transparent)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Bell size={17} />
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 flex items-center justify-center rounded-full text-[10px] font-bold px-1 leading-none"
|
||||
style={{ background: 'var(--color-error)', color: '#fff' }}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-xl shadow-2xl overflow-hidden flex flex-col"
|
||||
style={{
|
||||
width: '340px',
|
||||
maxHeight: '500px',
|
||||
background: 'var(--color-bg-secondary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={13} style={{ color: 'var(--color-accent)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Agent Approvals
|
||||
</span>
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="text-[11px] font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||
color: 'var(--color-error)',
|
||||
}}
|
||||
>
|
||||
{count} pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{count === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<CheckCircle size={26} style={{ color: 'var(--color-text-secondary)', opacity: 0.35 }} />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
No pending approvals
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
approvals.map((action, idx) => {
|
||||
const tier = TIER_STYLES[action.tier] ?? TIER_STYLES.medium;
|
||||
const isExpanded = !!expanded[action.id];
|
||||
const isLoading = !!processing[action.id];
|
||||
const hasPayload = Object.keys(action.payload ?? {}).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="px-4 py-3"
|
||||
style={{
|
||||
borderBottom: idx < count - 1 ? '1px solid var(--color-border)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Row 1: action type + tier + time */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span
|
||||
className="text-[11px] font-mono font-semibold"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
>
|
||||
{action.action_type}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded"
|
||||
style={{ background: tier.bg, color: tier.color }}
|
||||
>
|
||||
{tier.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] flex items-center gap-0.5"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Clock size={9} />
|
||||
{timeAgo(action.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className="text-[13px] mb-2.5 leading-snug"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{action.description}
|
||||
</p>
|
||||
|
||||
{/* Expandable payload */}
|
||||
{hasPayload && (
|
||||
<button
|
||||
className="flex items-center gap-1 text-[11px] mb-2 cursor-pointer"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onClick={() =>
|
||||
setExpanded(e => ({ ...e, [action.id]: !e[action.id] }))
|
||||
}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
|
||||
{isExpanded ? 'Hide details' : 'View details'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<pre
|
||||
className="text-[10px] rounded-lg p-2.5 mb-2.5 overflow-x-auto"
|
||||
style={{
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(action.payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Approve / Deny */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleApprove(action.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs font-semibold transition-opacity cursor-pointer disabled:opacity-40"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
|
||||
color: 'var(--color-success)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-success) 22%, transparent)',
|
||||
}}
|
||||
>
|
||||
<CheckCircle size={12} />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeny(action.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs font-semibold transition-opacity cursor-pointer disabled:opacity-40"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||
color: 'var(--color-error)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-error) 22%, transparent)',
|
||||
}}
|
||||
>
|
||||
<XCircle size={12} />
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,14 +146,29 @@ export function ChatArea() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-[var(--chat-max-width)] mx-auto px-4 py-6">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{streamState.isStreaming && streamState.content === '' && (
|
||||
<div className="flex justify-start mb-4">
|
||||
<StreamingDots phase={streamState.phase} />
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => {
|
||||
const isLastAssistant =
|
||||
i === messages.length - 1 && msg.role === 'assistant';
|
||||
return (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLive={isLastAssistant && streamState.isStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
if (!streamState.isStreaming || streamState.content !== '') return null;
|
||||
// For research messages the ResearchTimeline handles its own
|
||||
// pre-content loading state — suppress the generic dots.
|
||||
const last = messages[messages.length - 1];
|
||||
if (last?.role === 'assistant' && last.isResearch) return null;
|
||||
return (
|
||||
<div className="flex justify-start mb-4">
|
||||
<StreamingDots phase={streamState.phase} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,78 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Send, Square, Paperclip } from 'lucide-react';
|
||||
import { Send, Square, Paperclip, Search } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore, generateId } from '../../lib/store';
|
||||
import { streamChat } from '../../lib/sse';
|
||||
import { streamChat, streamResearch } from '../../lib/sse';
|
||||
import { fetchSavings, getBase } from '../../lib/api';
|
||||
import { listConnectors, getSyncStatus } from '../../lib/connectors-api';
|
||||
import { MicButton } from './MicButton';
|
||||
import { useSpeech } from '../../hooks/useSpeech';
|
||||
import type { ChatMessage, ToolCallInfo, TokenUsage, MessageTelemetry } from '../../types';
|
||||
import type {
|
||||
ChatMessage,
|
||||
MessageTelemetry,
|
||||
ResearchSearchTrace,
|
||||
ResearchSource,
|
||||
TokenUsage,
|
||||
ToolCallInfo,
|
||||
} from '../../types';
|
||||
|
||||
// While Deep Research is toggled on, poll connected sources for sync
|
||||
// progress so we can surface "Searching over N items — sync in progress"
|
||||
// next to the toggle. Polling is gated on `enabled` so toggling DR off
|
||||
// stops the network chatter immediately.
|
||||
function useResearchCorpusSync(enabled: boolean): {
|
||||
syncing: boolean;
|
||||
itemsSynced: number;
|
||||
} {
|
||||
const [state, setState] = useState({ syncing: false, itemsSynced: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState({ syncing: false, itemsSynced: 0 });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const list = await listConnectors();
|
||||
const connected = list.filter((c) => c.connected);
|
||||
if (connected.length === 0) {
|
||||
if (!cancelled) setState({ syncing: false, itemsSynced: 0 });
|
||||
return;
|
||||
}
|
||||
const results = await Promise.all(
|
||||
connected.map(async (c) => {
|
||||
try {
|
||||
return await getSyncStatus(c.connector_id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
let syncing = false;
|
||||
let itemsSynced = 0;
|
||||
for (const r of results) {
|
||||
if (!r) continue;
|
||||
if (r.state === 'syncing') syncing = true;
|
||||
itemsSynced += r.items_synced ?? 0;
|
||||
}
|
||||
if (!cancelled) setState({ syncing, itemsSynced });
|
||||
} catch {
|
||||
// Network blip — leave previous state intact.
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function InputArea() {
|
||||
const [input, setInput] = useState('');
|
||||
@@ -26,8 +93,17 @@ export function InputArea() {
|
||||
const setStreamState = useAppStore((s) => s.setStreamState);
|
||||
const resetStream = useAppStore((s) => s.resetStream);
|
||||
const modelLoading = useAppStore((s) => s.modelLoading);
|
||||
const deepResearch = useAppStore((s) => s.deepResearch);
|
||||
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.
|
||||
@@ -52,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 {
|
||||
@@ -86,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('');
|
||||
|
||||
@@ -114,6 +200,7 @@ export function InputArea() {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
isResearch: deepResearch || undefined,
|
||||
};
|
||||
addMessage(convId, assistantMsg);
|
||||
|
||||
@@ -131,12 +218,16 @@ export function InputArea() {
|
||||
let usage: TokenUsage | undefined;
|
||||
let complexity: { score: number; tier: string; suggested_max_tokens: number } | undefined;
|
||||
const toolCalls: ToolCallInfo[] = [];
|
||||
const researchTraces: ResearchSearchTrace[] = [];
|
||||
const researchSourcesByRef = new Map<number, ResearchSource>();
|
||||
const flushSources = () =>
|
||||
Array.from(researchSourcesByRef.values()).sort((a, b) => a.ref - b.ref);
|
||||
let lastFlush = 0;
|
||||
let ttftMs: number | undefined;
|
||||
|
||||
setStreamState({
|
||||
isStreaming: true,
|
||||
phase: 'Generating...',
|
||||
phase: deepResearch ? 'Researching...' : 'Generating...',
|
||||
elapsedMs: 0,
|
||||
activeToolCalls: [],
|
||||
content: '',
|
||||
@@ -145,10 +236,133 @@ export function InputArea() {
|
||||
timestamp: Date.now(),
|
||||
level: 'info',
|
||||
category: 'chat',
|
||||
message: `Request: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}" → ${selectedModel}`,
|
||||
message: deepResearch
|
||||
? `Research: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}"`
|
||||
: `Request: "${content.slice(0, 80)}${content.length > 80 ? '...' : ''}" → ${selectedModel}`,
|
||||
});
|
||||
|
||||
try {
|
||||
if (deepResearch) {
|
||||
for await (const ev of streamResearch(content, controller.signal)) {
|
||||
if (ev.type === 'search_call') {
|
||||
const trace: ResearchSearchTrace = {
|
||||
id: generateId(),
|
||||
query: ev.arguments?.query ?? '',
|
||||
person: ev.arguments?.person,
|
||||
timeRange: ev.arguments?.time_range,
|
||||
status: 'pending',
|
||||
};
|
||||
researchTraces.push(trace);
|
||||
setStreamState({ phase: `Searching: ${trace.query}` });
|
||||
updateLastAssistant(
|
||||
convId,
|
||||
accumulatedContent,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[...researchTraces],
|
||||
flushSources(),
|
||||
);
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(),
|
||||
level: 'info',
|
||||
category: 'tool',
|
||||
message: `Search: "${trace.query}"${trace.person ? ` (person: ${trace.person})` : ''}`,
|
||||
});
|
||||
} else if (ev.type === 'search_result') {
|
||||
const pending = [...researchTraces].reverse().find((t) => t.status === 'pending');
|
||||
if (pending) {
|
||||
pending.status = 'complete';
|
||||
pending.numHits = ev.num_hits;
|
||||
pending.topTitles = ev.top_titles;
|
||||
}
|
||||
if (ev.sources) {
|
||||
for (const src of ev.sources) {
|
||||
if (src && typeof src.ref === 'number' && !researchSourcesByRef.has(src.ref)) {
|
||||
researchSourcesByRef.set(src.ref, src);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateLastAssistant(
|
||||
convId,
|
||||
accumulatedContent,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[...researchTraces],
|
||||
flushSources(),
|
||||
);
|
||||
} else if (ev.type === 'synthesis') {
|
||||
if (!ttftMs) ttftMs = Date.now() - startTime;
|
||||
accumulatedContent += ev.text;
|
||||
setStreamState({ content: accumulatedContent, phase: '' });
|
||||
const now = Date.now();
|
||||
if (now - lastFlush >= 80) {
|
||||
updateLastAssistant(
|
||||
convId,
|
||||
accumulatedContent,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[...researchTraces],
|
||||
flushSources(),
|
||||
);
|
||||
lastFlush = now;
|
||||
}
|
||||
} else if (ev.type === 'system_metrics') {
|
||||
// Live GPU sample — feed straight to the System panel so Power
|
||||
// (W) and Energy (kJ) tick up in real time as the agent runs.
|
||||
useAppStore.getState().setLiveEnergy({
|
||||
power_w: ev.power_w,
|
||||
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 = {
|
||||
prompt_tokens: ev.usage.prompt_tokens ?? 0,
|
||||
completion_tokens: ev.usage.completion_tokens ?? 0,
|
||||
total_tokens:
|
||||
ev.usage.total_tokens ??
|
||||
(ev.usage.prompt_tokens ?? 0) +
|
||||
(ev.usage.completion_tokens ?? 0),
|
||||
};
|
||||
// Optimistically roll this research turn into the session
|
||||
// counters so the Session panel updates the moment the
|
||||
// stream finishes, regardless of how /v1/savings aggregates
|
||||
// research telemetry server-side.
|
||||
useAppStore.getState().incrementSavings(usage);
|
||||
}
|
||||
// Hold the final live numbers visible for a beat so the panel
|
||||
// doesn't flash to 0 between the SSE close and the next
|
||||
// /v1/telemetry/energy poll picking up the persisted record.
|
||||
window.setTimeout(() => {
|
||||
useAppStore.getState().setLiveEnergy(null);
|
||||
}, 1500);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for await (const sseEvent of streamChat(
|
||||
{ model: selectedModel, messages: apiMessages, stream: true, temperature, max_tokens: maxTokens },
|
||||
controller.signal,
|
||||
@@ -225,6 +439,7 @@ export function InputArea() {
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
// User cancelled or model switch — keep whatever was accumulated
|
||||
@@ -238,6 +453,9 @@ export function InputArea() {
|
||||
message: `Stream error: ${errMsg}`,
|
||||
});
|
||||
}
|
||||
// If we tore out mid-research, make sure the live System panel
|
||||
// numbers don't get stuck on the last sample.
|
||||
useAppStore.getState().setLiveEnergy(null);
|
||||
} finally {
|
||||
if (!accumulatedContent) {
|
||||
accumulatedContent = 'No response was generated. Please try again.';
|
||||
@@ -278,6 +496,8 @@ export function InputArea() {
|
||||
usage,
|
||||
telemetry,
|
||||
audioMeta,
|
||||
researchTraces.length > 0 ? researchTraces : undefined,
|
||||
researchSourcesByRef.size > 0 ? flushSources() : undefined,
|
||||
);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
@@ -290,9 +510,15 @@ export function InputArea() {
|
||||
});
|
||||
abortRef.current = null;
|
||||
|
||||
fetchSavings()
|
||||
.then((data) => useAppStore.getState().setSavings(data))
|
||||
.catch(() => {});
|
||||
// Research path updates session counters optimistically from the
|
||||
// `done` event's usage payload — re-fetching here would overwrite
|
||||
// it with a potentially stale snapshot if the server's research
|
||||
// telemetry hasn't been merged into /v1/savings yet.
|
||||
if (!deepResearch) {
|
||||
fetchSavings()
|
||||
.then((data) => useAppStore.getState().setSavings(data))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
input,
|
||||
@@ -304,6 +530,9 @@ export function InputArea() {
|
||||
updateLastAssistant,
|
||||
setStreamState,
|
||||
resetStream,
|
||||
deepResearch,
|
||||
temperature,
|
||||
maxTokens,
|
||||
]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -315,6 +544,38 @@ export function InputArea() {
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-2" style={{ maxWidth: 'var(--chat-max-width)', margin: '0 auto', width: '100%' }}>
|
||||
<div className="mb-2 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeepResearch(!deepResearch)}
|
||||
disabled={streamState.isStreaming}
|
||||
aria-pressed={deepResearch}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50"
|
||||
style={{
|
||||
background: deepResearch ? 'var(--color-accent-subtle)' : 'transparent',
|
||||
border: `1px solid ${deepResearch ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
color: deepResearch ? 'var(--color-accent)' : 'var(--color-text-tertiary)',
|
||||
}}
|
||||
title={deepResearch ? 'Deep Research: on' : 'Deep Research: off'}
|
||||
>
|
||||
<Search size={12} />
|
||||
Deep Research
|
||||
</button>
|
||||
</div>
|
||||
{deepResearch && corpusSync.syncing && corpusSync.itemsSynced > 0 && (
|
||||
<div
|
||||
className="text-[11px] leading-snug"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
Searching over{' '}
|
||||
<span key={corpusSync.itemsSynced} className="sync-bump" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{corpusSync.itemsSynced.toLocaleString()}
|
||||
</span>{' '}
|
||||
items — sync in progress, results will improve as more data is indexed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-2xl px-4 py-3 transition-shadow"
|
||||
style={{
|
||||
@@ -328,7 +589,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' }}
|
||||
@@ -353,13 +614,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>
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'katex/dist/katex.min.css';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { AudioPlayer } from './AudioPlayer';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { ResearchTimeline } from './ResearchTimeline';
|
||||
import { rehypeCitations } from '../../lib/rehype-citations';
|
||||
import { XRayFooter } from './XRayFooter';
|
||||
import type { ChatMessage } from '../../types';
|
||||
|
||||
@@ -19,6 +21,7 @@ function stripThinkTags(text: string): string {
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage;
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
function getTextContent(node: any): string {
|
||||
@@ -97,7 +100,7 @@ function CopyMessageButton({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: Props) {
|
||||
export function MessageBubble({ message, isLive = false }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
if (isUser) {
|
||||
@@ -121,8 +124,33 @@ export function MessageBubble({ message }: Props) {
|
||||
|
||||
const cleanContent = useMemo(() => stripThinkTags(message.content), [message.content]);
|
||||
|
||||
// Build a ref→source lookup once per render. Memoized so the rehype plugin
|
||||
// identity stays stable until the source list actually changes.
|
||||
const sourcesMap = useMemo(() => {
|
||||
const m = new Map<number, NonNullable<ChatMessage['researchSources']>[number]>();
|
||||
for (const s of message.researchSources ?? []) {
|
||||
if (typeof s.ref === 'number') m.set(s.ref, s);
|
||||
}
|
||||
return m;
|
||||
}, [message.researchSources]);
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const base: any[] = [[rehypeHighlight, { detect: true }], rehypeKatex];
|
||||
if (sourcesMap.size > 0) base.push([rehypeCitations, { sources: sourcesMap }]);
|
||||
return base;
|
||||
}, [sourcesMap]);
|
||||
|
||||
return (
|
||||
<div className="group mb-6">
|
||||
{/* Deep Research timeline (steps + status) */}
|
||||
{(message.isResearch || (message.researchTraces && message.researchTraces.length > 0)) && (
|
||||
<ResearchTimeline
|
||||
traces={message.researchTraces ?? []}
|
||||
isLive={isLive}
|
||||
hasContent={cleanContent.length > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="mb-3 flex flex-col gap-2">
|
||||
@@ -140,7 +168,7 @@ export function MessageBubble({ message }: Props) {
|
||||
<div className="prose max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true }], rehypeKatex]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={{
|
||||
pre: CodeBlockPre,
|
||||
}}
|
||||
@@ -154,7 +182,11 @@ export function MessageBubble({ message }: Props) {
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<CopyMessageButton content={cleanContent} />
|
||||
</div>
|
||||
<XRayFooter usage={message.usage} telemetry={message.telemetry} />
|
||||
<XRayFooter
|
||||
usage={message.usage}
|
||||
telemetry={message.telemetry}
|
||||
isResearch={message.isResearch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { ResearchSearchTrace, TimeRange } from '../../types';
|
||||
|
||||
interface Props {
|
||||
traces: ResearchSearchTrace[];
|
||||
isLive: boolean;
|
||||
hasContent: boolean;
|
||||
}
|
||||
|
||||
const SHORT_DATE = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
function fmtDate(iso: string): string | null {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return SHORT_DATE.format(d);
|
||||
}
|
||||
|
||||
function formatTimeRange(tr: TimeRange | string | undefined): string | null {
|
||||
if (!tr) return null;
|
||||
if (typeof tr === 'string') return tr.trim() || null;
|
||||
const start = tr.start ? fmtDate(tr.start) : null;
|
||||
const end = tr.end ? fmtDate(tr.end) : null;
|
||||
if (start && end) return start === end ? start : `${start} – ${end}`;
|
||||
if (start) return `after ${start}`;
|
||||
if (end) return `before ${end}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function summarizeTraces(traces: ResearchSearchTrace[]): string {
|
||||
const n = traces.length;
|
||||
const hits = traces.reduce((s, t) => s + (t.numHits ?? 0), 0);
|
||||
const searchLabel = `${n} ${n === 1 ? 'search' : 'searches'}`;
|
||||
if (hits === 0) return searchLabel;
|
||||
return `${searchLabel} · ${hits} ${hits === 1 ? 'result' : 'results'}`;
|
||||
}
|
||||
|
||||
function StatusLine({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="text-xs leading-relaxed animate-pulse"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{text}…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineStep({
|
||||
index,
|
||||
trace,
|
||||
isLive,
|
||||
}: {
|
||||
index: number;
|
||||
trace: ResearchSearchTrace;
|
||||
isLive: boolean;
|
||||
}) {
|
||||
const pending = trace.status === 'pending';
|
||||
const active = pending && isLive;
|
||||
const meta: string[] = [];
|
||||
if (trace.person) meta.push(`person: ${trace.person}`);
|
||||
const formattedTime = formatTimeRange(trace.timeRange);
|
||||
if (formattedTime) meta.push(`time: ${formattedTime}`);
|
||||
|
||||
return (
|
||||
<div className="relative pl-4">
|
||||
{/* Step indicator dot, sitting on the vertical rail */}
|
||||
<div
|
||||
className="absolute left-0 top-[7px] w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background: active
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--color-text-tertiary)',
|
||||
opacity: active ? 1 : 0.6,
|
||||
transform: 'translateX(-3px)',
|
||||
boxShadow: active ? '0 0 0 3px var(--color-accent-subtle)' : 'none',
|
||||
transition: 'background 200ms, box-shadow 200ms',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="text-[10px] uppercase tracking-[0.08em] mb-0.5"
|
||||
style={{
|
||||
color: active ? 'var(--color-accent)' : 'var(--color-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
Search {index}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--color-text)', fontWeight: 450 }}
|
||||
>
|
||||
“{trace.query}”
|
||||
</div>
|
||||
|
||||
{meta.length > 0 && (
|
||||
<div
|
||||
className="text-[11px] mt-0.5"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{meta.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active ? (
|
||||
<div
|
||||
className="mt-2 h-px overflow-hidden relative"
|
||||
style={{ background: 'var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className="research-shimmer absolute inset-y-0 w-1/4"
|
||||
style={{ background: 'var(--color-accent)' }}
|
||||
/>
|
||||
</div>
|
||||
) : trace.numHits != null ? (
|
||||
<div
|
||||
className="text-[11px] mt-1"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{trace.numHits} {trace.numHits === 1 ? 'result' : 'results'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{trace.topTitles && trace.topTitles.length > 0 && (
|
||||
<div
|
||||
className="text-[11px] mt-0.5 truncate"
|
||||
style={{ color: 'var(--color-text-tertiary)', opacity: 0.75 }}
|
||||
>
|
||||
{trace.topTitles.slice(0, 2).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResearchTimeline({ traces, isLive, hasContent }: Props) {
|
||||
const showAnalyzing = isLive && traces.length === 0 && !hasContent;
|
||||
const allComplete =
|
||||
traces.length > 0 && traces.every((t) => t.status === 'complete');
|
||||
const showSynthesizing = isLive && allComplete && !hasContent;
|
||||
|
||||
// Auto-collapse the timeline the moment synthesis text begins streaming.
|
||||
// Subsequent user toggles win — we only fire the auto-collapse once per
|
||||
// false→true transition of hasContent.
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const prevHasContent = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasContent && !prevHasContent.current) {
|
||||
setExpanded(false);
|
||||
}
|
||||
prevHasContent.current = hasContent;
|
||||
}, [hasContent]);
|
||||
|
||||
if (!showAnalyzing && traces.length === 0) return null;
|
||||
|
||||
// No collapse affordance before any traces have arrived — just the
|
||||
// analyzing status sitting alone.
|
||||
if (traces.length === 0) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="relative pl-4">
|
||||
<div
|
||||
className="absolute left-0 top-[7px] w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background: 'var(--color-accent)',
|
||||
transform: 'translateX(-3px)',
|
||||
boxShadow: '0 0 0 3px var(--color-accent-subtle)',
|
||||
}}
|
||||
/>
|
||||
<StatusLine text="Analyzing query" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = summarizeTraces(traces);
|
||||
const Chevron = expanded ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex items-center gap-2 text-[11px] mb-2 cursor-pointer transition-colors"
|
||||
style={{
|
||||
color: 'var(--color-text-tertiary)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
}}
|
||||
title={expanded ? 'Collapse search trace' : 'Expand search trace'}
|
||||
>
|
||||
<span>{summary}</span>
|
||||
<Chevron size={12} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="relative">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute top-1 bottom-1 left-0 w-px"
|
||||
style={{ background: 'var(--color-border)' }}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
{traces.map((t, i) => (
|
||||
<TimelineStep
|
||||
key={t.id}
|
||||
index={i + 1}
|
||||
trace={t}
|
||||
isLive={isLive}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showSynthesizing && (
|
||||
<div className="relative pl-4">
|
||||
<div
|
||||
className="absolute left-0 top-[7px] w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background: 'var(--color-accent)',
|
||||
transform: 'translateX(-3px)',
|
||||
boxShadow: '0 0 0 3px var(--color-accent-subtle)',
|
||||
}}
|
||||
/>
|
||||
<StatusLine text="Synthesizing findings" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export function SystemPanel() {
|
||||
const toggleSystemPanel = useAppStore((s) => s.toggleSystemPanel);
|
||||
const optInEnabled = useAppStore((s) => s.optInEnabled);
|
||||
const setOptInModalOpen = useAppStore((s) => s.setOptInModalOpen);
|
||||
const liveEnergy = useAppStore((s) => s.liveEnergy);
|
||||
const [energy, setEnergy] = useState<EnergyData | null>(null);
|
||||
const [telemetry, setTelemetry] = useState<TelemetryStats | null>(null);
|
||||
|
||||
@@ -129,13 +130,15 @@ export function SystemPanel() {
|
||||
<MiniStat
|
||||
icon={Zap}
|
||||
label="Power"
|
||||
value={(energy?.avg_power_w ?? 0).toFixed(1)}
|
||||
value={(liveEnergy?.power_w ?? energy?.avg_power_w ?? 0).toFixed(1)}
|
||||
unit="W"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={Activity}
|
||||
label="Energy"
|
||||
value={((energy?.total_energy_j ?? 0) / 1000).toFixed(1)}
|
||||
value={(
|
||||
((liveEnergy?.energy_j ?? energy?.total_energy_j ?? 0) / 1000)
|
||||
).toFixed(1)}
|
||||
unit="kJ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,26 @@ import type { TokenUsage, MessageTelemetry } from '../../types';
|
||||
interface Props {
|
||||
usage?: TokenUsage;
|
||||
telemetry?: MessageTelemetry;
|
||||
isResearch?: boolean;
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function XRayFooter({ usage, telemetry }: Props) {
|
||||
export function XRayFooter({ usage, telemetry, isResearch = false }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Build collapsed summary parts
|
||||
// Build collapsed summary parts. For Deep Research responses we hide the
|
||||
// chat-side engine/model (which doesn't reflect the planner that actually
|
||||
// produced the answer) and label the response with the mode instead.
|
||||
const parts: string[] = [];
|
||||
if (telemetry?.engine) parts.push(telemetry.engine);
|
||||
if (telemetry?.model_id) parts.push(telemetry.model_id);
|
||||
if (isResearch) {
|
||||
parts.push('Deep Research');
|
||||
} else {
|
||||
if (telemetry?.engine) parts.push(telemetry.engine);
|
||||
if (telemetry?.model_id) parts.push(telemetry.model_id);
|
||||
}
|
||||
if (telemetry?.complexity_tier) parts.push(telemetry.complexity_tier);
|
||||
if (telemetry?.total_ms) parts.push(formatMs(telemetry.total_ms));
|
||||
if (usage && (usage.prompt_tokens || usage.completion_tokens)) {
|
||||
@@ -32,7 +39,9 @@ export function XRayFooter({ usage, telemetry }: Props) {
|
||||
|
||||
// Build expanded rows
|
||||
const rows: Array<{ label: string; value: string; color?: string }> = [];
|
||||
if (telemetry?.engine) {
|
||||
if (isResearch) {
|
||||
rows.push({ label: 'Mode', value: 'Deep Research' });
|
||||
} else if (telemetry?.engine) {
|
||||
const modelDetail = telemetry.model_id || '';
|
||||
rows.push({ label: 'Engine', value: `${telemetry.engine}${modelDetail ? ` (${modelDetail})` : ''}` });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Search, Cpu, X, Download, Loader2, Trash2, Check, Cloud, Key, Eye, EyeOff } from 'lucide-react';
|
||||
import { useAppStore } from '../lib/store';
|
||||
import { pullModel, deleteModel, fetchModels, preloadModel, isTauri } from '../lib/api';
|
||||
import {
|
||||
pullModel,
|
||||
deleteModel,
|
||||
fetchModels,
|
||||
preloadModel,
|
||||
isTauri,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
} from '../lib/api';
|
||||
|
||||
/** Popular models that users can download from the catalogue. */
|
||||
const CATALOGUE_MODELS = [
|
||||
@@ -23,7 +31,6 @@ const CATALOGUE_MODELS = [
|
||||
interface CloudProvider {
|
||||
name: string;
|
||||
envKey: string;
|
||||
storageKey: string;
|
||||
models: Array<{ id: string; desc: string }>;
|
||||
}
|
||||
|
||||
@@ -31,7 +38,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
storageKey: 'openjarvis-openai-key',
|
||||
models: [
|
||||
{ id: 'gpt-4o', desc: 'GPT-4o — fast, multimodal' },
|
||||
{ id: 'gpt-4o-mini', desc: 'GPT-4o Mini — cheap, fast' },
|
||||
@@ -41,7 +47,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'Anthropic',
|
||||
envKey: 'ANTHROPIC_API_KEY',
|
||||
storageKey: 'openjarvis-anthropic-key',
|
||||
models: [
|
||||
{ id: 'claude-sonnet-4-6', desc: 'Claude Sonnet 4.6 — balanced' },
|
||||
{ id: 'claude-opus-4-6', desc: 'Claude Opus 4.6 — most capable' },
|
||||
@@ -51,7 +56,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'Google',
|
||||
envKey: 'GEMINI_API_KEY',
|
||||
storageKey: 'openjarvis-gemini-key',
|
||||
models: [
|
||||
{ id: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro — flagship' },
|
||||
{ id: 'gemini-2.5-flash', desc: 'Gemini 2.5 Flash — fast' },
|
||||
@@ -61,7 +65,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
envKey: 'OPENROUTER_API_KEY',
|
||||
storageKey: 'openjarvis-openrouter-key',
|
||||
models: [
|
||||
{ id: 'openrouter/auto', desc: 'Auto — best model for the task' },
|
||||
{ id: 'openrouter/anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 via OpenRouter' },
|
||||
@@ -70,16 +73,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function getStoredKey(storageKey: string): string {
|
||||
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
|
||||
}
|
||||
function setStoredKey(storageKey: string, value: string): void {
|
||||
try {
|
||||
if (value) localStorage.setItem(storageKey, value);
|
||||
else localStorage.removeItem(storageKey);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
type Tab = 'installed' | 'catalogue' | 'cloud';
|
||||
|
||||
export function CommandPalette() {
|
||||
@@ -92,11 +85,10 @@ export function CommandPalette() {
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
|
||||
const keys: Record<string, string> = {};
|
||||
for (const p of CLOUD_PROVIDERS) keys[p.storageKey] = getStoredKey(p.storageKey);
|
||||
return keys;
|
||||
});
|
||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||
const [cloudKeyStatus, setCloudKeyStatus] = useState<Record<string, boolean>>({});
|
||||
const [cloudKeyError, setCloudKeyError] = useState<string | null>(null);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const models = useAppStore((s) => s.models);
|
||||
@@ -106,6 +98,20 @@ export function CommandPalette() {
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
|
||||
const installedIds = new Set(models.map((m) => m.id));
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refreshCloudKeyStatus = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setCloudKeyStatus({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCloudKeyStatus(await getCloudKeyStatus());
|
||||
setCloudKeyError(null);
|
||||
} catch (e: any) {
|
||||
setCloudKeyError(e?.message || 'Failed to read cloud key status');
|
||||
}
|
||||
}, [desktopKeyStorage]);
|
||||
|
||||
const filtered = tab === 'installed'
|
||||
? (query
|
||||
@@ -122,6 +128,10 @@ export function CommandPalette() {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCloudKeyStatus();
|
||||
}, [refreshCloudKeyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIdx(0);
|
||||
}, [query, tab]);
|
||||
@@ -210,24 +220,29 @@ export function CommandPalette() {
|
||||
};
|
||||
|
||||
const handleSaveKey = async (provider: CloudProvider, value: string) => {
|
||||
setStoredKey(provider.storageKey, value);
|
||||
setApiKeys((prev) => ({ ...prev, [provider.storageKey]: value }));
|
||||
const keyValue = value.trim();
|
||||
setSavingKey(provider.envKey);
|
||||
setCloudKeyError(null);
|
||||
|
||||
// Also save to Tauri backend so the server process picks up the key
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('save_cloud_key', { keyName: provider.envKey, keyValue: value });
|
||||
} catch {}
|
||||
try {
|
||||
await saveCloudKey(provider.envKey, keyValue);
|
||||
setApiKeys((prev) => ({ ...prev, [provider.envKey]: '' }));
|
||||
await refreshCloudKeyStatus();
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'model',
|
||||
message: `${provider.name} API key ${keyValue ? 'saved' : 'removed'}. Refreshing model list...`,
|
||||
});
|
||||
await refreshModels();
|
||||
} catch (e: any) {
|
||||
setCloudKeyError(e?.message || `Failed to save ${provider.name} API key`);
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'model',
|
||||
message: `${provider.name} API key ${value ? 'saved' : 'removed'}. Refreshing model list…`,
|
||||
});
|
||||
|
||||
// Refresh the model list so cloud models appear immediately.
|
||||
await refreshModels();
|
||||
const handleKeyBlur = (provider: CloudProvider) => {
|
||||
const draft = apiKeys[provider.envKey] || '';
|
||||
if (draft.trim()) void handleSaveKey(provider, draft);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -323,6 +338,11 @@ export function CommandPalette() {
|
||||
<Check size={12} /> Downloaded {pullSuccess} successfully
|
||||
</div>
|
||||
)}
|
||||
{tab === 'cloud' && cloudKeyError && (
|
||||
<div className="px-4 py-2 text-xs" style={{ color: 'var(--color-error)', background: 'rgba(220,38,38,0.05)' }}>
|
||||
{cloudKeyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[400px] overflow-y-auto py-2">
|
||||
@@ -431,13 +451,17 @@ export function CommandPalette() {
|
||||
/* ── Cloud Models tab ── */
|
||||
<div className="px-4 py-2">
|
||||
<div className="text-[11px] mb-3" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
Add your API keys to use cloud models. Keys are stored locally on your device only.
|
||||
{desktopKeyStorage
|
||||
? 'Add your API keys to use cloud models. Keys are stored in secure desktop storage.'
|
||||
: 'Configure cloud provider keys in the server environment to use cloud models.'}
|
||||
</div>
|
||||
|
||||
{CLOUD_PROVIDERS.map((provider) => {
|
||||
const key = apiKeys[provider.storageKey] || '';
|
||||
const hasKey = !!key;
|
||||
const isVisible = showKeys[provider.storageKey];
|
||||
const key = apiKeys[provider.envKey] || '';
|
||||
const hasSavedKey = !!cloudKeyStatus[provider.envKey];
|
||||
const hasKey = hasSavedKey || !!key.trim();
|
||||
const isVisible = showKeys[provider.envKey];
|
||||
const isSaving = savingKey === provider.envKey;
|
||||
|
||||
return (
|
||||
<div key={provider.name} className="mb-4">
|
||||
@@ -458,26 +482,28 @@ export function CommandPalette() {
|
||||
<input
|
||||
type={isVisible ? 'text' : 'password'}
|
||||
value={key}
|
||||
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.storageKey]: e.target.value }))}
|
||||
onBlur={() => handleSaveKey(provider, apiKeys[provider.storageKey] || '')}
|
||||
placeholder={`${provider.envKey}`}
|
||||
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.envKey]: e.target.value }))}
|
||||
onBlur={() => handleKeyBlur(provider)}
|
||||
placeholder={hasSavedKey ? 'Saved in secure storage' : provider.envKey}
|
||||
disabled={!desktopKeyStorage || isSaving}
|
||||
className="flex-1 text-xs px-2 py-1.5 bg-transparent outline-none font-mono"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.storageKey]: !prev[provider.storageKey] }))}
|
||||
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.envKey]: !prev[provider.envKey] }))}
|
||||
className="px-2 cursor-pointer" style={{ color: 'var(--color-text-tertiary)' }}
|
||||
>
|
||||
{isVisible ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
{hasKey && (
|
||||
{hasSavedKey && (
|
||||
<button
|
||||
onClick={() => handleSaveKey(provider, '')}
|
||||
disabled={isSaving}
|
||||
className="px-2 py-1 rounded-lg text-[10px] cursor-pointer"
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)', opacity: isSaving ? 0.5 : 1 }}
|
||||
>
|
||||
Remove
|
||||
{isSaving ? 'Saving' : 'Remove'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { LEADERBOARD_ENABLED, SUPABASE_ANON_KEY, SUPABASE_URL } from '../../lib/supabase';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -279,9 +280,6 @@ function getOrCreateAnonId(): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
const SUPABASE_URL = 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
@@ -318,15 +316,16 @@ export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchData]);
|
||||
|
||||
// Share savings to Supabase when opted in and data changes
|
||||
// Share savings to Supabase when opted in and data changes. Skipped entirely
|
||||
// when no anon key was built in (leaderboard disabled).
|
||||
useEffect(() => {
|
||||
if (!optInEnabled || !displayName || !data) return;
|
||||
if (!LEADERBOARD_ENABLED || !optInEnabled || !displayName || !data) return;
|
||||
const dollarSavings = data.per_provider.reduce((s, p) => s + p.total_cost, 0);
|
||||
const energySaved = data.per_provider.reduce((s, p) => s + (p.energy_wh || 0), 0);
|
||||
const flopsSaved = data.per_provider.reduce((s, p) => s + (p.flops || 0), 0);
|
||||
invoke('submit_savings', {
|
||||
supabaseUrl: SUPABASE_URL,
|
||||
supabaseKey: SUPABASE_KEY,
|
||||
supabaseKey: SUPABASE_ANON_KEY,
|
||||
payload: {
|
||||
anon_id: anonId,
|
||||
display_name: displayName,
|
||||
|
||||
@@ -3,6 +3,25 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
type UpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const DISABLED_KEY = 'oj-auto-update-disabled';
|
||||
|
||||
export function isAutoUpdateDisabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DISABLED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutoUpdateDisabled(disabled: boolean): void {
|
||||
try {
|
||||
if (disabled) {
|
||||
localStorage.setItem(DISABLED_KEY, '1');
|
||||
} else {
|
||||
localStorage.removeItem(DISABLED_KEY);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function UpdateChecker() {
|
||||
const [state, setState] = useState<UpdateState>('idle');
|
||||
@@ -13,6 +32,7 @@ export function UpdateChecker() {
|
||||
const updateRef = useRef<any>(null);
|
||||
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
if (isAutoUpdateDisabled()) return;
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const update = await check();
|
||||
@@ -28,10 +48,20 @@ export function UpdateChecker() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're in a Tauri environment
|
||||
if (typeof window === 'undefined' || !(window as any).__TAURI_INTERNALS__) {
|
||||
return;
|
||||
}
|
||||
if (isAutoUpdateDisabled()) return;
|
||||
|
||||
// Local dev escape hatch: skip the auto-update poll if explicitly
|
||||
// disabled. Vite exposes any ``VITE_``-prefixed env var on
|
||||
// ``import.meta.env``, so a frontend dev can ``export
|
||||
// VITE_OPENJARVIS_NO_UPDATER=1`` before ``npm run tauri dev`` to
|
||||
// silence the 30-min poll. See docs/desktop-auto-update.md.
|
||||
const noUpdater = (import.meta as any).env?.VITE_OPENJARVIS_NO_UPDATER;
|
||||
if (noUpdater === '1' || noUpdater === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
checkForUpdate();
|
||||
const interval = setInterval(checkForUpdate, CHECK_INTERVAL_MS);
|
||||
@@ -50,9 +80,7 @@ export function UpdateChecker() {
|
||||
const contentLength = update.contentLength ?? 0;
|
||||
|
||||
await update.downloadAndInstall((event: any) => {
|
||||
if (event.event === 'Started' && event.data?.contentLength) {
|
||||
// Content length received
|
||||
} else if (event.event === 'Progress') {
|
||||
if (event.event === 'Progress') {
|
||||
downloaded += event.data?.chunkLength ?? 0;
|
||||
if (contentLength > 0) {
|
||||
setProgress(Math.min(100, Math.round((downloaded / contentLength) * 100)));
|
||||
@@ -75,13 +103,18 @@ export function UpdateChecker() {
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
await relaunch();
|
||||
} catch {
|
||||
// Fallback: inform user to restart manually
|
||||
setErrorMsg('Please restart the application manually');
|
||||
setState('error');
|
||||
setTimeout(() => setState('idle'), 5000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDisable = useCallback(() => {
|
||||
setAutoUpdateDisabled(true);
|
||||
setState('idle');
|
||||
setDismissed(false);
|
||||
}, []);
|
||||
|
||||
if (state === 'idle' || dismissed) return null;
|
||||
|
||||
return (
|
||||
@@ -91,7 +124,8 @@ export function UpdateChecker() {
|
||||
<span>Update available: <strong>v{version}</strong></span>
|
||||
<div style={styles.actions}>
|
||||
<button style={styles.primaryBtn} onClick={handleDownload}>Download</button>
|
||||
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Dismiss</button>
|
||||
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Later</button>
|
||||
<button style={styles.muteBtn} onClick={handleDisable}>Disable auto-updates</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -140,6 +174,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryBtn: {
|
||||
padding: '4px 14px',
|
||||
@@ -170,6 +205,15 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
muteBtn: {
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#585b70',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
progressBar: {
|
||||
flex: 1,
|
||||
maxWidth: '300px',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
import { ApprovalBell } from './ApprovalBell';
|
||||
import { Sidebar } from './Sidebar/Sidebar';
|
||||
import { SystemPulse } from './SystemPulse';
|
||||
import { useAppStore } from '../lib/store';
|
||||
@@ -27,6 +28,7 @@ export function Layout() {
|
||||
<div className="flex flex-col h-full w-full overflow-hidden relative" style={{ paddingTop: '3px' }}>
|
||||
<div className="hud-backdrop" aria-hidden="true" />
|
||||
<SystemPulse apiReachable={apiReachable} />
|
||||
<ApprovalBell />
|
||||
|
||||
{/* Health check banner */}
|
||||
{apiReachable === false && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -33,6 +33,7 @@ export function Sidebar() {
|
||||
const serverInfo = useAppStore((s) => s.serverInfo);
|
||||
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
|
||||
const modelLoading = useAppStore((s) => s.modelLoading);
|
||||
const deepResearch = useAppStore((s) => s.deepResearch);
|
||||
|
||||
const settings = useAppStore((s) => s.settings);
|
||||
const updateSettings = useAppStore((s) => s.updateSettings);
|
||||
@@ -143,8 +144,13 @@ export function Sidebar() {
|
||||
<Cpu size={14} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="truncate block text-left" style={{ color: 'var(--color-text)' }}>
|
||||
{selectedModel || serverInfo?.model || 'Select model'}
|
||||
<span
|
||||
className="truncate block text-left"
|
||||
style={{ color: deepResearch ? 'var(--color-accent)' : 'var(--color-text)' }}
|
||||
>
|
||||
{deepResearch
|
||||
? 'Deep Research'
|
||||
: selectedModel || serverInfo?.model || 'Select model'}
|
||||
</span>
|
||||
{modelLoading && (
|
||||
<span className="text-[10px] block text-left" style={{ color: 'var(--color-accent)' }}>
|
||||
|
||||
@@ -707,4 +707,50 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
/* Keep project radius tokens — don't override with shadcn calc values */
|
||||
}
|
||||
|
||||
/* Deep Research timeline — pending-step shimmer */
|
||||
@keyframes openjarvis-research-shimmer {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(420%); }
|
||||
}
|
||||
.research-shimmer {
|
||||
animation: openjarvis-research-shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Sync progress pulse — fires on each items_synced bump so the
|
||||
"12,450 / 99,840" label visibly breathes between polls. */
|
||||
@keyframes openjarvis-sync-bump {
|
||||
0% { opacity: 0.55; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.sync-bump {
|
||||
animation: openjarvis-sync-bump 400ms ease-out;
|
||||
}
|
||||
|
||||
/* Deep Research inline citation pills — small rounded chips that flow
|
||||
with the surrounding text and link out to the cited email. */
|
||||
.research-citation {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
margin: 0 1px;
|
||||
padding: 0 6px;
|
||||
min-width: 1.25em;
|
||||
text-align: center;
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
border-radius: 9999px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 150ms, color 150ms;
|
||||
/* Keep the pill from inheriting prose link styles (underline, bold). */
|
||||
border-bottom: none;
|
||||
}
|
||||
.research-citation:hover {
|
||||
color: var(--color-on-accent, #ffffff);
|
||||
background: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Frontend analytics client.
|
||||
*
|
||||
* Thin wrapper around posthog-js that:
|
||||
* - Pulls its identity (anon_id, host, project key) from the backend
|
||||
* via GET /v1/analytics/identity, so all backend / install.sh /
|
||||
* frontend events tie to the same person.
|
||||
* - Initializes posthog-js with autocapture, session replay, and
|
||||
* pageviews disabled — we send only events we explicitly call out.
|
||||
* - Registers the app version as a super-property so every event
|
||||
* carries it uniformly (no per-event repetition needed).
|
||||
* - Fails silently when the backend isn't reachable, when analytics
|
||||
* is disabled, or when the SDK throws — must never break the UI.
|
||||
*
|
||||
* Opt-out matches the backend: if /identity returns enabled=false
|
||||
* (analytics disabled in config), the SDK is never
|
||||
* initialized and track() becomes a no-op.
|
||||
*/
|
||||
|
||||
import posthog from 'posthog-js';
|
||||
import { getBase } from './api';
|
||||
|
||||
/**
|
||||
* Mirror of the Python event catalog (src/openjarvis/analytics/events.py
|
||||
* REGISTRY). Anything not in this set is dropped with a console.warn at
|
||||
* track() time, so typos and unallowed events don't silently ship.
|
||||
*
|
||||
* KEEP IN SYNC with the Python REGISTRY. CI could enforce this later.
|
||||
*/
|
||||
const KNOWN_EVENTS = new Set<string>([
|
||||
'install_started',
|
||||
'install_stage_completed',
|
||||
'install_completed',
|
||||
'install_failed',
|
||||
'uninstall_started',
|
||||
'app_opened',
|
||||
'setup_completed',
|
||||
'first_chat_sent',
|
||||
'chat_session_ended',
|
||||
'tool_first_used',
|
||||
'model_changed',
|
||||
'connector_auth_completed',
|
||||
'feature_used',
|
||||
'feedback_submitted',
|
||||
'error_shown_to_user',
|
||||
'settings_changed',
|
||||
'usage_daily_summary',
|
||||
]);
|
||||
|
||||
// Hardcoded app version — should match the backend.
|
||||
// TODO: wire to Vite define() so this comes from package.json at build time.
|
||||
const APP_VERSION = '0.1.0';
|
||||
|
||||
interface AnalyticsIdentity {
|
||||
enabled: boolean;
|
||||
anon_id: string;
|
||||
host: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
let enabledState = false;
|
||||
let cachedAnonId = '';
|
||||
|
||||
/**
|
||||
* Fetch identity from the backend and initialize the SDK.
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export async function initAnalytics(): Promise<void> {
|
||||
if (initialized) return;
|
||||
initialized = true; // claim the slot even on failure paths
|
||||
|
||||
try {
|
||||
const base = getBase();
|
||||
if (!base) return;
|
||||
|
||||
const resp = await fetch(`${base}/v1/analytics/identity`);
|
||||
if (!resp.ok) return;
|
||||
|
||||
const identity: AnalyticsIdentity = await resp.json();
|
||||
if (!identity.enabled || !identity.key || !identity.anon_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
cachedAnonId = identity.anon_id;
|
||||
|
||||
posthog.init(identity.key, {
|
||||
api_host: identity.host,
|
||||
bootstrap: { distinctID: identity.anon_id },
|
||||
// No surprise data collection — only events we call out by name.
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
disable_session_recording: true,
|
||||
// No /decide call for feature flags (saves a request, we don't use them yet).
|
||||
advanced_disable_decide: true,
|
||||
// No PostHog person profiles — we're not making accounts, just sending events.
|
||||
person_profiles: 'never',
|
||||
// Don't try to load IP geolocation; backend disables this too.
|
||||
ip: false,
|
||||
// Best-effort sending only.
|
||||
loaded: () => {
|
||||
enabledState = true;
|
||||
},
|
||||
});
|
||||
|
||||
// Set distinct_id explicitly in case bootstrap raced.
|
||||
posthog.identify(identity.anon_id);
|
||||
|
||||
// Register version + platform as super-properties so they're attached
|
||||
// to every event automatically, without per-call-site repetition.
|
||||
posthog.register({
|
||||
version: APP_VERSION,
|
||||
platform: detectPlatform(),
|
||||
});
|
||||
} catch {
|
||||
// Any failure → analytics off, silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one event. Properties are passed through to PostHog; redaction
|
||||
* happens server-side for backend events. Unknown event names are
|
||||
* dropped here with a console.warn so typos surface in dev rather than
|
||||
* silently landing in the analytics warehouse.
|
||||
*/
|
||||
export function track(
|
||||
event: string,
|
||||
properties: Record<string, unknown> = {},
|
||||
): void {
|
||||
if (!enabledState) return;
|
||||
if (!KNOWN_EVENTS.has(event)) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
`[analytics] Unknown event "${event}" dropped. ` +
|
||||
`Add it to KNOWN_EVENTS in lib/analytics.ts and to the Python ` +
|
||||
`REGISTRY in src/openjarvis/analytics/events.py.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
posthog.capture(event, properties);
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-flush queued events. Call on visibilitychange / pagehide. */
|
||||
export function flush(): void {
|
||||
if (!enabledState) return;
|
||||
try {
|
||||
// posthog-js queues then flushes async; this is best-effort.
|
||||
// The SDK doesn't expose a direct flush, but the page lifecycle
|
||||
// hooks below cause a beacon-style flush automatically.
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
|
||||
export function isAnalyticsEnabled(): boolean {
|
||||
return enabledState;
|
||||
}
|
||||
|
||||
export function getAnonId(): string {
|
||||
return cachedAnonId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an identifier to a 16-char sha256 hex prefix.
|
||||
*
|
||||
* Used for model / tool names that we want to cohort on without
|
||||
* actually shipping the raw value (e.g. proprietary model names a
|
||||
* power user has configured). Mirror of the backend's
|
||||
* :func:`openjarvis.analytics.redaction.hash_id`.
|
||||
*/
|
||||
export async function hashId(s: string): Promise<string> {
|
||||
if (!s) return '';
|
||||
try {
|
||||
const data = new TextEncoder().encode(s);
|
||||
const buf = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.slice(0, 8)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect platform string conforming to the events.py allowlist:
|
||||
* "tauri-macos" | "tauri-linux" | "tauri-windows" | "web".
|
||||
*/
|
||||
export function detectPlatform(): string {
|
||||
const ua =
|
||||
typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '';
|
||||
const isTauri =
|
||||
typeof window !== 'undefined' &&
|
||||
!!(window as unknown as { __TAURI_INTERNALS__?: unknown })
|
||||
.__TAURI_INTERNALS__;
|
||||
if (isTauri) {
|
||||
if (ua.includes('mac')) return 'tauri-macos';
|
||||
if (ua.includes('windows')) return 'tauri-windows';
|
||||
return 'tauri-linux';
|
||||
}
|
||||
return 'web';
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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';
|
||||
|
||||
// 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');
|
||||
(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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+265
-59
@@ -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();
|
||||
}
|
||||
@@ -192,12 +260,26 @@ export async function checkHealth(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${getBase()}/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// In the browser, hit /health relative to the page origin so the request
|
||||
// flows through whatever path is already serving the SPA — the Vite
|
||||
// proxy in dev, FastAPI's static mount in prod. This avoids the
|
||||
// false-negative "Cannot reach backend" banner when getBase() points at
|
||||
// an absolute URL the browser can't reach directly.
|
||||
//
|
||||
// If /health itself fails for any reason (proxy quirk, stale service
|
||||
// worker, etc.) fall back to an arbitrary API endpoint we know the rest
|
||||
// of the app polls successfully. If THAT also fails we genuinely can't
|
||||
// reach the backend.
|
||||
const probe = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (await probe('/health')) return true;
|
||||
return probe('/v1/connectors');
|
||||
}
|
||||
|
||||
export async function fetchEnergy(): Promise<unknown> {
|
||||
@@ -206,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();
|
||||
}
|
||||
@@ -217,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();
|
||||
}
|
||||
@@ -228,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();
|
||||
}
|
||||
@@ -258,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();
|
||||
}
|
||||
|
||||
@@ -280,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();
|
||||
}
|
||||
@@ -364,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();
|
||||
}
|
||||
@@ -382,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),
|
||||
@@ -395,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),
|
||||
@@ -405,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 }),
|
||||
@@ -437,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 || [];
|
||||
@@ -481,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 }),
|
||||
@@ -498,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({
|
||||
@@ -520,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({
|
||||
@@ -538,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}`);
|
||||
@@ -559,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}`);
|
||||
@@ -574,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();
|
||||
}
|
||||
@@ -603,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 }),
|
||||
@@ -708,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 || [];
|
||||
@@ -756,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 || [];
|
||||
@@ -766,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),
|
||||
@@ -790,26 +901,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();
|
||||
}
|
||||
@@ -870,20 +981,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 }),
|
||||
@@ -894,26 +1024,102 @@ 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();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approvals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PendingApproval {
|
||||
id: string;
|
||||
action_type: string;
|
||||
description: string;
|
||||
payload: Record<string, unknown>;
|
||||
permission_key: string;
|
||||
tier: 'trivial' | 'low' | 'medium' | 'high';
|
||||
status: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export async function fetchPendingApprovals(): Promise<PendingApproval[]> {
|
||||
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 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 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',
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ResearchSource } from '../types';
|
||||
|
||||
interface HastNode {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
value?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
children?: HastNode[];
|
||||
}
|
||||
|
||||
// Tags whose descendants should NOT be searched for citations.
|
||||
// `<a>` is handled specially below (markdown link to a single number is
|
||||
// promoted to a pill); other links + code/pre/script/style are skipped.
|
||||
const SKIP_TAGS = new Set(['code', 'pre', 'script', 'style']);
|
||||
|
||||
// Matches `[N]` or `[N, M, ...]` — bracketed comma-separated digit lists.
|
||||
// Whitespace inside the brackets is tolerated. An optional trailing
|
||||
// whitespace run is consumed when followed by sentence punctuation, so we
|
||||
// can render `… San Francisco [1].` without a stray space before the period.
|
||||
const CITATION_RE =
|
||||
/\[(\s*\d+(?:\s*,\s*\d+)*\s*)\](\s+(?=[.,;:!?]))?/g;
|
||||
|
||||
const TOOLTIP_DATE = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
function formatTooltipDate(iso: string | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.trim() || null;
|
||||
return TOOLTIP_DATE.format(d);
|
||||
}
|
||||
|
||||
function buildTooltip(src: ResearchSource): string {
|
||||
const parts: string[] = [];
|
||||
if (src.title) parts.push(src.title);
|
||||
const meta: string[] = [];
|
||||
if (src.sender) meta.push(src.sender);
|
||||
const date = formatTooltipDate(src.date);
|
||||
if (date) meta.push(date);
|
||||
if (meta.length > 0) parts.push(meta.join(' · '));
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function buildPill(n: number, src: ResearchSource): HastNode {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'a',
|
||||
properties: {
|
||||
href: src.url,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className: ['research-citation'],
|
||||
'data-ref': String(n),
|
||||
title: buildTooltip(src) || `Source ${n}`,
|
||||
},
|
||||
children: [{ type: 'text', value: String(n) }],
|
||||
};
|
||||
}
|
||||
|
||||
function parseRefList(inner: string): number[] {
|
||||
return inner
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace bracketed citations in the rendered markdown with small inline
|
||||
* pill links to the underlying source.
|
||||
*
|
||||
* Patterns handled:
|
||||
* • `[1]` — single ref
|
||||
* • `[4, 7, 20]` — grouped refs → individual pills, no separators
|
||||
* • `[1](https://…)` — markdown link whose text is just a number; the
|
||||
* inline URL is discarded in favor of the source-map
|
||||
* URL so all citations stay routed through Gmail.
|
||||
*
|
||||
* Citations whose ref is missing from the source map are preserved as
|
||||
* literal text so we never silently lose information.
|
||||
*/
|
||||
export function rehypeCitations(options: {
|
||||
sources: Map<number, ResearchSource>;
|
||||
}) {
|
||||
const { sources } = options;
|
||||
if (sources.size === 0) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
function transformText(text: string): HastNode[] | null {
|
||||
if (!text.includes('[')) return null;
|
||||
|
||||
const pieces: HastNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
CITATION_RE.lastIndex = 0;
|
||||
let changed = false;
|
||||
|
||||
while ((m = CITATION_RE.exec(text)) !== null) {
|
||||
const refs = parseRefList(m[1]);
|
||||
const resolved = refs.map((n) => ({ n, src: sources.get(n) }));
|
||||
const allMatched =
|
||||
resolved.length > 0 && resolved.every((r) => r.src && r.src.url);
|
||||
|
||||
if (m.index > lastIndex) {
|
||||
pieces.push({ type: 'text', value: text.slice(lastIndex, m.index) });
|
||||
}
|
||||
|
||||
if (allMatched) {
|
||||
for (const r of resolved) {
|
||||
pieces.push(buildPill(r.n, r.src!));
|
||||
}
|
||||
changed = true;
|
||||
} else {
|
||||
// Conservative fallback: if any ref in this group is missing source
|
||||
// data, keep the whole bracketed expression as the model wrote it.
|
||||
pieces.push({ type: 'text', value: m[0] });
|
||||
}
|
||||
|
||||
lastIndex = m.index + m[0].length;
|
||||
}
|
||||
|
||||
if (!changed) return null;
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
pieces.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
function tryMarkdownCitationLink(node: HastNode): HastNode | null {
|
||||
// `[1](url)` → <a href="url">1</a>. If the link text is just a number
|
||||
// and we have a source for that ref, replace with our pill.
|
||||
if (
|
||||
node.type !== 'element' ||
|
||||
node.tagName !== 'a' ||
|
||||
!node.children ||
|
||||
node.children.length !== 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const child = node.children[0];
|
||||
if (child.type !== 'text' || typeof child.value !== 'string') return null;
|
||||
|
||||
const trimmed = child.value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
|
||||
const n = parseInt(trimmed, 10);
|
||||
const src = sources.get(n);
|
||||
if (!src || !src.url) return null;
|
||||
|
||||
return buildPill(n, src);
|
||||
}
|
||||
|
||||
function walk(node: HastNode): void {
|
||||
if (!node.children || node.children.length === 0) return;
|
||||
if (node.tagName && SKIP_TAGS.has(node.tagName)) return;
|
||||
|
||||
const out: HastNode[] = [];
|
||||
let changed = false;
|
||||
|
||||
for (const child of node.children) {
|
||||
// 1. Markdown citation link: [1](url)
|
||||
const promoted = tryMarkdownCitationLink(child);
|
||||
if (promoted) {
|
||||
out.push(promoted);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Other elements — recurse but don't transform inside <a>/code/etc.
|
||||
if (child.type === 'element') {
|
||||
if (child.tagName !== 'a') walk(child);
|
||||
out.push(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Text node — look for [N] and [N, M, …] patterns.
|
||||
if (child.type === 'text' && typeof child.value === 'string') {
|
||||
const replaced = transformText(child.value);
|
||||
if (replaced) {
|
||||
out.push(...replaced);
|
||||
changed = true;
|
||||
} else {
|
||||
out.push(child);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(child);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
node.children = out;
|
||||
}
|
||||
}
|
||||
|
||||
return (tree: HastNode) => {
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
+53
-3
@@ -1,5 +1,5 @@
|
||||
import type { SSEEvent } from '../types';
|
||||
import { getBase } from './api';
|
||||
import type { ResearchEvent, SSEEvent } from '../types';
|
||||
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,
|
||||
});
|
||||
@@ -57,3 +57,53 @@ export async function* streamChat(
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamResearch(
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<ResearchEvent> {
|
||||
// /api/research is mounted at the server root — strip any trailing /v1
|
||||
// from the base so configurations like "http://host:8000/v1" still resolve.
|
||||
const base = getBase().replace(/\/v1\/?$/, '');
|
||||
const response = await fetch(`${base}/api/research`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ query }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Research request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') return;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as ResearchEvent;
|
||||
yield parsed;
|
||||
if (parsed.type === 'done') return;
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@ import { create } from 'zustand';
|
||||
import type {
|
||||
Conversation,
|
||||
ChatMessage,
|
||||
LiveEnergyMetrics,
|
||||
LogEntry,
|
||||
ModelInfo,
|
||||
MessageTelemetry,
|
||||
ResearchSearchTrace,
|
||||
ResearchSource,
|
||||
SavingsData,
|
||||
ServerInfo,
|
||||
StreamState,
|
||||
@@ -67,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;
|
||||
@@ -79,6 +86,7 @@ function loadSettings(): Settings {
|
||||
const defaults: Settings = {
|
||||
theme: 'system',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
fontSize: 'default',
|
||||
defaultModel: '',
|
||||
defaultAgent: '',
|
||||
@@ -158,16 +166,29 @@ interface AppState {
|
||||
usage?: TokenUsage,
|
||||
telemetry?: MessageTelemetry,
|
||||
audio?: { url: string },
|
||||
researchTraces?: ResearchSearchTrace[],
|
||||
researchSources?: ResearchSource[],
|
||||
) => void;
|
||||
setStreamState: (state: Partial<StreamState>) => void;
|
||||
resetStream: () => void;
|
||||
|
||||
// Deep Research toggle
|
||||
deepResearch: boolean;
|
||||
setDeepResearch: (on: boolean) => void;
|
||||
|
||||
// Actions: models & server
|
||||
setModels: (models: ModelInfo[]) => void;
|
||||
setModelsLoading: (loading: boolean) => void;
|
||||
setSelectedModel: (model: string) => void;
|
||||
setServerInfo: (info: ServerInfo | null) => void;
|
||||
setSavings: (data: SavingsData | null) => void;
|
||||
incrementSavings: (usage: TokenUsage) => void;
|
||||
|
||||
// Live GPU metrics — streamed from /api/research system_metrics events.
|
||||
// When non-null, the System panel renders this instead of polled values
|
||||
// so Power (W) and Energy (kJ) update in real time during a research run.
|
||||
liveEnergy: LiveEnergyMetrics | null;
|
||||
setLiveEnergy: (data: LiveEnergyMetrics | null) => void;
|
||||
|
||||
// Actions: settings
|
||||
updateSettings: (partial: Partial<Settings>) => void;
|
||||
@@ -270,6 +291,12 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
const existing = store.conversations[overlay.id];
|
||||
// Only update if the overlay has newer/more messages
|
||||
if (existing && existing.messages.length >= overlay.messages.length) return;
|
||||
// Track first use of overlay for this conversation
|
||||
if (!existing) {
|
||||
import('../lib/analytics').then(({ track }) => {
|
||||
track('feature_used', { feature_name: 'overlay' });
|
||||
});
|
||||
}
|
||||
store.conversations[overlay.id] = {
|
||||
id: overlay.id,
|
||||
title: overlay.title || 'Overlay chat',
|
||||
@@ -381,6 +408,8 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
usage?: TokenUsage,
|
||||
telemetry?: MessageTelemetry,
|
||||
audio?: { url: string },
|
||||
researchTraces?: ResearchSearchTrace[],
|
||||
researchSources?: ResearchSource[],
|
||||
) => {
|
||||
const store = loadConversations();
|
||||
const conv = store.conversations[conversationId];
|
||||
@@ -392,6 +421,8 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
if (usage) lastMsg.usage = usage;
|
||||
if (telemetry) lastMsg.telemetry = telemetry;
|
||||
if (audio) lastMsg.audio = audio;
|
||||
if (researchTraces) lastMsg.researchTraces = researchTraces;
|
||||
if (researchSources) lastMsg.researchSources = researchSources;
|
||||
conv.updatedAt = Date.now();
|
||||
saveConversations(store);
|
||||
set({ messages: [...conv.messages] });
|
||||
@@ -406,13 +437,42 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
set({ streamState: INITIAL_STREAM });
|
||||
},
|
||||
|
||||
// ── Deep Research ─────────────────────────────────────────────
|
||||
deepResearch: false,
|
||||
setDeepResearch: (on: boolean) => set({ deepResearch: on }),
|
||||
|
||||
// ── 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 }),
|
||||
setSavings: (data: SavingsData | null) => set({ savings: data }),
|
||||
incrementSavings: (usage: TokenUsage) => {
|
||||
const cur = get().savings;
|
||||
const prompt = usage.prompt_tokens ?? 0;
|
||||
const completion = usage.completion_tokens ?? 0;
|
||||
const total = usage.total_tokens ?? prompt + completion;
|
||||
set({
|
||||
savings: {
|
||||
total_calls: (cur?.total_calls ?? 0) + 1,
|
||||
total_prompt_tokens: (cur?.total_prompt_tokens ?? 0) + prompt,
|
||||
total_completion_tokens: (cur?.total_completion_tokens ?? 0) + completion,
|
||||
total_tokens: (cur?.total_tokens ?? 0) + total,
|
||||
local_cost: cur?.local_cost ?? 0,
|
||||
per_provider: cur?.per_provider ?? [],
|
||||
token_counting_version: cur?.token_counting_version,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
liveEnergy: null,
|
||||
setLiveEnergy: (data: LiveEnergyMetrics | null) => set({ liveEnergy: data }),
|
||||
|
||||
cachedConnectors: null,
|
||||
setCachedConnectors: (list) => set({ cachedConnectors: list }),
|
||||
|
||||
@@ -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;
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import App from './App';
|
||||
import { initApiBase } from './lib/api';
|
||||
import { initAnalytics } from './lib/analytics';
|
||||
import './index.css';
|
||||
|
||||
function applyTheme() {
|
||||
@@ -27,6 +28,10 @@ applyTheme();
|
||||
// This ensures JARVIS_PORT is defined in one place (the Rust backend).
|
||||
// In non-Tauri environments this is a no-op.
|
||||
initApiBase().finally(() => {
|
||||
// Kick off analytics init in the background — it's never awaited so
|
||||
// a slow/failed identity fetch never delays UI render.
|
||||
void initAnalytics();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
|
||||
+389
-465
@@ -9,7 +9,6 @@ import {
|
||||
fetchAgentChannels,
|
||||
bindAgentChannel,
|
||||
unbindAgentChannel,
|
||||
fetchAgentMessages,
|
||||
fetchTemplates,
|
||||
createManagedAgent,
|
||||
pauseManagedAgent,
|
||||
@@ -17,10 +16,11 @@ import {
|
||||
deleteManagedAgent,
|
||||
runManagedAgent,
|
||||
recoverManagedAgent,
|
||||
sendAgentMessage,
|
||||
askAgent,
|
||||
fetchLearningLog,
|
||||
triggerLearning,
|
||||
fetchAgentTraces,
|
||||
fetchAgentTrace,
|
||||
fetchManagedAgent,
|
||||
fetchAvailableTools,
|
||||
saveToolCredentials,
|
||||
@@ -32,8 +32,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 +61,7 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
Pencil,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { SOURCE_CATALOG } from '../types/connectors';
|
||||
import type { ConnectRequest } from '../types/connectors';
|
||||
@@ -1403,20 +1405,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 +1430,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 +1476,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 +1542,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 +3535,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 (
|
||||
@@ -3903,7 +3827,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, getSyncStatus, triggerSync } from '../lib/connectors-api';
|
||||
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync, startServerOAuth } from '../lib/connectors-api';
|
||||
import type { SyncStatus } from '../types/connectors';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -312,11 +312,120 @@ const IconFor = ({ id, size = 18 }: { id: string; size?: number }) => {
|
||||
return <Ico size={size} />;
|
||||
};
|
||||
|
||||
// The Gmail card unifies the OAuth (`gmail`) and IMAP (`gmail_imap`) backend
|
||||
// connectors — both should resolve to the gmail_imap catalog entry so the
|
||||
// connected card shows the same name, unit label, and troubleshooting tips
|
||||
// regardless of which underlying flow the user picked.
|
||||
function metaFor(connectorId: string) {
|
||||
const id = connectorId === 'gmail' ? 'gmail_imap' : connectorId;
|
||||
return SOURCE_CATALOG.find((s) => s.connector_id === id);
|
||||
}
|
||||
|
||||
// Advanced OAuth disclosure for the unified Gmail card. Hidden by default;
|
||||
// expands to a Client ID + Client Secret form that POSTs to the OAuth
|
||||
// `gmail` backend connector. Lives here rather than in SOURCE_CATALOG
|
||||
// because the Gmail card is the only one with a dual-flow shape.
|
||||
function GmailOAuthAdvanced({
|
||||
loading,
|
||||
onConnect,
|
||||
}: {
|
||||
loading: boolean;
|
||||
onConnect: (req: ConnectRequest) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-tertiary)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{open ? 'Hide advanced' : 'Advanced: Connect with Google OAuth'}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 10,
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)', marginBottom: 8 }}>
|
||||
For developers with an existing Google Cloud project. Enable the
|
||||
Gmail API and create a Desktop OAuth client at{' '}
|
||||
<a
|
||||
href="https://console.cloud.google.com/apis/credentials"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)', textDecoration: 'underline' }}
|
||||
>
|
||||
Google Cloud Credentials →
|
||||
</a>{' '}
|
||||
then paste the Client ID and Client Secret below.
|
||||
</div>
|
||||
<InlineConnectForm
|
||||
fields={[
|
||||
{ name: 'email', placeholder: 'Client ID', type: 'text' },
|
||||
{ name: 'password', placeholder: 'Client Secret', type: 'password' },
|
||||
]}
|
||||
loading={loading}
|
||||
onSubmit={onConnect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Sources section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Sync status display component with progress bar
|
||||
function formatTimeAgo(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
const diffSec = (Date.now() - t) / 1000;
|
||||
if (diffSec < 30) return 'just now';
|
||||
if (diffSec < 60) return 'less than a min ago';
|
||||
if (diffSec < 3600) {
|
||||
const m = Math.round(diffSec / 60);
|
||||
return `${m} min${m === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
if (diffSec < 86400) {
|
||||
const h = Math.round(diffSec / 3600);
|
||||
return `${h} hr${h === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
const d = Math.round(diffSec / 86400);
|
||||
return `${d} day${d === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
/** Render how far back the corpus extends, given the oldest indexed
|
||||
* item's timestamp. Returns null when there isn't enough data yet. */
|
||||
function formatBacklogRange(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
const days = (Date.now() - t) / 86400_000;
|
||||
if (days < 7) return 'past few days';
|
||||
if (days < 30) return 'past month';
|
||||
if (days < 90) return 'past 3 months';
|
||||
if (days < 365) return 'past year';
|
||||
const years = Math.round(days / 365);
|
||||
return `past ${years} year${years === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function SyncStatusDisplay({
|
||||
chunks,
|
||||
sync,
|
||||
@@ -368,13 +477,65 @@ function SyncStatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// Done — has chunks
|
||||
if (chunks > 0) {
|
||||
// Treat the SyncEngine's checkpointed items_synced as the source of
|
||||
// truth for "total indexed" — `chunks` from listConnectors counts
|
||||
// embedding chunks (often != source items) and the checkpoint is what
|
||||
// both the syncing and idle branches need to display consistently.
|
||||
const totalIndexed = sync?.items_synced ?? chunks;
|
||||
const itemsTotal = sync?.items_total ?? 0;
|
||||
const backlogRange = formatBacklogRange(sync?.oldest_item_date);
|
||||
// "Complete inbox" — the user has indexed everything reachable. Only
|
||||
// surface this label when idle (during a sync we always show how far
|
||||
// back we've gotten so far).
|
||||
const isComplete =
|
||||
totalIndexed > 0 && itemsTotal > 0 && totalIndexed >= itemsTotal;
|
||||
|
||||
// Actively syncing — single status line + reassurance line.
|
||||
if (sync?.state === 'syncing' || syncing) {
|
||||
const rangeLabel = backlogRange ?? 'building corpus';
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-warning)', marginBottom: 4 }}>
|
||||
Indexed{' '}
|
||||
<span key={totalIndexed} className="sync-bump">
|
||||
{totalIndexed.toLocaleString()} {unitLabel}
|
||||
</span>{' '}
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
({rangeLabel})
|
||||
</span>{' '}
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
· Still indexing…
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--color-text-tertiary)' }}>
|
||||
Deep Research available now · results improve as more {unitLabel} are indexed
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle — already has indexed items: show the corpus size + range or
|
||||
// "complete inbox" label, plus how long ago we last refreshed it.
|
||||
if (totalIndexed > 0) {
|
||||
const lastSyncLabel = formatTimeAgo(sync?.last_sync);
|
||||
const rangeLabel = isComplete
|
||||
? 'complete inbox'
|
||||
: backlogRange;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-success)' }}>
|
||||
{chunks.toLocaleString()} {unitLabel}
|
||||
Indexed {totalIndexed.toLocaleString()} {unitLabel}
|
||||
{rangeLabel && (
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{' '}({rangeLabel})
|
||||
</span>
|
||||
)}
|
||||
{lastSyncLabel && (
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
{' · '}Last synced {lastSyncLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
@@ -397,70 +558,14 @@ function SyncStatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
// Actively syncing
|
||||
if (sync?.state === 'syncing' || syncing) {
|
||||
const pct = sync?.items_total && sync.items_total > 0
|
||||
? Math.round((sync.items_synced / sync.items_total) * 100)
|
||||
: null;
|
||||
const label = sync?.items_total && sync.items_total > 0
|
||||
? `${sync.items_synced.toLocaleString()} / ${sync.items_total.toLocaleString()}`
|
||||
: sync?.items_synced && sync.items_synced > 0
|
||||
? `${sync.items_synced.toLocaleString()} items so far`
|
||||
: 'Starting...';
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-warning)', marginBottom: 4 }}>
|
||||
Syncing — {label}
|
||||
</div>
|
||||
<div style={{
|
||||
height: 4, borderRadius: 2,
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 2,
|
||||
background: 'var(--color-warning)',
|
||||
width: pct != null ? `${pct}%` : '30%',
|
||||
transition: 'width 0.5s ease',
|
||||
animationName: pct == null ? 'pulse' : undefined,
|
||||
animationDuration: pct == null ? '1.5s' : undefined,
|
||||
animationIterationCount: pct == null ? 'infinite' : undefined,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle with items synced but no chunks yet (indexing)
|
||||
if (sync?.state === 'idle' && sync.items_synced > 0) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-warning)', marginBottom: 4 }}>
|
||||
Indexing {sync.items_synced.toLocaleString()} items...
|
||||
</div>
|
||||
<div style={{
|
||||
height: 4, borderRadius: 2,
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 2, background: 'var(--color-warning)',
|
||||
width: '60%',
|
||||
animationName: 'pulse', animationDuration: '1.5s', animationIterationCount: 'infinite',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected but no chunks yet
|
||||
// Connected but nothing ever ingested. Mirror the original copy.
|
||||
const hasSynced = sync?.last_sync != null;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
||||
{hasSynced
|
||||
? 'Synced — 0 items found'
|
||||
? `Synced — 0 ${unitLabel} found`
|
||||
: 'Connected — not synced yet'}
|
||||
</span>
|
||||
<button
|
||||
@@ -546,6 +651,21 @@ function DataSourcesSection() {
|
||||
const [connectingId, setConnectingId] = useState<string | null>(null);
|
||||
const [connectStage, setConnectStage] = useState<string>('');
|
||||
const [connectError, setConnectError] = useState<string>('');
|
||||
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
|
||||
|
||||
const handleDisconnect = async (id: string) => {
|
||||
if (disconnectingId) return;
|
||||
setDisconnectingId(id);
|
||||
try {
|
||||
await disconnectSource(id);
|
||||
loadConnectors();
|
||||
} catch {
|
||||
// Surface failures silently — the connector list will refresh on the
|
||||
// next poll and reflect the true state regardless.
|
||||
} finally {
|
||||
setDisconnectingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (id: string, req: ConnectRequest) => {
|
||||
setLoading(true);
|
||||
@@ -553,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
|
||||
@@ -598,8 +730,32 @@ function DataSourcesSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const connected = connectors.filter((c) => c.connected);
|
||||
const notConnectedBase = connectors.filter((c) => !c.connected);
|
||||
// Merge the OAuth Gmail (`gmail`) and IMAP Gmail (`gmail_imap`) backend
|
||||
// connectors into a single user-facing Gmail card. IMAP is the default
|
||||
// flow (no Google Cloud setup needed); OAuth lives behind an "Advanced"
|
||||
// disclosure when the card is expanded. If both happen to be connected,
|
||||
// keep whichever has more indexed chunks so the active source still
|
||||
// surfaces its sync state.
|
||||
const unifiedConnectors = (() => {
|
||||
const gmail = connectors.find((c) => c.connector_id === 'gmail');
|
||||
const gmailImap = connectors.find((c) => c.connector_id === 'gmail_imap');
|
||||
if (!gmail || !gmailImap) return connectors;
|
||||
if (gmail.connected && !gmailImap.connected) {
|
||||
return connectors.filter((c) => c.connector_id !== 'gmail_imap');
|
||||
}
|
||||
if (gmailImap.connected && !gmail.connected) {
|
||||
return connectors.filter((c) => c.connector_id !== 'gmail');
|
||||
}
|
||||
if (gmail.connected && gmailImap.connected) {
|
||||
const dropId = gmail.chunks >= gmailImap.chunks ? 'gmail_imap' : 'gmail';
|
||||
return connectors.filter((c) => c.connector_id !== dropId);
|
||||
}
|
||||
// Neither connected — show only the IMAP card as the default flow.
|
||||
return connectors.filter((c) => c.connector_id !== 'gmail');
|
||||
})();
|
||||
|
||||
const connected = unifiedConnectors.filter((c) => c.connected);
|
||||
const notConnectedBase = unifiedConnectors.filter((c) => !c.connected);
|
||||
// Always show the upload card in the not-connected list (it has no backend connector)
|
||||
const uploadEntry = { connector_id: 'upload', display_name: 'Upload / Paste', connected: false, chunks: 0 };
|
||||
const notConnected = notConnectedBase.some((c) => c.connector_id === 'upload')
|
||||
@@ -642,10 +798,9 @@ function DataSourcesSection() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{connected.map((c) => {
|
||||
const meta = SOURCE_CATALOG.find(s => s.connector_id === c.connector_id);
|
||||
const meta = metaFor(c.connector_id);
|
||||
const unit = meta?.unitLabel || 'items';
|
||||
const sync = syncStatuses[c.connector_id];
|
||||
const isReconnecting = expandedId === c.connector_id;
|
||||
const hasError = !!sync?.error;
|
||||
return (
|
||||
<div
|
||||
@@ -663,7 +818,7 @@ function DataSourcesSection() {
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="font-semibold" style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{c.display_name}
|
||||
{meta?.display_name ?? c.display_name}
|
||||
</div>
|
||||
<SyncStatusDisplay
|
||||
chunks={c.chunks}
|
||||
@@ -674,60 +829,23 @@ function DataSourcesSection() {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpandedId(isReconnecting ? null : c.connector_id)}
|
||||
onClick={() => handleDisconnect(c.connector_id)}
|
||||
disabled={disconnectingId === c.connector_id}
|
||||
className="hud-label"
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'transparent',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 4, cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
cursor: disconnectingId === c.connector_id ? 'default' : 'pointer',
|
||||
letterSpacing: '0.15em',
|
||||
opacity: disconnectingId === c.connector_id ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{isReconnecting ? 'Cancel' : 'Reconnect'}
|
||||
{disconnectingId === c.connector_id ? 'Disconnecting…' : 'Disconnect'}
|
||||
</button>
|
||||
</div>
|
||||
{isReconnecting && meta?.steps && (
|
||||
<div style={{ borderTop: '1px solid var(--color-border)', padding: 12 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-warning)', marginBottom: 8 }}>
|
||||
Re-enter credentials to reconnect this source.
|
||||
</div>
|
||||
{meta.steps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6, padding: 10,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--color-accent-purple)', fontSize: 10, fontWeight: 600, marginBottom: 3 }}>
|
||||
STEP {i + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, marginBottom: step.url ? 4 : 0 }}>{step.label}</div>
|
||||
{step.url && (
|
||||
<a
|
||||
href={step.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--color-accent)', fontSize: 11, textDecoration: 'underline' }}
|
||||
>
|
||||
{step.urlLabel || 'Open'} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{meta.inputFields && (
|
||||
<InlineConnectForm
|
||||
fields={meta.inputFields}
|
||||
loading={loading}
|
||||
onSubmit={(req) => handleConnect(c.connector_id, req)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -744,7 +862,7 @@ function DataSourcesSection() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{notConnected.map((c) => {
|
||||
const meta = SOURCE_CATALOG.find(s => s.connector_id === c.connector_id);
|
||||
const meta = metaFor(c.connector_id);
|
||||
const isExpanded = expandedId === c.connector_id;
|
||||
|
||||
return (
|
||||
@@ -767,10 +885,10 @@ function DataSourcesSection() {
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="font-semibold" style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{c.display_name}
|
||||
{meta?.display_name ?? c.display_name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)', marginTop: 2 }}>
|
||||
Not connected
|
||||
{meta?.description ?? 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12, fontWeight: 500 }}>
|
||||
@@ -822,6 +940,12 @@ function DataSourcesSection() {
|
||||
onSubmit={(req) => handleConnect(c.connector_id, req)}
|
||||
/>
|
||||
)}
|
||||
{c.connector_id === 'gmail_imap' && (
|
||||
<GmailOAuthAdvanced
|
||||
loading={loading && connectingId === 'gmail'}
|
||||
onConnect={(req) => handleConnect('gmail', req)}
|
||||
/>
|
||||
)}
|
||||
{meta?.troubleshooting && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-[11px] cursor-pointer" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Palette,
|
||||
Globe,
|
||||
@@ -16,9 +16,23 @@ import {
|
||||
Key,
|
||||
Search,
|
||||
Brain,
|
||||
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,
|
||||
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 }>>([]);
|
||||
@@ -42,32 +56,111 @@ function OllamaModelList() {
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyInput({ storageKey, placeholder }: { storageKey: string; placeholder: string }) {
|
||||
const [value, setValue] = useState(() => {
|
||||
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
|
||||
});
|
||||
function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: string }) {
|
||||
const [value, setValue] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const save = (v: string) => {
|
||||
setValue(v);
|
||||
try { if (v) localStorage.setItem(storageKey, v); else localStorage.removeItem(storageKey); } catch {}
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
const save = async (v: string) => {
|
||||
const next = v.trim();
|
||||
if (!next) return;
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, next);
|
||||
setValue('');
|
||||
setHasKey(true);
|
||||
setSaved(true);
|
||||
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save API key');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, '');
|
||||
setValue('');
|
||||
setHasKey(false);
|
||||
setSaved(true);
|
||||
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to remove API key');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="password" value={value} onChange={e => save(e.target.value)} placeholder={placeholder}
|
||||
<input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => { if (value.trim()) void save(value); }}
|
||||
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
|
||||
disabled={!desktopKeyStorage}
|
||||
className="w-48 px-2 py-1 rounded text-xs"
|
||||
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }} />
|
||||
{hasKey && (
|
||||
<button
|
||||
onClick={() => void remove()}
|
||||
className="px-2 py-1 rounded text-[10px] cursor-pointer"
|
||||
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
{saved && <span className="text-[10px]" style={{ color: 'var(--color-success)' }}>Saved</span>}
|
||||
{error && <span className="text-[10px]" style={{ color: 'var(--color-error)' }}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudProviderStatus({ label, storageKey }: { label: string; storageKey: string }) {
|
||||
function CloudProviderStatus({ label, keyName }: { label: string; keyName: string }) {
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const desktopKeyStorage = isTauri();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
|
||||
useEffect(() => {
|
||||
try { setHasKey(!!localStorage.getItem(storageKey)); } catch { setHasKey(false); }
|
||||
}, [storageKey]);
|
||||
void refresh();
|
||||
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
<span style={{
|
||||
@@ -122,6 +215,27 @@ export function SettingsPage() {
|
||||
const [speechBackendAvailable, setSpeechBackendAvailable] = useState<boolean | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(() => !isAutoUpdateDisabled());
|
||||
const [updateCheckState, setUpdateCheckState] = useState<'idle' | 'checking' | 'available' | 'latest'>('idle');
|
||||
|
||||
const handleAutoUpdateToggle = useCallback((enabled: boolean) => {
|
||||
setAutoUpdateEnabled(enabled);
|
||||
setAutoUpdateDisabled(!enabled);
|
||||
}, []);
|
||||
|
||||
const handleCheckNow = useCallback(async () => {
|
||||
if (!(window as any).__TAURI_INTERNALS__) return;
|
||||
setUpdateCheckState('checking');
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const update = await check();
|
||||
setUpdateCheckState(update ? 'available' : 'latest');
|
||||
setTimeout(() => setUpdateCheckState('idle'), 4000);
|
||||
} catch {
|
||||
setUpdateCheckState('idle');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [memoryStats, setMemoryStats] = useState<{ entries: number; backend: string } | null>(null);
|
||||
const [memoryEnabled, setMemoryEnabled] = useState(() => {
|
||||
try { return localStorage.getItem('openjarvis-memory-enabled') !== 'false'; } catch { return true; }
|
||||
@@ -139,6 +253,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()
|
||||
@@ -293,6 +436,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 */}
|
||||
@@ -305,10 +515,10 @@ export function SettingsPage() {
|
||||
</div>
|
||||
<SettingRow label="Cloud providers" description="Green dot means API key is configured">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<CloudProviderStatus label="OpenAI" storageKey="openjarvis-openai-key" />
|
||||
<CloudProviderStatus label="Anthropic" storageKey="openjarvis-anthropic-key" />
|
||||
<CloudProviderStatus label="Google" storageKey="openjarvis-gemini-key" />
|
||||
<CloudProviderStatus label="OpenRouter" storageKey="openjarvis-openrouter-key" />
|
||||
<CloudProviderStatus label="OpenAI" keyName="OPENAI_API_KEY" />
|
||||
<CloudProviderStatus label="Anthropic" keyName="ANTHROPIC_API_KEY" />
|
||||
<CloudProviderStatus label="Google" keyName="GEMINI_API_KEY" />
|
||||
<CloudProviderStatus label="OpenRouter" keyName="OPENROUTER_API_KEY" />
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
@@ -316,23 +526,23 @@ export function SettingsPage() {
|
||||
{/* API Keys */}
|
||||
<Section title="API Keys">
|
||||
<SettingRow label="OpenAI" description="GPT-4, GPT-3.5, etc.">
|
||||
<ApiKeyInput storageKey="openjarvis-openai-key" placeholder="sk-..." />
|
||||
<ApiKeyInput keyName="OPENAI_API_KEY" placeholder="sk-..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="Anthropic" description="Claude models">
|
||||
<ApiKeyInput storageKey="openjarvis-anthropic-key" placeholder="sk-ant-..." />
|
||||
<ApiKeyInput keyName="ANTHROPIC_API_KEY" placeholder="sk-ant-..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="Google" description="Gemini models">
|
||||
<ApiKeyInput storageKey="openjarvis-gemini-key" placeholder="AI..." />
|
||||
<ApiKeyInput keyName="GEMINI_API_KEY" placeholder="AI..." />
|
||||
</SettingRow>
|
||||
<SettingRow label="OpenRouter" description="Multi-provider routing">
|
||||
<ApiKeyInput storageKey="openjarvis-openrouter-key" placeholder="sk-or-..." />
|
||||
<ApiKeyInput keyName="OPENROUTER_API_KEY" placeholder="sk-or-..." />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Tools */}
|
||||
<Section title="Tools">
|
||||
<SettingRow label="Web Search" description="SerpAPI or Tavily key for web search tool">
|
||||
<ApiKeyInput storageKey="openjarvis-search-key" placeholder="API key..." />
|
||||
<SettingRow label="Web Search" description="Tavily key for web search tool">
|
||||
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
@@ -551,6 +761,39 @@ export function SettingsPage() {
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Updates */}
|
||||
<Section title="Updates">
|
||||
<SettingRow label="Auto-update" description="Check for new desktop builds automatically every 30 minutes">
|
||||
<button
|
||||
onClick={() => handleAutoUpdateToggle(!autoUpdateEnabled)}
|
||||
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors"
|
||||
style={{ background: autoUpdateEnabled ? 'var(--color-accent)' : 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3.5 w-3.5 rounded-full transition-transform"
|
||||
style={{
|
||||
background: 'white',
|
||||
transform: autoUpdateEnabled ? 'translateX(18px)' : 'translateX(2px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</SettingRow>
|
||||
<SettingRow label="Check for updates" description="Manually check for a new version right now">
|
||||
<button
|
||||
onClick={handleCheckNow}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text)', cursor: 'pointer' }}
|
||||
disabled={updateCheckState === 'checking'}
|
||||
>
|
||||
<RefreshCw size={12} className={updateCheckState === 'checking' ? 'animate-spin' : ''} />
|
||||
{updateCheckState === 'checking' && 'Checking...'}
|
||||
{updateCheckState === 'available' && 'Update available — see banner above'}
|
||||
{updateCheckState === 'latest' && 'Already up to date'}
|
||||
{updateCheckState === 'idle' && 'Check now'}
|
||||
</button>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* About */}
|
||||
<Section title="About">
|
||||
<div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface SyncStatus {
|
||||
state: "idle" | "syncing" | "paused" | "error";
|
||||
items_synced: number;
|
||||
items_total: number;
|
||||
/** Items processed in the current (or most recent) run only. `null`
|
||||
* when no sync has been triggered through this server session yet. */
|
||||
new_items_synced?: number | null;
|
||||
/** ISO 8601 timestamp of the oldest indexed item, used to label how far
|
||||
* back the corpus reaches ("past 3 months", "past 5 years"). `null`
|
||||
* before anything is indexed. */
|
||||
oldest_item_date?: string | null;
|
||||
last_sync: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -48,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
|
||||
@@ -73,6 +93,9 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
},
|
||||
// ── Communication ──────────────────────────────────────────────────
|
||||
{
|
||||
// Unified Gmail card. Defaults to the IMAP (app-password) flow because
|
||||
// it needs no Google Cloud setup; the OAuth path is offered as an
|
||||
// "Advanced" disclosure rendered in DataSourcesPage.
|
||||
connector_id: 'gmail_imap',
|
||||
display_name: 'Gmail',
|
||||
auth_type: 'oauth',
|
||||
@@ -83,23 +106,14 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
unitLabel: 'emails',
|
||||
steps: [
|
||||
{
|
||||
label: 'Go to your Google Account \u2192 Security \u2192 2-Step Verification. Make sure it\'s turned ON. App Passwords only work if 2-Step Verification is enabled.',
|
||||
url: 'https://myaccount.google.com/signinoptions/two-step-verification',
|
||||
urlLabel: 'Open Google Security \u2192',
|
||||
},
|
||||
{
|
||||
label: 'Go to App Passwords. Select app: "Mail", device: "Other" and type "OpenJarvis". Click Generate. This does NOT open a login popup \u2014 you\'ll get a 16-character password to copy (you won\'t see it again).',
|
||||
label: 'Make sure 2-Step Verification is enabled, then generate a 16-character App Password (Mail / Other / "OpenJarvis"). Paste it below \u2014 spaces are fine, and use the app password, not your regular Gmail password.',
|
||||
url: 'https://myaccount.google.com/apppasswords',
|
||||
urlLabel: 'Open App Passwords \u2192',
|
||||
},
|
||||
{
|
||||
label: 'Paste your Gmail address and the 16-character app password below (spaces are fine). Use the app password, NOT your regular Gmail password.',
|
||||
urlLabel: 'How to get an app password \u2192',
|
||||
},
|
||||
],
|
||||
troubleshooting: [
|
||||
"Don't see App Passwords? Make sure 2-Step Verification is enabled first.",
|
||||
"Google Workspace user? Your admin may need to enable App Passwords for your organization.",
|
||||
"Want OAuth instead? Use the Google Drive connector \u2014 it covers Gmail content too.",
|
||||
],
|
||||
inputFields: [
|
||||
{ name: 'email', placeholder: 'you@gmail.com', type: 'text' },
|
||||
@@ -113,7 +127,7 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
category: 'communication',
|
||||
icon: 'Hash',
|
||||
color: 'text-purple-400',
|
||||
description: 'Read messages from channels, DMs, and threads',
|
||||
description: 'Read messages from every channel, private channel, DM, and group DM you have access to',
|
||||
unitLabel: 'messages',
|
||||
steps: [
|
||||
{
|
||||
@@ -122,16 +136,16 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
urlLabel: 'Open Slack Apps',
|
||||
},
|
||||
{
|
||||
label: 'In the left sidebar, click "OAuth & Permissions". Scroll down to "Bot Token Scopes" and click "Add an OAuth Scope" to add EACH of these scopes one by one:',
|
||||
label: 'In the left sidebar, click "OAuth & Permissions". Scroll down to "User Token Scopes" (NOT "Bot Token Scopes"). Click "Add an OAuth Scope" and add EACH of these scopes one by one:',
|
||||
},
|
||||
{
|
||||
label: 'channels:read • channels:history • channels:join • groups:read • groups:history • im:read • im:history • mpim:read • mpim:history • chat:write • users:read • app_mentions:read',
|
||||
label: 'channels:history • channels:read • groups:history • groups:read • im:history • im:read • mpim:history • mpim:read • users:read',
|
||||
},
|
||||
{
|
||||
label: 'In the left sidebar, click "Install App" → click "Install to Workspace" → click "Allow". After installing, copy the "Bot User OAuth Token" that appears (starts with xoxb-)',
|
||||
label: 'In the left sidebar, click "Install App" → click "Install to Workspace" → click "Allow". After installing, copy the "User OAuth Token" that appears (starts with xoxp-, NOT xoxb-)',
|
||||
},
|
||||
{
|
||||
label: 'Paste the bot token below. After connecting, invite the bot to channels you want indexed by typing /invite @OpenJarvis in each channel',
|
||||
label: 'Paste the user token below. Sync indexes every channel, private channel, DM, and group DM you have access to — no need to invite anything to channels',
|
||||
},
|
||||
{
|
||||
label: '(Optional) Set the app icon: in the left sidebar click "Basic Information" → scroll to "Display Information" → upload the OpenJarvis logo',
|
||||
@@ -140,7 +154,7 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
|
||||
},
|
||||
],
|
||||
inputFields: [
|
||||
{ name: 'token', placeholder: 'xoxb-...', type: 'password' },
|
||||
{ name: 'token', placeholder: 'xoxp-...', type: 'password' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -256,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: [
|
||||
|
||||
@@ -61,12 +61,69 @@ export interface MessageTelemetry {
|
||||
suggested_max_tokens?: number;
|
||||
}
|
||||
|
||||
export interface TimeRange {
|
||||
start?: string;
|
||||
end?: string;
|
||||
}
|
||||
|
||||
export interface ResearchSource {
|
||||
ref: number;
|
||||
title?: string;
|
||||
sender?: string;
|
||||
date?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface ResearchSearchTrace {
|
||||
id: string;
|
||||
query: string;
|
||||
person?: string;
|
||||
timeRange?: TimeRange | string;
|
||||
status: 'pending' | 'complete';
|
||||
numHits?: number;
|
||||
topTitles?: string[];
|
||||
}
|
||||
|
||||
export type ResearchEvent =
|
||||
| {
|
||||
type: 'search_call';
|
||||
arguments: {
|
||||
query: string;
|
||||
person?: string;
|
||||
time_range?: TimeRange | string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'search_result';
|
||||
num_hits: number;
|
||||
top_titles?: string[];
|
||||
sources?: ResearchSource[];
|
||||
}
|
||||
| { type: 'synthesis'; text: string }
|
||||
| {
|
||||
type: 'system_metrics';
|
||||
power_w: number;
|
||||
energy_j: number;
|
||||
duration_s: number;
|
||||
}
|
||||
| { type: 'done'; usage?: TokenUsage }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export interface LiveEnergyMetrics {
|
||||
power_w: number;
|
||||
energy_j: number;
|
||||
duration_s: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
researchTraces?: ResearchSearchTrace[];
|
||||
researchSources?: ResearchSource[];
|
||||
isResearch?: boolean;
|
||||
usage?: TokenUsage;
|
||||
telemetry?: MessageTelemetry;
|
||||
audio?: { url: string };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user