feat: Machine-Hosted PWA — Direct Mode without MeticAI backend#316
feat: Machine-Hosted PWA — Direct Mode without MeticAI backend#316hessius merged 41 commits intoversion/2.4.0from
Conversation
There was a problem hiding this comment.
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
MachineServiceabstraction with aDirectAdapterusing 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 |
41afc36 to
478b7d2
Compare
- 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
- 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>
478b7d2 to
28d2bcc
Compare
- 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>
| 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') | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ 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.
| 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()), | ||
| }) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ 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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ Resolved in c8cc045. Same fix applied to handleSave() — all localStorage.setItem() calls now use STORAGE_KEYS constants.
| // 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]) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ 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.
| 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() | ||
| }, |
There was a problem hiding this comment.
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(...).
There was a problem hiding this comment.
✅ 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.
scripts/machine/update-meticai.sh
Outdated
| # Delegate to installer (handles backup, download, extract) | ||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| exec "${SCRIPT_DIR}/install-meticai.sh" "$METICAI_VERSION" |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ 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).
| 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' }) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ 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.
| // 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 */}) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ 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).
| * 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
✅ Resolved in c8cc045. Updated the JSDoc header comment: ProxyAdapter → MeticAIAdapter 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>
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
main.tsx) with ~30 endpoint translationsbuild:machine,install-meticai.sh)Limitations (by design)
Related