Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
b0fad5d
feat: route LLM endpoints to local OpenAI-compatible providers
banggaoo Oct 4, 2026
ee3931a
feat: prepare multimodal unification for Gemma 4 E4B
banggaoo Oct 7, 2026
27006da
test: reset the Apple Vision OCR breaker counter between tests
banggaoo Oct 7, 2026
edde23d
feat: gate local model installs through the readiness engine
banggaoo Oct 9, 2026
fcbaec1
fix: use the gemma4-e4b alias actually served by the platform
banggaoo Oct 9, 2026
bb43c5f
Bind ARTEMIS to the local platform: gemma4-e4b default, vision-hybrid…
banggaoo Oct 9, 2026
837d53f
Bind vision nodes to gemma4-e4b; vision-hybrid reserved as OCR route
banggaoo Oct 9, 2026
5f2d1a9
Route perform_ocr through platform vision-hybrid (ARTEMIS_OCR_PROVIDE…
banggaoo Oct 9, 2026
636c8fa
Remove apple_vision_ocr worker; vision-hybrid is the OCR path
banggaoo Oct 9, 2026
cbefe03
Revert platform OCR wiring: ARTEMIS stays a consumer, unchanged
banggaoo Oct 9, 2026
44187a9
fix: object_detector uses yx_1000 for gemma4-e4b
banggaoo Oct 9, 2026
4f751d4
fix: provider compatibility and prompt discipline for local gemma4-e4b
banggaoo Oct 9, 2026
068c0f3
docs: mark on-device lightweight VLMs roadmap item delivered
banggaoo Oct 9, 2026
65a26b1
docs: add on-device gemma4-e4b execution evidence screenshots
banggaoo Oct 9, 2026
58d1282
docs: local models guide; generic pull hint for non-platform servers
banggaoo Oct 9, 2026
6dad0c2
refactor: keep local-model support server-agnostic; drop platform-spe…
banggaoo Oct 9, 2026
2c39050
feat: artemis model CLI pulls and serves local models itself
banggaoo Oct 9, 2026
c80f651
feat: vision soft-token budget for managed local serving
banggaoo Oct 10, 2026
ba580f8
fix: image-first ordering at remaining text-first multimodal sites
banggaoo Oct 10, 2026
5d50d3a
feat: mark_done completion affordance; all-resolved plans terminate v…
banggaoo Oct 10, 2026
f75b596
feat: console-guided local model setup — readiness card, managed pull…
banggaoo Oct 10, 2026
0e5eea4
docs: mark_done/blocked-plan termination contract; console Run button…
banggaoo Oct 10, 2026
e118e48
fix: harden Flash tool-call resolution for local VLM servers
banggaoo Oct 10, 2026
f27b9f5
feat: add System-One decision layer client and planner-validation off…
banggaoo Oct 14, 2026
b20aa8e
feat: hybrid Flash loop — VLM describes, decision model picks the action
banggaoo Oct 14, 2026
c68011f
feat: managed laya-serve lifecycle; decision plumbing; idle-turn re-o…
banggaoo Oct 14, 2026
770f3d3
fix: mask already-tried actions from the hybrid menu; escalate on rep…
banggaoo Oct 15, 2026
b502faf
fix: goal-anchored decision question; enumerate all elements in describe
banggaoo Oct 15, 2026
1d8aebb
fix: escalate terminal picks to VLM verification; describe names the …
banggaoo Oct 15, 2026
4b7a732
fix: ground hybrid plan on first observation instead of planning blind
banggaoo Oct 15, 2026
9315a69
fix: plan prompt requires goal-path milestones, not element inventory
banggaoo Oct 15, 2026
0af4925
feat: TARGET visibility gate — decision model only picks on-screen el…
banggaoo Oct 15, 2026
cfdfade
feat: goal-token visibility gate — VLM navigates when no menu label m…
banggaoo Oct 15, 2026
1029056
fix: same-turn tool-call re-ask; menu-size-aware laya floor; determin…
banggaoo Oct 15, 2026
b5f92dc
fix: correct retry variable name in same-turn re-ask
banggaoo Oct 15, 2026
05e4711
feat: index stateful controls with live on/off state in the element list
banggaoo Oct 15, 2026
ee400a6
fix: mask only dead taps — screen-change-gated repeat tracking + verb…
banggaoo Oct 15, 2026
e4779b6
fix: restore single-screenshot tail contract; block-list retry notice…
banggaoo Oct 15, 2026
8a38522
feat: verify completed reports at the terminal boundary — Pro final c…
banggaoo Oct 15, 2026
832fbb9
fix: route TARGET:reached describes to VLM verification; retry degene…
banggaoo Oct 15, 2026
1f6f2aa
feat: last-action outcome in decision state; describe reports control…
banggaoo Oct 15, 2026
da74892
fix: repeat notices name navigation alternatives and escalate to hone…
banggaoo Oct 15, 2026
af959c2
fix: suppress verbatim dead call sets — answer with an error and nav …
banggaoo Oct 15, 2026
7a0b048
fix: detect A-B-A screen alternation (ping-pong) cycles that verbatim…
banggaoo Oct 15, 2026
b6989d9
fix: fail fast after three verbatim re-issues of a suppressed dead call
banggaoo Oct 15, 2026
d2ad38a
fix: grace window on device-offline — poll for return instead of fail…
banggaoo Oct 16, 2026
9ca0fbd
fix(flash): render goal and plan into hybrid plan/describe prompts
banggaoo Oct 16, 2026
c3fc63c
chore: keep local model PR scoped to Flash lifecycle
banggaoo Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ ARTEMIS supports two execution profiles tailored for different automation requir

- [ ] **Android Studio Integration**: Native IDE plugin and workflow integration to enable in-editor debugging, test recording, and automated device control directly within Android Studio.
- [ ] **iOS Platform Expansion**: Extending multimodal perception and mobile automation to iOS devices and simulators.
- [ ] **On-Device Lightweight VLMs**: Local execution with lightweight edge vision models for low-latency, privacy-first automation.
- [x] **On-Device Lightweight VLMs**: Local execution with lightweight edge vision models for low-latency, privacy-first automation via OpenAI-compatible local providers — see the [local models guide](./docs/local_models.md) for install/pull/run steps (`config/artemis.gemma4.jsonc` is the tested Gemma 4 E4B setup).
- [ ] **Real-time Duplex Voice Interaction**: Voice-driven task dispatch with real-time conversational control and interruption handling.

## Community & Contributing
Expand Down
186 changes: 186 additions & 0 deletions apps/admin_console/routers/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import ipaddress
import os
import re
import secrets
from urllib.parse import urlsplit

Expand Down Expand Up @@ -624,3 +625,188 @@ async def _shutdown_after_response() -> None:
"message": "Artemis server is shutting down.",
"pid": os.getpid(),
}


