mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
OpenJarvis can run vision-capable local models (gemma3, qwen2.5-vl), but the CLI had no way to send them a picture -- the Ollama engine only serialized text. This adds end-to-end image input. What's new - `jarvis ask -i/--image <file>` attaches one or more images to the query. - `jarvis ask -S/--screen` captures the primary monitor (dependency-free on Windows via .NET; mss/Pillow fallback elsewhere). - Vision auto-routes to direct-to-engine mode; with an explicit --agent it warns rather than silently dropping the image. - Privacy guard: warns before sending an image to a non-local engine, keeping OpenJarvis local-first by default. - Context-window default raised 8k -> 16k (JARVIS_NUM_CTX) so an image plus a conversation fit. Implementation - Message.images carries base64 data; messages_to_dicts() forwards it to Ollama's /api/chat "images" field. Text-only messages are unchanged. - GuardrailsEngine preserves images when it rewrites a flagged message. Tests (tests/test_vision.py, 6/6 pass, ruff-clean) - payload forwarding, text path untouched, num_ctx override, guardrail image preservation. Verified on AMD RX 9070 XT (Ollama/Vulkan, 100% GPU) with gemma3:4b: solid-color image, file image, and live screen capture all described. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""CLI-level regression tests for ``jarvis ask`` vision input.
|
|
|
|
The unit tests in ``tests/test_vision.py`` cover the ``Message.images`` ->
|
|
``messages_to_dicts`` serialization contract in isolation. These tests lock
|
|
the *end-to-end CLI wiring*: that ``--image`` reads a file, base64-encodes it,
|
|
attaches it to the final user ``Message``, and that the bytes actually reach
|
|
``engine.generate()`` -- and that the local-first privacy guard fires only for
|
|
non-local engines.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import importlib
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from click.testing import CliRunner
|
|
|
|
from openjarvis.cli import cli
|
|
from openjarvis.core.config import JarvisConfig
|
|
from openjarvis.core.types import Role
|
|
|
|
# Import the module (not the Click command attribute) so we can monkeypatch
|
|
# the names it looks up at call time.
|
|
_ask_mod = importlib.import_module("openjarvis.cli.ask")
|
|
|
|
# A minimal but valid 1x1 PNG so ``click.Path(exists=True)`` is satisfied and
|
|
# the bytes are deterministic.
|
|
_PNG_BYTES = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
|
|
"+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
|
|
class _RecordingEngine:
|
|
"""A fake engine that records the messages handed to ``generate()``."""
|
|
|
|
def __init__(self) -> None:
|
|
self.engine_id = "mock"
|
|
self.received: list[Any] = []
|
|
|
|
def health(self) -> bool:
|
|
return True
|
|
|
|
def list_models(self) -> list[str]:
|
|
return ["test-model"]
|
|
|
|
def generate(self, messages, *, model=None, **kwargs):
|
|
# Capture the exact Message objects the CLI built so the test can
|
|
# assert the image bytes reached the engine boundary.
|
|
self.received = list(messages)
|
|
return {
|
|
"content": "a 1x1 pixel",
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
"model": "test-model",
|
|
"finish_reason": "stop",
|
|
}
|
|
|
|
|
|
def _patch_ask(monkeypatch, tmp_path: Path, *, engine_name: str) -> _RecordingEngine:
|
|
"""Wire ``jarvis ask`` to a recording engine reported under ``engine_name``."""
|
|
cfg = JarvisConfig()
|
|
cfg.telemetry.db_path = str(tmp_path / "telemetry.db")
|
|
# Keep memory context out of the picture so the user message we inspect is
|
|
# the one the CLI built directly from the query + image.
|
|
cfg.agent.context_from_memory = False
|
|
monkeypatch.setattr(_ask_mod, "load_config", lambda: cfg)
|
|
|
|
engine = _RecordingEngine()
|
|
monkeypatch.setattr(_ask_mod, "get_engine", lambda *a, **kw: (engine_name, engine))
|
|
monkeypatch.setattr(_ask_mod, "discover_engines", lambda c: [(engine_name, engine)])
|
|
monkeypatch.setattr(
|
|
_ask_mod, "discover_models", lambda e: {engine_name: ["test-model"]}
|
|
)
|
|
return engine
|
|
|
|
|
|
def _write_png(tmp_path: Path) -> tuple[Path, str]:
|
|
img = tmp_path / "pixel.png"
|
|
img.write_bytes(_PNG_BYTES)
|
|
return img, base64.b64encode(_PNG_BYTES).decode("ascii")
|
|
|
|
|
|
def test_image_reaches_engine_payload(monkeypatch, tmp_path: Path) -> None:
|
|
engine = _patch_ask(monkeypatch, tmp_path, engine_name="ollama")
|
|
img, expected_b64 = _write_png(tmp_path)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["ask", "-i", str(img), "--no-context", "--agent", "", "describe this"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# The CLI must have routed to direct mode and called the engine.
|
|
assert engine.received, "engine.generate() was never called"
|
|
user_msgs = [m for m in engine.received if m.role == Role.USER]
|
|
assert user_msgs, "no USER message reached the engine"
|
|
assert user_msgs[-1].images == [expected_b64]
|
|
|
|
|
|
def test_privacy_warning_for_non_local_engine(monkeypatch, tmp_path: Path) -> None:
|
|
engine = _patch_ask(monkeypatch, tmp_path, engine_name="openai")
|
|
img, expected_b64 = _write_png(tmp_path)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["ask", "-i", str(img), "--no-context", "--agent", "", "describe this"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Privacy warning" in result.output
|
|
# The warning is informational; the image must still be delivered.
|
|
user_msgs = [m for m in engine.received if m.role == Role.USER]
|
|
assert user_msgs and user_msgs[-1].images == [expected_b64]
|
|
|
|
|
|
def test_no_privacy_warning_for_local_engine(monkeypatch, tmp_path: Path) -> None:
|
|
_patch_ask(monkeypatch, tmp_path, engine_name="ollama")
|
|
img, _ = _write_png(tmp_path)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["ask", "-i", str(img), "--no-context", "--agent", "", "describe this"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Privacy warning" not in result.output
|