Skip to content

feat: Machine-Hosted PWA — Direct Mode without MeticAI backend#316

Merged
hessius merged 41 commits intoversion/2.4.0from
feat/machine-hosted-pwa
Mar 25, 2026
Merged

feat: Machine-Hosted PWA — Direct Mode without MeticAI backend#316
hessius merged 41 commits intoversion/2.4.0from
feat/machine-hosted-pwa

Conversation

@hessius
Copy link
Copy Markdown
Owner

@hessius hessius commented Mar 25, 2026

Machine-Hosted PWA

Run MeticAI's web UI directly on the Meticulous machine without the MeticAI Docker container. The PWA intercepts all API calls and translates them to native Meticulous /api/v1/ endpoints.

What's included

  • Global fetch interceptor (main.tsx) with ~30 endpoint translations
  • Direct Socket.IO telemetry — real-time shot data mapped to MeticAI's format
  • Profile catalogue from machine's native profiles
  • Shot history translated from machine format
  • Run profiles with stop → load → start flow (no purge)
  • Preheat & schedule support via client-side timers
  • Build scripts for machine deployment (build:machine, install-meticai.sh)
  • CI workflow for automated PWA builds

Limitations (by design)

  • No AI features (no Gemini API key on machine)
  • No profile generation or analysis
  • No cross-device sync
  • History limited to machine's local storage

Related

@hessius hessius added this to the 2.4 milestone Mar 25, 2026
@hessius hessius requested a review from Copilot March 25, 2026 14:13
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a “machine-hosted PWA” deployment path where MeticAI’s web UI can run as static files served by the Meticulous machine (no MeticAI Docker backend), introducing direct-mode adapters for machine comms + browser-based persistence and AI.

Changes:

  • Introduces direct/proxy mode detection, feature flags, and a MachineService abstraction with a DirectAdapter using the machine’s native REST + Socket.IO APIs.
  • Adds browser-side persistence (IndexedDB via idb) and ports/introduces AI prompt + validation utilities for direct/PWA operation.
  • Adds install/validate/update/uninstall scripts and CI workflows to build and ship a machine PWA tarball.

Reviewed changes