# --- Local model lifecycle (artemis model pull/serve/stop) ------------------
#
# The console surfaces the local_model_endpoint probe's guided actions; these
# endpoints let the user run them without leaving the browser. Each action
# spawns `python -m artemis model ...` and streams its output into a capped
# in-memory log the frontend polls.


class LocalModelActionRequest(BaseModel):
"""Payload for a local-model lifecycle action."""

alias: str = Field(..., description="Catalog alias or org/repo id (e.g. gemma4-e4b)")
port: int | None = Field(
default=None,
ge=1,
le=65535,
description="Serve/stop port; the CLI default (8080) applies when omitted",
)


_MODEL_TASKS: dict[str, dict] = {}
_MODEL_TASK_LOG_LINES = 80


def _model_cli_available() -> bool:
"""Whether the ``artemis model`` command group exists in this build."""
import importlib.util

try:
return importlib.util.find_spec("artemis.interfaces.cli.commands.model") is not None
except Exception:
return False


def _task_view(key: str, task: dict) -> dict:
return {
"key": key,
"action": task["action"],
"alias": task["alias"],
"cmd": task["cmd"],
"status": task["status"],
"returncode": task["returncode"],
"started_at": task["started_at"],
"log": task["log"][-12:],
}


def _task_snapshot() -> dict:
return {k: _task_view(k, t) for k, t in _MODEL_TASKS.items()}


