A multi-page local AI workbench. Each feature is a self-contained page that plugs into a shared framework — sidebar, global task queue, status bar, GPU lifecycle, styling — so adding a new tool costs almost no extra code.
Currently ships with:
Built on Google Mesop.
Tested on 2× NVIDIA RTX 3090 (24 GB each), CUDA 13, Ubuntu 24. A single 24 GB GPU can run NF4 12B but not BF16 12B. The 25.2B MoE requires both GPUs + CPU offload.
venv/)git clone https://github.com/fliaght/latelier.git
cd latelier
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
venv_marker/) — optionalMarker has its own PyTorch + Surya models and would otherwise downgrade the main venv's transformers. Keep it separate:
python3 -m venv venv_marker
source venv_marker/bin/activate
pip install --upgrade pip
pip install -r requirements-marker.txt
Marker models auto-download into ~/.cache/ on first parse (several hundred MB).
./restart_mesop.sh
The script:
mesop app_mesop.py process (SIGTERM, then SIGKILL after 2 s).nvidia-smi until VRAM is below 1 GB.0.0.0.0:7861 with unbuffered stdout.Open http://localhost:7861 (SSH-forward the port if remote).
Stop cleanly with pkill -TERM -f "mesop app_mesop.py". SIGTERM, SIGINT, and atexit handlers in app_mesop.py all call engine.clear_gpu() before the process dies.
latelier/
├── app_mesop.py # Entry: monkey-patches, @me.page, exit handlers
│
├── state.py # @me.stateclass State + SESSION_CACHE helpers
├── styles.py # Color tokens + style factories
├── tasks.py # TaskManager, @task, EarlyExit
├── shell.py # Page dataclass, sidebar, status, render_shell
├── engine.py # HuggingFace lifecycle (load/clear, tokenizers)
│
├── pages/
│ ├── __init__.py # PAGES tuple (registry)
│ ├── duel.py # ⚔ LLM Duel
│ ├── pdf_parse.py # 📄 PDF Parser (Marker subprocess)
│ ├── settings.py # ⚙ Settings
│ ├── performance.py # 📊 Performance (queue + log + resources)
│ └── about.py # ℹ About
│
├── requirements.txt # Main app deps
├── requirements-marker.txt # PDF Parser backend deps (separate venv)
├── restart_mesop.sh # Safe restart (kill + VRAM poll + start)
│
└── README.md # This file
archive/, reports/, CLAUDE.md, venv/, venv_marker/ are git-ignored.
Three concerns are strictly separated and imports flow in one direction only.
┌──────────────────────────────────────────────────────────────┐
│ app_mesop.py Entry: @me.page + signal handlers │
└──────────────────────────────────────────────────────────────┘
│
▼
┌───────────── STYLES ─────────────┐ ┌───────────── FRAMEWORK ──────────────┐
│ styles.py │ │ state.py — @me.stateclass State │
│ C (color tokens) │ │ + SESSION_CACHE helpers │
│ card_style / page_container / │ │ │
│ page_header / section_header │ │ tasks.py — TaskManager, @task, │
│ edge_button_style │ │ EarlyExit, FIFO queue │
└──────────────────────────────────┘ │ │
▲ │ shell.py — Page type, sidebar, │
│ │ floating status, │
│ │ render_shell(pages) │
│ │ │
│ │ engine.py — HuggingFace models: │
│ │ MODELS, tokenizers, │
│ │ load_model, clear_gpu │
│ └──────────────────────────────────────┘
│ ▲
└────────────────────────────────────────┘
│
▼
┌───────────── PAGES (one module each) ────────────────────┐
│ pages/duel.py, pdf_parse.py, settings.py, │
│ performance.py, about.py │
│ pages/__init__.py — PAGES = (...,) │
└──────────────────────────────────────────────────────────┘
pages/ (that would cycle). shell.render_shell(pages) takes the registered tuple as an argument.click "Send" → pages/duel.py::_on_send
→ yield from _run_duel()
→ @task wrapper submits QueueEntry, sets is_busy=True
→ polls TASK_MANAGER.try_start() every 0.3 s (yields)
→ when next: load LEFT, stream, unload; load RIGHT, stream, unload
→ TASK_MANAGER.finish()
→ is_busy=False, final yield → sidebar re-enables
At most one task runs across the whole process. Other sessions submitting tasks see "Queued (position N)..." until their turn.
pages/<name>.pyimport mesop as me
from shell import Page
from state import State
from styles import card_style, page_container_style, page_header, page_inner_style
def _render():
state = me.state(State)
with me.box(style=page_container_style()):
with me.box(style=page_inner_style()):
page_header("My Page", "Short description.")
with me.box(style=card_style()):
me.text("Content here.")
PAGE = Page(key="my", title="My Page", icon="✨", render=_render)
@taskfrom tasks import EarlyExit, task
@task("My Work")
def _run_work(cancel_event):
state = me.state(State)
state.status = "Step 1..."
yield
if cancel_event.is_set():
raise EarlyExit()
# ... do work, yield periodically, check cancel_event ...
Event handlers invoke the worker via yield from:
def _on_start(e: me.ClickEvent):
yield from _run_work()
from state import session_get, session_set
KEY_MY_OUTPUT = "my.output"
def _render():
state = me.state(State)
output = session_get(state, KEY_MY_OUTPUT, "") or ""
me.markdown(output)
pages/__init__.pyfrom . import duel, pdf_parse, my_page, settings, performance, about
PAGES = (
duel.PAGE,
pdf_parse.PAGE,
my_page.PAGE,
settings.PAGE,
performance.PAGE,
about.PAGE,
)
Done. The sidebar picks up the new nav item automatically, the floating status pill shows state.status, the Performance page shows your task entries with working Cancel buttons, and all Mesop rendering gotchas are handled by the shared helpers.
| Concern | API |
|---|---|
| Progress messages | state.status = "..." — floating pill picks colors from keywords (ready, loading, done, error, queue, cancel). |
| Performance log | session_set(state, KEY_PERF_LOG, prev + "\n" + new_line). |
| Long task | @task("Label") on a generator that takes cancel_event as first arg. Raise EarlyExit to abort cleanly. |
| Cancel the current session's task | TASK_MANAGER.cancel(state.my_task_id). |
| Large output | Use session_set / session_get — never store multi-KB strings on State, it multiplies re-render cost. |
| Load a HuggingFace model | engine.load_model(model_id, quant) paired with engine.clear_gpu() in a finally block. Let load_model raise ConfigError if the combo isn't supported — don't second-guess the compatibility matrix. |
What you never reimplement:
engine.clear_gpu handles accelerate's quirks)state in a module-level closure — get it inside each event handler with me.state(State).lambda, no functools.partial, no closure factories. Mesop identifies handlers by __qualname__ and collapses same-named ones.SESSION_CACHE via session_get / session_set.key — e.g., key=f"send-btn-{'busy' if state.is_busy else 'ready'}".@task body, use EarlyExit — never return — return skips the decorator's post-finally re-render, leaving the sidebar stuck disabled.pages/../restart_mesop.sh — Mesop's hot reload is not reliable.GPL-3.0-or-later. See LICENSE for the full text.
Use, modify, and redistribute freely; if you distribute a modified version (including over a network), you must release your modifications under the same license.
2 commits
Python
98.8%
Shell
1.2%
A multi-page local AI workbench. Each feature is a self-contained page that plugs into a shared framework — sidebar, global task queue, status bar, GPU lifecycle, styling — so adding a new tool costs almost no extra code.
Currently ships with:
Built on Google Mesop.
Tested on 2× NVIDIA RTX 3090 (24 GB each), CUDA 13, Ubuntu 24. A single 24 GB GPU can run NF4 12B but not BF16 12B. The 25.2B MoE requires both GPUs + CPU offload.
venv/)git clone https://github.com/fliaght/latelier.git
cd latelier
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
venv_marker/) — optionalMarker has its own PyTorch + Surya models and would otherwise downgrade the main venv's transformers. Keep it separate:
python3 -m venv venv_marker
source venv_marker/bin/activate
pip install --upgrade pip
pip install -r requirements-marker.txt
Marker models auto-download into ~/.cache/ on first parse (several hundred MB).
./restart_mesop.sh
The script:
mesop app_mesop.py process (SIGTERM, then SIGKILL after 2 s).nvidia-smi until VRAM is below 1 GB.0.0.0.0:7861 with unbuffered stdout.Open http://localhost:7861 (SSH-forward the port if remote).
Stop cleanly with pkill -TERM -f "mesop app_mesop.py". SIGTERM, SIGINT, and atexit handlers in app_mesop.py all call engine.clear_gpu() before the process dies.
latelier/
├── app_mesop.py # Entry: monkey-patches, @me.page, exit handlers
│
├── state.py # @me.stateclass State + SESSION_CACHE helpers
├── styles.py # Color tokens + style factories
├── tasks.py # TaskManager, @task, EarlyExit
├── shell.py # Page dataclass, sidebar, status, render_shell
├── engine.py # HuggingFace lifecycle (load/clear, tokenizers)
│
├── pages/
│ ├── __init__.py # PAGES tuple (registry)
│ ├── duel.py # ⚔ LLM Duel
│ ├── pdf_parse.py # 📄 PDF Parser (Marker subprocess)
│ ├── settings.py # ⚙ Settings
│ ├── performance.py # 📊 Performance (queue + log + resources)
│ └── about.py # ℹ About
│
├── requirements.txt # Main app deps
├── requirements-marker.txt # PDF Parser backend deps (separate venv)
├── restart_mesop.sh # Safe restart (kill + VRAM poll + start)
│
└── README.md # This file
archive/, reports/, CLAUDE.md, venv/, venv_marker/ are git-ignored.
Three concerns are strictly separated and imports flow in one direction only.
┌──────────────────────────────────────────────────────────────┐
│ app_mesop.py Entry: @me.page + signal handlers │
└──────────────────────────────────────────────────────────────┘
│
▼
┌───────────── STYLES ─────────────┐ ┌───────────── FRAMEWORK ──────────────┐
│ styles.py │ │ state.py — @me.stateclass State │
│ C (color tokens) │ │ + SESSION_CACHE helpers │
│ card_style / page_container / │ │ │
│ page_header / section_header │ │ tasks.py — TaskManager, @task, │
│ edge_button_style │ │ EarlyExit, FIFO queue │
└──────────────────────────────────┘ │ │
▲ │ shell.py — Page type, sidebar, │
│ │ floating status, │
│ │ render_shell(pages) │
│ │ │
│ │ engine.py — HuggingFace models: │
│ │ MODELS, tokenizers, │
│ │ load_model, clear_gpu │
│ └──────────────────────────────────────┘
│ ▲
└────────────────────────────────────────┘
│
▼
┌───────────── PAGES (one module each) ────────────────────┐
│ pages/duel.py, pdf_parse.py, settings.py, │
│ performance.py, about.py │
│ pages/__init__.py — PAGES = (...,) │
└──────────────────────────────────────────────────────────┘
pages/ (that would cycle). shell.render_shell(pages) takes the registered tuple as an argument.click "Send" → pages/duel.py::_on_send
→ yield from _run_duel()
→ @task wrapper submits QueueEntry, sets is_busy=True
→ polls TASK_MANAGER.try_start() every 0.3 s (yields)
→ when next: load LEFT, stream, unload; load RIGHT, stream, unload
→ TASK_MANAGER.finish()
→ is_busy=False, final yield → sidebar re-enables
At most one task runs across the whole process. Other sessions submitting tasks see "Queued (position N)..." until their turn.
pages/<name>.pyimport mesop as me
from shell import Page
from state import State
from styles import card_style, page_container_style, page_header, page_inner_style
def _render():
state = me.state(State)
with me.box(style=page_container_style()):
with me.box(style=page_inner_style()):
page_header("My Page", "Short description.")
with me.box(style=card_style()):
me.text("Content here.")
PAGE = Page(key="my", title="My Page", icon="✨", render=_render)
@taskfrom tasks import EarlyExit, task
@task("My Work")
def _run_work(cancel_event):
state = me.state(State)
state.status = "Step 1..."
yield
if cancel_event.is_set():
raise EarlyExit()
# ... do work, yield periodically, check cancel_event ...
Event handlers invoke the worker via yield from:
def _on_start(e: me.ClickEvent):
yield from _run_work()
from state import session_get, session_set
KEY_MY_OUTPUT = "my.output"
def _render():
state = me.state(State)
output = session_get(state, KEY_MY_OUTPUT, "") or ""
me.markdown(output)
pages/__init__.pyfrom . import duel, pdf_parse, my_page, settings, performance, about
PAGES = (
duel.PAGE,
pdf_parse.PAGE,
my_page.PAGE,
settings.PAGE,
performance.PAGE,
about.PAGE,
)
Done. The sidebar picks up the new nav item automatically, the floating status pill shows state.status, the Performance page shows your task entries with working Cancel buttons, and all Mesop rendering gotchas are handled by the shared helpers.
| Concern | API |
|---|---|
| Progress messages | state.status = "..." — floating pill picks colors from keywords (ready, loading, done, error, queue, cancel). |
| Performance log | session_set(state, KEY_PERF_LOG, prev + "\n" + new_line). |
| Long task | @task("Label") on a generator that takes cancel_event as first arg. Raise EarlyExit to abort cleanly. |
| Cancel the current session's task | TASK_MANAGER.cancel(state.my_task_id). |
| Large output | Use session_set / session_get — never store multi-KB strings on State, it multiplies re-render cost. |
| Load a HuggingFace model | engine.load_model(model_id, quant) paired with engine.clear_gpu() in a finally block. Let load_model raise ConfigError if the combo isn't supported — don't second-guess the compatibility matrix. |
What you never reimplement:
engine.clear_gpu handles accelerate's quirks)state in a module-level closure — get it inside each event handler with me.state(State).lambda, no functools.partial, no closure factories. Mesop identifies handlers by __qualname__ and collapses same-named ones.SESSION_CACHE via session_get / session_set.key — e.g., key=f"send-btn-{'busy' if state.is_busy else 'ready'}".@task body, use EarlyExit — never return — return skips the decorator's post-finally re-render, leaving the sidebar stuck disabled.pages/../restart_mesop.sh — Mesop's hot reload is not reliable.GPL-3.0-or-later. See LICENSE for the full text.
Use, modify, and redistribute freely; if you distribute a modified version (including over a network), you must release your modifications under the same license.
2 commits
Python
98.8%
Shell
1.2%