Copilot reviewed 64 out of 66 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
scripts/machine/validate-meticai.sh Installation validation utility for machine-hosted PWA
scripts/machine/update-meticai.sh Delegates updates to installer script
scripts/machine/uninstall-meticai.sh Removes PWA install + backups from machine
scripts/install-direct.sh Installer for deploying static PWA to machine + patching Tornado route
apps/web/vite.config.ts Adds direct/proxy build mode behavior (base path, chunking)
apps/web/src/vite-end.d.ts Types for import.meta.env direct/proxy vars
apps/web/src/views/StartView.tsx Adds direct-mode behavior (skip backend author fetch, profile catalogue entry)
apps/web/src/services/storage/useStorageMigration.ts Initializes IndexedDB only in direct mode
apps/web/src/services/storage/index.ts Storage barrel exports
apps/web/src/services/storage/AppDatabase.ts IndexedDB schema + helpers (settings, cache, sessions, images)
apps/web/src/services/storage/AppDatabase.test.ts Unit tests for IndexedDB layer
apps/web/src/services/machine/index.ts Exports expanded machine service types + adapters
apps/web/src/services/machine/MeticAIAdapter.ts Refactors proxy adapter to satisfy expanded MachineService interface
apps/web/src/services/machine/MachineServiceContext.tsx Provider selects DirectAdapter vs proxy and connects in direct mode
apps/web/src/services/machine/MachineService.ts Expands MachineService to include profiles/telemetry/history/settings
apps/web/src/services/machine/DirectAdapter.ts Direct machine adapter using @meticulous-home/espresso-api + Socket.IO
apps/web/src/services/ai/prompts/prompts.test.ts Tests for prompt builders
apps/web/src/services/ai/prompts/index.ts TypeScript prompt builder implementations
apps/web/src/services/ai/profilePromptFull.ts Full profile prompt + validate/retry loop shared by browser AI
apps/web/src/services/ai/index.ts AI services barrel exports
apps/web/src/services/ai/ProxyAIService.ts Backend-delegating AI service implementation
apps/web/src/services/ai/BrowserAIService.ts Browser Gemini SDK AI service implementation
apps/web/src/services/ai/AIServiceProvider.tsx React context/provider for AI service injection
apps/web/src/services/ai/AIService.ts AI service interface + request/response types
apps/web/src/lib/staticProfileDescription.ts Non-AI profile description generator (server parity)
apps/web/src/lib/profileValidator.ts Browser-side OEPF validation (server parity)
apps/web/src/lib/network-url.ts Skips backend network IP discovery in direct mode
apps/web/src/lib/machineMode.ts Detects direct vs proxy mode and default machine URL
apps/web/src/lib/machineMode.test.ts Tests for mode detection + default URL
apps/web/src/lib/featureFlags.ts Feature gating between proxy/direct deployments
apps/web/src/lib/featureFlags.test.ts Tests for feature flag behavior/caching
apps/web/src/lib/directModeAI.ts Client-side Gemini REST integration (direct mode)
apps/web/src/lib/config.ts Direct-mode config defaults + BASE_URL-aware config fetch
apps/web/src/lib/config.test.ts Adjusts config tests for mode/env setup
apps/web/src/index.css iOS PWA safe-area padding adjustments
apps/web/src/i18n/config.ts BASE_URL-aware locale loading path
apps/web/src/hooks/useMachineTelemetry.ts Unified telemetry hook (proxy WS vs direct Socket.IO)
apps/web/src/hooks/useMachineService.ts Re-export hook from service context
apps/web/src/components/SettingsView.tsx Direct-mode localStorage settings + feature-gated sections
apps/web/src/components/RunShotView.tsx Feature-gates scheduling UI and adjusts navigation behavior
apps/web/src/components/ProfileCatalogueView.tsx Adds direct-mode UI affordances (click-to-view, short descriptions, image URLs)
apps/web/src/components/ProfileBreakdown.tsx Hardens handling of missing keys / dynamics formats
apps/web/src/components/PourOverView.tsx Null-safe recipe preference reads
apps/web/src/components/MeticAILogo.tsx BASE_URL-aware logo asset paths
apps/web/src/components/LiveShotView.tsx Disables image-proxy usage in direct mode
apps/web/src/components/ControlCenterExpanded.tsx Disables image-proxy usage in direct mode
apps/web/src/components/ControlCenter.tsx Disables image-proxy usage in direct mode
apps/web/src/components/BetaBanner.tsx Skips backend beta status fetch in direct mode
apps/web/src/App.tsx Switches to unified telemetry, adds storage migration, direct-mode routing
apps/web/public/manifest.json Makes manifest start/scope + icon paths relative for subpath hosting
apps/web/public/locales/en/translation.json Adds new keys for “done” and profile catalogue messages
apps/web/public/locales/sv/translation.json Adds new keys for “done” and profile catalogue messages
apps/web/public/locales/de/translation.json Adds new keys for “done” and profile catalogue messages
apps/web/public/locales/es/translation.json Adds new keys for “done” and profile catalogue messages
apps/web/public/locales/fr/translation.json Adds new keys for “done” and profile catalogue messages
apps/web/public/locales/it/translation.json Adds new keys for “done” and profile catalogue messages
apps/web/package.json Adds direct/proxy build scripts, direct-mode tests, new deps (genai, espresso-api, idb, etc.)
apps/web/index.html Updates manifest comment (but still references manifest)
apps/web/.gitignore Ignores generated meticai-web.tar.gz
MACHINE_PWA.md Documentation for installing/running machine-hosted PWA
.github/workflows/tests.yml Adds direct-mode test run in CI
.github/workflows/build-machine-pwa.yml New workflow to build and upload machine PWA artifact
.github/workflows/auto-release.yml Attaches machine PWA tarball to GitHub releases
.github/skills/testing.md Fixes Docker compose command formatting