async def _drain_model_task(task: dict) -> None:
proc = task["process"]
try:
if proc.stdout is not None:
async for raw in proc.stdout:
line = raw.decode(errors="replace").rstrip()
if line:
task["log"].append(line)
del task["log"][:-_MODEL_TASK_LOG_LINES]
rc = await proc.wait()
except Exception as e: # pylint: disable=broad-exception-caught
rc = -1
task["log"].append(f"task monitor error: {e}")
task["returncode"] = rc
task["status"] = "done" if rc == 0 else "failed"


async def _start_model_task(action: str, alias: str, extra_args: list[str]) -> dict:
"""Spawn `artemis model <action>` as a subprocess with a capped log."""
import asyncio
import sys
import time

key = f"{action}:{alias}"
existing = _MODEL_TASKS.get(key)
if existing and existing["status"] == "running":
return _task_view(key, existing)

# ``stop`` is port-scoped on the CLI (no positional model arg).
cmd = [sys.executable, "-m", "artemis", "model", action]
if action != "stop":
cmd.append(alias)
cmd += extra_args
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to start `artemis model {action}`: {e}"
) from e

task = {
"action": action,
"alias": alias,
"cmd": " ".join(cmd),
"status": "running",
"returncode": None,
"started_at": time.time(),
"log": [],
"process": proc,
}
_MODEL_TASKS[key] = task
task["watcher"] = asyncio.create_task(_drain_model_task(task))
return _task_view(key, task)


def _require_model_cli() -> None:
if not _model_cli_available():
raise HTTPException(
status_code=501,
detail=(
"`artemis model` commands are not available in this build. "
"Run the shown command in a terminal, or manage the model with "
"your own OpenAI-compatible server."
),
)


_MODEL_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*(/[A-Za-z0-9][A-Za-z0-9_.-]*)?$")


def _require_model_ref(alias: str) -> None:
"""Reject aliases the managed CLI could never resolve.

``resolve_model_ref`` accepts catalog aliases and ``org/repo`` ids — the
same inputs ``artemis model pull`` takes. Anything else (bare junk, flag
injection like ``--port``, or argv that doesn't look like a model ref) is
refused before a subprocess is spawned.
"""
from artemis.config.local_models import resolve_model_ref

if not _MODEL_REF_RE.match(alias) or resolve_model_ref(alias) is None:
raise HTTPException(
status_code=400,
detail=(
f"Unknown model '{alias}'. `artemis model` accepts catalog "
"aliases (see `artemis model list`) or a Hugging Face "
"'org/repo' id."
),
)


@router.get("/local-models/status")
async def local_model_status():
"""Command availability plus in-flight/finished model task states."""
return {
"commands_available": _model_cli_available(),
"tasks": _task_snapshot(),
}


@router.post("/local-models/pull")
async def pull_local_model(payload: LocalModelActionRequest, request: Request):
"""Download model weights via `artemis model pull` (long-running)."""
_require_loopback_request(request, "Local model lifecycle is local-only.")
_require_model_cli()
alias = payload.alias.strip()
_require_model_ref(alias)
return await _start_model_task("pull", alias, [])


@router.post("/local-models/serve")
async def serve_local_model(payload: LocalModelActionRequest, request: Request):
"""Start the managed model server via `artemis model serve`."""
_require_loopback_request(request, "Local model lifecycle is local-only.")
_require_model_cli()
alias = payload.alias.strip()
_require_model_ref(alias)
extra = ["--port", str(payload.port)] if payload.port else []
return await _start_model_task("serve", alias, extra)