@hessius hessius force-pushed the feat/machine-hosted-pwa branch from 41afc36 to 478b7d2 Compare March 25, 2026 14:44
hessius and others added 25 commits March 25, 2026 15:45
- Expand MachineService interface: profiles, telemetry, history, settings
- Create ProxyAdapter (wraps MeticAI backend, Docker mode)
- Create DirectAdapter (uses @meticulous-home/espresso-api, PWA mode)
- Add MachineServiceProvider with mode selection (direct/proxy)
- Add machineMode utility (build-time + runtime detection)
- Install espresso-api, espresso-profile, @google/genai, idb, fzstd
- Install vite-plugin-pwa, fake-indexeddb (dev)
- Add VITE_MACHINE_MODE/VITE_DEFAULT_MACHINE_URL env type declarations
- All 320 tests pass, 0 lint errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Create AIService interface (profile gen, shot analysis, image gen, recommendations, dial-in)
- Create ProxyAIService wrapping MeticAI backend endpoints
- Create BrowserAIService using @google/genai SDK directly
- Port prompt_builder.py to TypeScript (image, profile, analysis, recommendation, dial-in prompts)
- Create AIServiceProvider with mode selection (direct/proxy)
- All 320 tests pass, 0 lint errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Create AppDatabase with idb library: settings, annotations, AI cache, pour-over, dial-in, profile images
- TTL-based AI cache (7-day expiry) with auto-cleanup
- LRU eviction for profile images (50 MB cap)
- Storage migration hook for first-run initialization
- All 320 tests pass, 0 lint errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Create useMachineTelemetry hook supporting both proxy and direct modes
- Proxy mode: WebSocket to MeticAI backend /api/ws/live (existing pattern)
- Direct mode: Socket.IO via MachineService (espresso-api events)
- Field mapping from espresso-api StatusData to MachineState
- Exponential backoff reconnection, staleness detection
- All 320 tests pass, 0 lint errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Create featureFlags module with proxy/direct mode flag sets
- Proxy mode: all features enabled (Docker backend)
- Direct mode: disable mDNS, scheduled shots, system mgmt, tailscale, MCP, cloud sync
- Direct mode: enable PWA install prompt, AI via browser SDK
- hasFeature() utility for conditional rendering
- All 320 tests pass, 0 lint errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add build:docker and build:machine scripts to package.json
- Add test:direct script for PWA-mode testing
- Configure vite-plugin-pwa with workbox caching strategies:
  - Static assets: CacheFirst
  - Machine API: NetworkFirst (5s timeout)
- PWA manifest with standalone display mode
- Machine build uses /meticai/ base path for Tornado static handler
- Manual chunk splitting: recharts, framer-motion, machine-api, genai
- Docker build: 5.8 MB output, Machine build: 5.8 MB output
- Both builds verified, all 320 tests pass, 0 lint errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- install-meticai.sh: resource checks, download, backup, extract
- validate-meticai.sh: verify files, routes, API connectivity
- update-meticai.sh: delegates to installer with backup
- uninstall-meticai.sh: interactive cleanup with confirmation
- All scripts include Tornado route configuration instructions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add build-machine-pwa.yml: builds VITE_MACHINE_MODE=direct, creates
  meticai-web.tar.gz artifact, runs lint and direct-mode tests
- Update auto-release.yml: build PWA tarball and attach to GitHub release
  with machine install instructions
- Update tests.yml: add test:direct step, include feature branch in CI
- All 320 tests pass in both proxy and direct modes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- machineMode.test.ts: 13 tests for mode detection, env vars, port detection
- featureFlags.test.ts: 13 tests for proxy/direct flags, hasFeature, caching
- AppDatabase.test.ts: 27 tests for IndexedDB CRUD, TTL cache, LRU eviction
- prompts.test.ts: 42 tests for all 6 prompt builders, tag system, safety

Total: 95 new tests (320 → 415), all passing in both modes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Critical wiring fixes:
- Wire AIServiceProvider into main.tsx component tree
- Replace useWebSocket with useMachineTelemetry in App.tsx
- Call useStorageMigration in App.tsx for IndexedDB init
- Gate Tailscale and Updates UI sections behind feature flags

Build fixes:
- Add maximumFileSizeToCacheInBytes to Workbox config (logo.png > 2MB)
- Exclude static manifest.json from Workbox precache glob
- Remove PNG from precache glob (large assets)

AI service improvements:
- Add wrapApiError() for user-friendly Gemini error messages (429/401/404)
- Wrap all generateContent/generateImages calls in try/catch
- Port full dial-in prompt with coffee params and iteration history

Other fixes:
- Map brew_head_temperature in direct mode telemetry
- Update LRU timestamp on profile image reads (true LRU semantics)
- Fix lint errors in test files (unused imports/vars)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- machineMode.ts: add comment clarifying meticulous.local fallback is
  proxy-mode only; direct mode uses window.location.host (same origin)
- install-meticai.sh: replace hardcoded meticulous.local with dynamic
  hostname detection; add note about randomized hostnames
- validate-meticai.sh: add CPU load average to system resource report

The Meticulous machine uses randomized hostnames (e.g.
meticulous-abc123.local), not a fixed meticulous.local. Since the PWA
is served from the machine itself, direct mode correctly uses
window.location.host. The install script now shows the actual hostname.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Meticulous machines don't have curl installed. All machine scripts now
use wget as primary HTTP tool with curl as fallback. Added fetch() and
download() helpers to install script, http_status() helper to validate
script. No new dependencies required — wget is standard on the machine.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… mode

The Meticulous machine has neither curl nor wget — only busybox and
python3. Updated HTTP helpers to try: busybox wget → python3 urllib →
curl → wget. Added --local flag for SCP-based installs where the
tarball is pre-copied to the machine. This is the recommended approach
for testing since no HTTP tool needs to be installed.

Usage: bash install-meticai.sh --local /tmp/meticai-web.tar.gz

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use TARBALL variable to reference the source directly instead of
copying to /tmp/meticai-web.tar.gz. Also preserves the user's
original file when using --local (only cleans up downloaded files).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The @meticulous-home/espresso-api package is CJS-only (exports.default).
Rolldown's production bundle wraps the default export differently than
dev mode, causing 'Object is not a constructor' at runtime. Added
interop that handles both cases: direct function or wrapped .default.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The inline env var in 'VAR=x cmd1 && cmd2' only applies to cmd1.
Using 'export VAR=x && cmd1 && cmd2' ensures vite build sees the
VITE_MACHINE_MODE=direct flag and applies the /meticai/ base path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Prefix logo, config.json, i18n loadPath with import.meta.env.BASE_URL
- Guard MeticAI proxy API calls (/api/settings, /api/history, /api/version)
  with isDirectMode() checks — these endpoints don't exist on the machine