@router.post("/local-models/stop")
async def stop_local_model(payload: LocalModelActionRequest, request: Request):
"""Stop a managed model server via `artemis model stop`."""
_require_loopback_request(request, "Local model lifecycle is local-only.")
_require_model_cli()
extra = ["--port", str(payload.port)] if payload.port else []
return await _start_model_task("stop", payload.alias.strip(), extra)
83 changes: 82 additions & 1 deletion apps/showcase_ui/src/app/pages/home/home.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ <h3 class="step-title">AI Model Setup</h3>
<span class="choice-title">Custom Configuration</span>
</div>
<p class="choice-desc">Define models in <code>config/artemis.jsonc</code> & keys in <code>.env</code>
(OpenAI, Claude, Ollama).</p>
(OpenAI, Claude, Ollama — or a local model via <code>artemis model serve</code>).</p>
</div>
</div>
</div>
Expand Down Expand Up @@ -604,6 +604,87 @@ <h3 class="step-title">AI Model Setup</h3>
}
</div>

<!-- Section 1b: Local Model Endpoint (shown when a custom api_base is
configured, or the readiness probe is failing) -->
@if (localModelProbe(); as lm) {
@if (lm.status !== 'pass' || modelConfigEnv()?.default_model?.provider === 'custom') {
<div class="inspector-card" [class.card-warn]="lm.status !== 'pass'">
<div class="inspector-card-header">
<div class="inspector-header-left">
<span class="material-symbols-outlined header-icon">dns</span>
<div class="inspector-header-titles">
<span class="inspector-title">Local Model Endpoint</span>
<span class="inspector-subtitle">{{ lm.summary }}</span>
</div>
</div>
<div class="inspector-header-actions">
@if (lm.status === 'pass') {
<span class="badge-env-pass">
<span class="material-symbols-outlined">check_circle</span>
<span>Ready</span>
</span>
} @else {
<span class="badge-env-unset">
<span class="material-symbols-outlined">error</span>
<span>Action Needed</span>
</span>
}
</div>
</div>

<p class="inspector-desc">{{ lm.description }}</p>

<!-- Guided actions: Artemis commands are clickable when the CLI
exists in this build (fallback: copyable code box); hints text -->
@for (act of lm.actions; track act.label) {
@if (act.action_type === 'command') {
<div class="code-box small-box">
<code>{{ act.payload }}</code>
@if (modelActionArgs(act.payload); as args) {
@if (localModelCommandsAvailable()) {
@if (localModelTaskFor(args.action, args.alias)?.status === 'running') {
<span class="material-symbols-outlined spinning lm-spin">progress_activity</span>
<span class="lm-running-label">Running…</span>
} @else {
<button type="button" class="btn-run-cmd"
(click)="runLocalModelAction(args)">
<span class="material-symbols-outlined">play_arrow</span>
<span>Run</span>
</button>
}
}
}
<button type="button" class="btn-copy-code" [class.copied]="copiedId() === 'lm_' + act.label"
(click)="copyToClipboard(act.payload, 'lm_' + act.label)">
<span class="material-symbols-outlined">{{ copiedId() === 'lm_' + act.label ? 'check' : 'content_copy' }}</span>
<span>{{ copiedId() === 'lm_' + act.label ? 'Copied!' : 'Copy' }}</span>
</button>
</div>
@if (modelActionArgs(act.payload); as args) {
@if (localModelTaskFor(args.action, args.alias); as task) {
@if (task.status === 'failed' || task.log.length) {
<div class="lm-task-log" [class.lm-task-failed]="task.status === 'failed'">
@if (task.status === 'done') {
<div class="lm-log-line lm-log-done">✓ {{ act.label }} finished</div>
}
@if (task.status === 'failed') {
<div class="lm-log-line lm-log-failed">✗ {{ act.label }} failed (exit {{ task.returncode }}) — check the log below or run the command in a terminal</div>
}
@for (line of task.log.slice(-4); track $index) {
<div class="lm-log-line">{{ line }}</div>
}
</div>
}
}
}
} @else {
<p class="inspector-desc"><strong>{{ act.label }}:</strong> {{ act.payload }}</p>
}
}
</div>
}
}

<!-- Section 2: Environment API Keys (.env) -->
<div class="inspector-card">
<div class="inspector-card-header">
Expand Down
Loading
Loading