- SettingsView: load/save settings from localStorage in direct mode
- Install script: skip backups for --local reinstalls, clean stale backups
- Recover ~12MB from accumulated backup directories on machine

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In direct mode, the Meticulous machine only has /api/v1/ endpoints.
MeticAI proxy endpoints (/api/settings, /api/machine/*, /api/history,
etc.) don't exist. Rather than guarding 80+ individual fetch calls,
install a global fetch interceptor in main.tsx that:

- Silently returns 404 for /api/<non-v1> paths (no network request)
- Passes through /api/v1/ paths to the Meticulous backend
- Passes through espresso-api calls (which use axios, not fetch)

Also skip config.json fetch entirely in direct mode (no file exists).

Verified on machine: all assets load, no 404 errors, machine API works,
storage stable at 2478 MB.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The fetch interceptor now translates key MeticAI proxy endpoints to
their Meticulous-native /api/v1/ equivalents:

- /api/machine/profiles → /api/v1/profile/list (wraps in {profiles})
- /api/machine/profile/:id/json → /api/v1/profile/get/:id
- /api/machine/status → synthetic idle response (real state via Socket.IO)
- /api/last-shot → /api/v1/history/last
- /api/history → /api/v1/history (wraps in {entries, total})
- All other proxy paths → 200 with empty JSON

Also guard profile image-proxy URLs (set via <img src>, bypasses
fetch interceptor) with isDirectMode() in ControlCenter,
ControlCenterExpanded, LiveShotView, and App.tsx.

Machine has 18 profiles that should now be visible through the
translated API layer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t mode interceptor

- Run profile: home → poll load (2s intervals, 10 retries) → start
- Run with overrides: same flow (overrides not supported in direct mode)
- Profile import from file: POST to /api/v1/profile/save
- Profile import from machine: no-op (already on machine)
- Import all: no-op success response
- Delete profile: translate to /api/v1/profile/delete/:id
- Machine commands: start/stop/load-profile → /api/v1/action/*
- jsonResponse helper now supports status codes

Verified on live machine: home→load takes ~4s, full run flow works.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three bugs fixed in direct mode interceptor:

1. Run profile no longer sends 'home' (which triggered a purge).
   Instead: try load → if busy, stop → retry with 2s backoff.

2. Preheat: POST /api/machine/preheat → GET /api/v1/action/preheat

3. Schedule shot: POST /api/machine/schedule-shot → preheat (if
   requested) + setTimeout for delayed profile load → start

Also added:
- /api/machine/profiles/orphaned → empty list (no MeticAI DB)
- /api/profiles/sync/status → zero counts
- POST /api/profiles/sync → no-op

Verified on live machine: no purge, preheat works, stop+load in ~2s.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Direct mode telemetry fixes:
- Convert profile_time from ms to seconds (shot timer was 1000x too high)
- Use data.profile/loaded_profile for active_profile (not data.name
  which is the stage name during brewing)
- Fetch target_weight from loaded profile's final_weight via API
- Map data.name to state field (shows stage like 'heating', 'preinfusion')

Profile catalogue:
- Add in_history/has_description fields to profile list response
- Wrap profile JSON in {profile: data} to match expected format

History:
- Translate machine history entries to MeticAI HistoryEntry format
  (id, created_at, profile_name, coffee_analysis, etc.)
- Convert epoch timestamp to ISO date string
- Same for /api/last-shot endpoint

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… shot analysis

- Fix profile load handler reading URL string as body instead of init.body
- Add retry logic for 409 'machine is busy' responses (stop + retry)
- Fix /api/shots/analyze interceptor for shot analysis in direct mode
- Expand API translation layer for machine-hosted PWA
- BrowserAIService uses Gemini SDK directly in browser for profile generation
- DirectAdapter, MachineService, MeticAIAdapter direct mode support
- Add profile validator and prompt for browser-based generation
hessius and others added 11 commits March 25, 2026 15:52
- Hide Machine IP setting in direct mode, hide MQTT bridge in direct mode
- Profile catalogue navigates back to start view in direct mode
- Profile catalogue edit mode improvements for direct mode
- Telemetry unit conversion fixes (ms→s)
- ProfileBreakdown, RunShotView, PourOverView direct mode adjustments
- StartView profile catalogue navigation
- Add profileCatalogue.loaded, profileCatalogue.loadFailed keys
- Add pwa/direct mode related translation strings
Move padding-top from body to #root so the ambient background
gradient renders behind the notch while content stays below it.
…gation

- manifest.json: use relative paths for start_url, scope, and icons
  to work correctly with /meticai/ base path
- machineMode: detect direct mode via build-time env var for port 80
  compatibility (nginx proxies API/Socket.IO to 8080)
- ProfileCatalogueView: replace handleLoadProfile with onViewProfile
  navigation — clicking a profile now shows the detail view instead
  of loading/starting it on the machine
- App.tsx: add handleViewMachineProfile handler that fetches profile
  JSON and navigates to ProfileDetailView with back-to-catalogue support
Port _build_static_profile_description from the Python backend to
TypeScript (apps/web/src/lib/staticProfileDescription.ts). On startup
in direct mode, main.tsx fetches each profile's JSON in the background
and generates static descriptions (Description / Preparation / Why
This Works / Special Notes format). Descriptions are cached in
localStorage and displayed in:

- Profile catalogue: short description snippet under each card
- Profile detail view: full sectioned description via parseProfileSections

Machine-provided descriptions (display.shortDescription) take priority
over generated ones in the catalogue.
- package.json: build:machine/build:docker no longer use 'export' for
  VITE_MACHINE_MODE, preventing env var leak to subsequent shell commands
- machineMode.test.ts: fix stubLocation hostname derivation from host
  param, add clearMachineMode helper, ensure env-clean test isolation
- config.test.ts: stub VITE_MACHINE_MODE to ensure proxy mode in tests
- vite.config.ts: remove unused VitePWA import (lint error)
In machine-hosted mode, all profiles are inherently both in the
catalogue and on the machine, making the badge unnecessary noise.
- MACHINE_PWA.md: full install instructions for testers (no build needed)
- install-meticai.sh: auto-patch web_ui.py and restart backend instead
  of printing manual instructions
- scripts/machine/install-meticai.sh → scripts/install-direct.sh
- Clarifies this is for the direct/machine-hosted mode
- Added TODO(release) for updating URLs before merge to main
- Updated all references in MACHINE_PWA.md and auto-release.yml
Replace hardcoded root password with guidance to consult machine
documentation. Credentials should never be committed to source control.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@hessius hessius force-pushed the feat/machine-hosted-pwa branch from 478b7d2 to 28d2bcc Compare March 25, 2026 14:53
hessius and others added 3 commits March 25, 2026 15:58
- Create shared STORAGE_KEYS constants (lib/constants.ts) to prevent
  localStorage key mismatches between BrowserAIService and SettingsView
- Fix manifest.json absolute path in index.html — use %BASE_URL% for
  subpath hosting under /meticai/
- Fix VitePWA comment (plugin is disabled, update to accurate text)
- Handle unhandled promise rejection in MachineServiceContext connect()
- Document DirectAdapter.connect() URL parameter behavior
- Guard validate-meticai.sh find commands with directory existence check

Addresses: Copilot review comments C2, C3, C5, C6, C7

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Refactor useMachineTelemetry to always call both hooks (fixes
  conditional hook violation / rules-of-hooks). Inactive hook receives
  enabled=false and returns INITIAL_STATE with no side effects.
- Replace hard-coded error strings in BrowserAIService with typed
  AIServiceError class and error codes (API_KEY_MISSING, QUOTA_EXCEEDED,
  etc.) per i18n convention — UI layer translates via t().
- Update CI workflow branches from stale 'version/2.0.0' to 'version/*'
  glob pattern for future-proof version branch matching.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Fix history.spec.ts:45 — use 'Profile Catalogue' text locator instead
  of generic h2/h3 selector that matched hidden StartView greeting
- Replace fake schedule-shot success with honest 501 response in direct
  mode interceptor (feature flag already hides the UI, but the endpoint
  should not pretend to work via unreliable setTimeout)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ram in DirectAdapter

- RunShotView.tsx: Add missing closing brace for inner if(selectedProfile)
  block lost during merge conflict resolution (caused TS1005 try/catch mismatch)
- DirectAdapter.ts: Remove unused _url parameter from connect() (lint error)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 67 out of 69 changed files in this pull request and generated 9 comments.

Comment on lines +202 to +210
const images = response.generatedImages
if (!images || images.length === 0) {
throw new Error('No image generated')
}

const imageData = images[0].image
if (!imageData?.imageBytes) {
throw new Error('No image data in response')
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateImage() throws generic Error instances with user-facing English messages (“No image generated”, “No image data in response”). Elsewhere this service uses typed AIServiceError codes so the UI can translate. For consistency (and i18n), throw AIServiceError with a specific code here as well, or map these cases to an existing code.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c8cc045. Added IMAGE_GENERATION_FAILED and IMAGE_NO_DATA to AIErrorCode type union. Both throw new Error() calls replaced with throw new AIServiceError() — consistent with the typed error pattern used throughout the service.

Comment on lines +197 to +206
if (isDirectMode()) {
// In direct mode, load from localStorage
setSettings({
geminiApiKey: localStorage.getItem('meticai-gemini-key') || '',
meticulousIp: window.location.hostname,
authorName: localStorage.getItem('meticai-author-name') || '',
mqttEnabled: true,
geminiApiKeyMasked: false,
geminiApiKeyConfigured: Boolean(localStorage.getItem('meticai-gemini-key')?.trim()),
})
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct-mode settings read/write uses string literals for localStorage keys ('meticai-gemini-key', 'meticai-author-name'). This duplicates the new STORAGE_KEYS single-source-of-truth and risks future key drift. Import STORAGE_KEYS and use STORAGE_KEYS.GEMINI_API_KEY / STORAGE_KEYS.AUTHOR_NAME instead.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c8cc045. Imported STORAGE_KEYS from @/lib/constants and replaced all hard-coded 'meticai-gemini-key' / 'meticai-author-name' string literals in the loadSettings path with STORAGE_KEYS.GEMINI_API_KEY / STORAGE_KEYS.AUTHOR_NAME.

Comment on lines +356 to +363
if (isDirectMode()) {
// In direct mode, persist to localStorage
if (settings.geminiApiKey && !settings.geminiApiKey.startsWith('*')) {
localStorage.setItem('meticai-gemini-key', settings.geminiApiKey)
}
if (settings.authorName) {
localStorage.setItem('meticai-author-name', settings.authorName)
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct-mode handleSave() persists to localStorage using hard-coded keys ('meticai-gemini-key', 'meticai-author-name'). To avoid mismatches with other direct-mode code, use the shared STORAGE_KEYS constants here too.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c8cc045. Same fix applied to handleSave() — all localStorage.setItem() calls now use STORAGE_KEYS constants.

Comment on lines +56 to +64
// Connect in direct mode
useEffect(() => {
if (value.name === 'DirectAdapter') {
value.connect(getDefaultMachineUrl()).catch((err) => {
console.error('[MachineService] Failed to connect:', err)
})
return () => value.disconnect()
}
}, [value])
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The provider’s connect/disconnect effect is gated on value.name === 'DirectAdapter', which is a brittle string check (and won’t run for custom direct implementations). Since MachineService always defines connect()/disconnect() and MeticAIAdapter.connect() is a safe no-op, consider connecting based on getMachineMode() (or just always calling connect()/disconnect() in the effect) instead of relying on the name string.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c8cc045. Removed the value.name === 'DirectAdapter' guard. The effect now always calls connect()/disconnect()MeticAIAdapter.connect() is a documented safe no-op (async () => {}), so this works correctly for both adapters and any future implementations.

Comment on lines +64 to +87
function setupSocketListeners() {
const socket = api.getSocket()
if (!socket) return

socket.on('connect', () => setConnected(true))
socket.on('disconnect', () => setConnected(false))
socket.on('status', (data) => statusCallbacks.forEach(cb => cb(data)))
socket.on('actuators', (data) => actuatorCallbacks.forEach(cb => cb(data)))
socket.on('heater_status', (data: number) => heaterStatusCallbacks.forEach(cb => cb(data)))
socket.on('notification', (data) => notificationCallbacks.forEach(cb => cb(data)))
socket.on('profile', (data) => profileUpdateCallbacks.forEach(cb => cb(data)))
}

return {
name: 'DirectAdapter',

// -- Connection ---------------------------------------------------------
connect: async () => {
// URL is set at adapter creation time via createDirectAdapter(baseUrl).
// The connect(url?) parameter from MachineService is intentionally unused:
// the underlying espresso-api client is bound to baseUrl at construction.
api.connectToSocket()
setupSocketListeners()
},
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

connect() calls setupSocketListeners() every time, and setupSocketListeners() only ever adds socket.on(...) handlers (no off() / guard). If the provider remounts or connect() is called again (e.g. React StrictMode double-invokes effects in dev), handlers can accumulate and callbacks will fire multiple times. Add an idempotency guard (e.g. listenersAttached ref) and/or remove listeners in disconnect() via socket.off(...).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c8cc045. Added socket.removeAllListeners() at the top of setupSocketListeners() before registering handlers. This prevents listener accumulation on reconnect or React StrictMode double-invocation.

Comment on lines +26 to +28
# Delegate to installer (handles backup, download, extract)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/install-meticai.sh" "$METICAI_VERSION"
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update-meticai.sh delegates to "${SCRIPT_DIR}/install-meticai.sh", but that script is not present under scripts/machine/ (or elsewhere in scripts/). As written, the updater will always fail with “No such file or directory”. Point it at the actual installer (scripts/install-direct.sh) or add the missing install-meticai.sh to the repo and ensure paths match.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c8cc045. install-meticai.sh does not exist in scripts/machine/. Updated the reference to ${SCRIPT_DIR}/../install-direct.sh which is the actual installer script (236 lines, handles backup/download/extract).

Comment on lines +89 to +150
onProgress?.({ phase: 'analyzing', message: 'Preparing prompt...' })

// Build multipart content
const parts: { text?: string; inlineData?: { mimeType: string; data: string } }[] = []

// Add image if provided
if (request.image) {
onProgress?.({ phase: 'analyzing', message: 'Processing image...' })
const buffer = await request.image.arrayBuffer()
const base64 = btoa(
new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), ''),
)
parts.push({
inlineData: {
mimeType: request.image.type || 'image/jpeg',
data: base64,
},
})
}

// Build the full system prompt (matching server parity)
const authorName = (() => {
try { return localStorage.getItem(STORAGE_KEYS.AUTHOR_NAME) || 'MeticAI' }
catch { return 'MeticAI' }
})()
const systemPrompt = buildFullProfilePrompt(
authorName,
request.preferences,
request.tags,
!!request.image,
)
parts.push({ text: systemPrompt })

onProgress?.({ phase: 'generating', message: 'Generating profile...' })

let response
try {
response = await client.models.generateContent({
model: GEMINI_MODEL,
contents: [{ role: 'user', parts }],
})
} catch (err) {
wrapApiError(err)
}

let text = response.text ?? ''

onProgress?.({ phase: 'validating', message: 'Validating profile...' })

// Validation + retry loop
const generateFix = async (fixPrompt: string) => {
const fixResponse = await client.models.generateContent({
model: GEMINI_MODEL,
contents: [{ role: 'user', parts: [{ text: fixPrompt }] }],
})
return fixResponse.text ?? ''
}

const { profileJson, reply: validatedReply } = await validateAndRetryProfile(text, generateFix)
text = validatedReply

onProgress?.({ phase: 'complete', message: 'Profile generated successfully' })
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onProgress events emit hard-coded English message strings (e.g. “Preparing prompt...”), and LoadingView renders progress.message directly. This makes the UI non-localizable in direct mode. Consider emitting a stable message key/code (or just phase + params) from the service and translating via t() in the UI layer.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c8cc045. BrowserAIService now emits i18n keys (e.g. generation.progress.preparingPrompt) instead of English strings. LoadingView translates via t(progress.message, { defaultValue: progress.message }) — i18n keys get translated, while server SSE messages (English) fall through as-is. Added all 5 progress keys to all 6 locales.

Note: In the current direct mode flow, onProgress is not wired up (the fetch interceptor doesn't pass it), so this is forward-looking. The keys are ready for when progress is plumbed through.

Comment on lines +103 to +114
// Fetch target weight from loaded profile when profile changes
const profileId = ext.id
if (profileId && profileId !== lastProfileIdRef.current) {
lastProfileIdRef.current = profileId
fetch(`/api/v1/profile/get/${profileId}`)
.then(r => r.ok ? r.json() : null)
.then((p: {final_weight?: number} | null) => {
if (p?.final_weight) {
setState(s => ({ ...s, target_weight: p.final_weight ?? null }))
}
})
.catch(() => {/* ignore */})
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In direct mode this fetches /api/v1/profile/get/${profileId} from the current origin to derive final_weight. That works for machine-hosted PWA, but the adapter is also intended for Capacitor/other direct deployments where the machine base URL may be different. Prefer using machine.getProfile(profileId) (which is already baseUrl-aware) and add a simple cancellation guard so an in-flight request can’t setState after unmount.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c8cc045. Replaced the raw fetch('/api/v1/profile/get/${profileId}') with machine.getProfile(profileId) — the machine service is already available in the hook via useMachineService(). This works correctly across all deployment modes (machine PWA uses DirectAdapter → espresso-api, Capacitor will use the same adapter with a configurable base URL).

Comment on lines +4 to +8
* Defines all operations that can be performed against a Meticulous espresso
* machine. Two implementations exist:
*
* - **ProxyAdapter** — delegates to the MeticAI FastAPI backend (Docker mode)
* - **DirectAdapter** — talks directly to the machine via @meticulous-home/espresso-api (PWA mode)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interface header comment says the proxy implementation is ProxyAdapter, but the actual proxy implementation in this PR is MeticAIAdapter (and there’s no ProxyAdapter module). Update the comment to match the real implementation name to avoid confusion for future contributors.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in c8cc045. Updated the JSDoc header comment: ProxyAdapterMeticAIAdapter to match the actual implementation name.

- BrowserAIService: use AIServiceError for generateImage() errors, add
  IMAGE_GENERATION_FAILED and IMAGE_NO_DATA error codes
- BrowserAIService: replace hard-coded progress messages with i18n keys
- LoadingView: translate progress.message via t() with fallback
- SettingsView: use STORAGE_KEYS constants for localStorage reads/writes
- MachineServiceContext: remove brittle name check, always connect/disconnect
  (MeticAIAdapter.connect is a safe no-op)
- DirectAdapter: add socket.removeAllListeners() before adding new listeners
  to prevent accumulation on reconnect/StrictMode remounts
- update-meticai.sh: fix reference to install-direct.sh (install-meticai.sh
  does not exist)
- useMachineTelemetry: use machine.getProfile() instead of raw fetch
  (works across all deployment modes including future Capacitor)
- MachineService.ts: fix comment ProxyAdapter → MeticAIAdapter
- Add generation.progress i18n keys to all 6 locales

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@hessius hessius merged commit c29de55 into version/2.4.0 Mar 25, 2026
13 checks passed
@hessius hessius deleted the feat/machine-hosted-pwa branch March 25, 2026 17:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants