Dr-Swopt 1 неделя назад
Родитель
Сommit
25baae2517

+ 160 - 0
CLAUDE.md

@@ -0,0 +1,160 @@
+# CLAUDE.md — frontend
+
+Angular 20 SPA. Multi-tenant dashboard with a lazy-loaded `src.palm.vision` sub-app for MPOB-standard palm oil fruit bunch (FFB) ripeness detection. Three inference engines: browser ONNX WASM, NestJS remote (WebSocket), and n8n edge passthrough.
+
+## Commands
+
+```bash
+npm start                   # Dev server at 0.0.0.0:4200
+npm run build               # Production build → dist/fisapp-ui
+npm run build:dev           # Dev build → dist/dev
+npm run build:prod          # Prod build → dist/rc
+npm run build:leave:prod    # Leave-module variant
+npm run build:quot:prod     # Quotation variant
+npm run build:maf:quot:prod # MAF quotation variant
+npm run test                # Karma + Jasmine
+npm run clean               # Clear Angular + npm caches
+```
+
+## Architecture
+
+### Application Shell (`src/app/`)
+
+- **`app.config.ts`** — `ApplicationConfig`: bootstraps NGXS root store (`withNgxsStoragePlugin`, `withNgxsReduxDevtoolsPlugin`), Angular Router (hash location), HTTP client, service worker.
+- **`app.routes.ts`** — Root routes: `dashboard`, `auth`, `leave`, `tender`, `src.palm.vision` (all lazy-loaded except dashboard).
+- **`app.component.ts`** — Root component: session timeout, PWA install prompt, theme management.
+
+### PalmVision Sub-App (`src/src.palm.vision/`)
+
+Lazy-loaded at `/src.palm.vision`. Provides its own NGXS `VisionState` via `provideStates([VisionState])` in the feature module.
+
+**Sub-routes:**
+
+| Path | Component | Purpose |
+|---|---|---|
+| `analyzer` (default) | `AnalyzerComponent` | Inference UI — file/camera input, bounding box canvas |
+| `vault` | `HistoryComponent` | Batch history vault with canvas thumbnails |
+| `chat` | `ChatbotComponent` | Industrial Intelligence Portal (n8n chat proxy) |
+
+#### Services (`src/src.palm.vision/services/`)
+
+**`InferenceService`** — Local WASM inference:
+- `analyze(file, batchId?)` → `Observable<InferenceFrame>` — preprocesses image → posts tensor to Web Worker → emits result
+- `preprocessImage(dataUrl)` — resizes to 640×640, converts RGBA→CHW, normalizes `[0.0, 1.0]`
+- `results$: Subject<InferenceFrame>` — all completed frames
+- `queueDepth$: BehaviorSubject<number>` — pending worker frames
+
+**`RemoteInferenceService`** — WebSocket bridge to NestJS backend:
+- `analyze(file, sourceLabel?, batchId?)` → `Observable<InferenceFrame>` — sends `PalmVision:analyze` via FIS envelope
+- `getHistory()` → `Observable<any[]>` — `History:getAll`
+- `deleteRecord(archiveId)` → `Observable<{ deleted: boolean }>` — `History:delete`
+- `clearHistory()` → `Observable<{ deleted: number }>` — `History:clearAll`
+- `getImage(archiveId)` → `Observable<{ archiveId, image_data }>` — `PalmHistory:GetImage`
+- `saveExternalResult(payload)` → `Observable<any>` — `PalmHistory:SaveExternalResult`
+
+Connection config loaded from `src/src.palm.vision/config/config.json`:
+```json
+{ "connection": { "uacp": "http://localhost:3000", "uacp_ws": "ws://localhost:3000/socket.io", "uacpEmulation": "on" } }
+```
+
+#### State (`src/src.palm.vision/store/`)
+
+**`VisionStateModel`:**
+```typescript
+{ items: any[]; loading: boolean; expandedBatchIds: string[]; currentInference: InferenceFrame | null }
+```
+
+**Selectors:** `VisionState.items`, `VisionState.loading`, `VisionState.expandedBatchIds`, `VisionState.currentInference`
+
+**Actions (`vision.actions.ts`):**
+
+| Action | Payload | Effect |
+|---|---|---|
+| `SubmitBatchAnalysis` | `{ files: File[]; mode: 'local'\|'remote'\|'n8n' }` | Run batch inference |
+| `ToggleBatchGroup` | `{ batchId: string }` | Expand/collapse history accordion |
+| `LoadGroupImages` | `{ batchId: string }` | Lazy-load archived images for a batch |
+| `LoadHistory` | — | Fetch last 50 records from SQLite |
+| `DeleteHistoryRecord` | `{ id: string }` | Delete record + disk image |
+| `ClearAllHistory` | — | Wipe all records |
+
+#### Components
+
+**`AnalyzerComponent`** — Three-mode inference UI:
+- Engine selector: `local` (WASM), `remote` (NestJS), `n8n` (edge)
+- Input: drag-and-drop file zone or live webcam (`getUserMedia`, environment-facing)
+- Canvas overlay via `renderPredictionsWithBoxes(frame)` — draws bounding boxes + confidence labels
+- MPOB color palette: `Ripe #4caf50`, `Unripe #ff9800`, `Underripe #ffeb3b`, `Overripe #9c27b0`, `Abnormal #f44336`, `Empty_Bunch #607d8b`
+
+**`HistoryComponent`** — Batch vault:
+- Groups `items` by `batch_id` into `BatchGroup[]` via `combineLatest([items$, expandedBatchIds$])`
+- `@ViewChildren('thumbCanvas')` — canvas thumbnails rendered via `renderThumbnailWithBoxes()` using `norm_box` coordinates
+- `paintAllVisibleCanvases()` with `setTimeout(0)` deferral; `data-archive-id` attribute maps canvas elements to data items
+- Mode badge variants: `local` (WASM), `remote` (Server), `n8n`
+
+**`ChatbotComponent`** — Thin wrapper around `angularlib/chat/chat.component` with title "Industrial Intelligence Portal".
+
+#### Web Worker (`src/src.palm.vision/workers/inference.worker.ts`)
+
+Receives `{ frameId, batchId, imageDataUrl, tensor, processingStart }`, runs ONNX inference, returns `InferenceFrame`. Keeps inference off the main thread for WASM local mode.
+
+### Key Interfaces
+
+```typescript
+interface DetectionResult {
+  bunch_id: number;
+  class: string;              // MPOB class name
+  confidence: number;         // [0, 1]
+  is_health_alert: boolean;   // true if Abnormal | Empty_Bunch
+  box: [x1, y1, x2, y2];     // pixel coords
+  norm_box?: [nx1, ny1, nx2, ny2]; // normalized [0,1] coords
+}
+
+interface InferenceFrame {
+  frameId: string;
+  batchId?: string;
+  imageDataUrl: string;
+  detections: DetectionResult[];
+  inference_ms: number;
+  processing_ms: number;
+  total_count: number;
+  industrial_summary: Record<string, number>;
+  source: 'wasm-local' | 'remote' | 'n8n';
+}
+```
+
+### Shared In-Source Dependencies (`src/dependencies/`)
+
+| Alias | Path | Contents |
+|---|---|---|
+| `angularlib/*` | `src/dependencies/angularlib/` | UI components, auth, forms, chat |
+| `dp-ui/*` | `src/dependencies/dp-ui/` | Custom Material extensions, `NgxSocketService` |
+| `fis/*` | `src/dependencies/fis/` | Domain modules (leave, tender, approval) |
+| `fis-commons/*` | `src/dependencies/fis-commons/` | CDN-hosted shared utilities |
+
+### Models & WASM Assets
+
+- `src/assets/models/onnx/best.onnx` — YOLOv8 ONNX model for browser WASM inference
+- `src/assets/models/tflite/best_float16.tflite` / `best_float32.tflite` — TFLite variants
+- `src/assets/wasm/` — ONNX Runtime JS WASM bundles
+- `src/assets/tflite-wasm/` — TensorFlow Lite WASM runtime
+
+### Multi-Build Strategy
+
+`angular.json` defines four production configurations that swap assets and menu via `fileReplacements`:
+
+| Config | Output | Swaps |
+|---|---|---|
+| `production` | `dist/rc` | — |
+| `leave-prod` | `dist/leave` | `menu.ts → menu.leave.ts`, leave assets |
+| `quotation-prod` | `dist/quotation` | `menu.ts → menu.quotation.ts`, quotation assets |
+| `maf-quot-prod` | `dist/maf/quotation` | MAF quotation assets |
+
+Development build uses a mock socket service (`dp.service.t.ts`) for offline development.
+
+### PWA
+
+Service worker registered immediately (`registerImmediately`) via `ngsw-config.json`. PWA install prompt handled in `AppComponent`. Three language packs: `en_US`, `ms_MY`, `zh_Hans`.
+
+### CORS / Connection
+
+Frontend connects to NestJS backend at `http://localhost:3000` (configurable via `config.json`). Backend CORS whitelist is hardcoded in `server-desktop/src/main.ts` — add new device IPs there.

+ 17 - 0
angular.json

@@ -187,6 +187,16 @@
                   "input": "src/src.test/assets/icons",
                   "output": "assets/icons",
                   "glob": "*.png"
+                },
+                {
+                  "input": "src/assets/models",
+                  "output": "assets/models",
+                  "glob": "**/*"
+                },
+                {
+                  "input": "src/assets/wasm",
+                  "output": "assets/wasm",
+                  "glob": "**/*"
                 }
               ],
               "fileReplacements": [
@@ -206,6 +216,13 @@
         },
         "serve": {
           "builder": "@angular/build:dev-server",
+          "options": {
+            "host": "0.0.0.0",
+            "port": 4200,
+            "ssl": true,
+            "sslCert": "cert/localhost+1.pem",
+            "sslKey": "cert/localhost+1-key.pem"
+          },
           "configurations": {
             "production": {
               "buildTarget": "fisapp-ui:build:production"

+ 28 - 0
cert/localhost+1-key.pem

@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDaV41hbIbC6hDT
+oaYvPKJ6OoAbHFWr5Mi+mBEURwu8diJHW/9hbpf5XSXwbL1fPEIvWR+8YwVcaNOY
+FYwWDmSgGTeI/QQVxTR2XhBdEoF9ox5xSqyKmhJdtxDiT+JibhZev9hZ4iV6Ej1F
+vr/3+Mu3GtaJXcFen/cVt7xsbL5rml4/Bja2WarK6uiZfaBzklg7Ckb4Q8ShNB8Z
+Mu4RbnreRyx4dzAAxpuEWZLHDAZBM+H+rGnKI2PMBsPYwdvHRs76u9OKHqMAPMdd
+e/22YvqhFDDnirk61vW2ESmhtbWZsuWk5+n+lq3XSMkRpHgenxNF9TxxhbSrL07R
+txxh15bVAgMBAAECggEBAMds3mpUqMXQ6muSIurUNAb19dpNSAbH4X8L/9WIirSp
+JegNpDWGwPJ3XNa7S0B4Fm+OtMjpnJTp/hHT5G6k2M3OGoZZquiDhcZzZfjMlU9+
+tKh/rxatYQcN1TMQCdMjf/UsvtxiDR7xF9vjPQ2txcvJjJhM9PiLnS/N8SELNeWq
+mlPK+jo5z8MBjNwCiSrZ6Hy4NifIKqrGdkSmnZHpZFmFb+1ie+enolo10rTaW2xR
+WwvX81Bt68xgc81NiQussH8rfDR0Dd+emvh4Ii7tVfhGZKQjEyDkFOUKsjWx7kZr
+jXBLI2RIwMfXp7kxmn/NgfAzlxkz0W9g1hqeLXxicyUCgYEA8l2i8rgTCwDftv4R
+ZKz1UOntDGYL+mg0GYWcv2NVkt7d7STho05byIEYnpS7mbtb9FoHAVnCyida/o4z
+MZM+zOtPhAPG+Fy7G3iVVVBl3BtBOrbL6Z2QjlCKEn4LmVhTO+HWSW3eSSO1CU0f
+797VAp9DYZCCQ7B5dU1L2MUlFzMCgYEA5p/xsoHNRtNkK+QamUjbMHBnZ5rirVRT
+57Yy2JmvR11VQHXpYFRmYF8nxMcWiZFBM9COIQ3+p2D4L7A+S5jbI+k7adBjb0d9
+rKr8Og33qIaITsNi+u3Y82cnk8//nFH1o7/kn+nXmJRsdfPArVBRnMduReVpBxmN
+iQgi6zFeedcCgYEA4w09nk076c8Dxhb2jG5L02WSoU7oYcpFJLO7SMDyZglLtuIa
+UDcUXR4zxjxoE3kmiB/e+DDy/xcnc9obs5HR/39imrY/LGUTFIU+wRH0muMdlLez
+CESILArfjrtuelX4g9zqNxgqajJ9Yx1RkhIbU72IDlqm7mrhHjcvmv/142kCgYEA
+nlSWeafVh1dfgSaEAFJdcP7qbt2N29N2GzEh7URtaoAwJCYPR7wJ4QXS5qyL03wu
+mGUI/rZ96umO2iaUThAt+pSH3phbe61IIX/t0+l86m0aLYDEdmNOO6TJLhhxcx9t
+lbMLQaIoCq9zWvMyh4oJzam5EjFyjpZDbh1w46ksJFcCgYAzxUaUkmkKQ8pSYfY3
+K3BY6N/PFb4N+xW4R0SNqd6skW6Q0BAkKMRy/L8YWRlTHZL/LsWOs3cBN0dReFdA
+nOhryKL4hBVLVTnY4o7nkCBig/CaMDHwXUrlZwoIVcVS/VQRWuMFS6DWD+QwDyV4
+Ja72UPQkTYbu0AnM1p2AjnQ1Sw==
+-----END PRIVATE KEY-----

+ 25 - 0
cert/localhost+1.pem

@@ -0,0 +1,25 @@
+-----BEGIN CERTIFICATE-----
+MIIEOTCCAqGgAwIBAgIQZXHBr+i1ZsyCh9EeIyBkWjANBgkqhkiG9w0BAQsFADB5
+MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExJzAlBgNVBAsMHlNPREMx
+NlxlbnpvQFNPUEMtMjNEMTAxIChFbnpvKTEuMCwGA1UEAwwlbWtjZXJ0IFNPREMx
+NlxlbnpvQFNPUEMtMjNEMTAxIChFbnpvKTAeFw0yNjA1MjUwNjI0NDZaFw0yODA4
+MjUwNjI0NDZaMFIxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZp
+Y2F0ZTEnMCUGA1UECwweU09EQzE2XGVuem9AU09QQy0yM0QxMDEgKEVuem8pMIIB
+IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2leNYWyGwuoQ06GmLzyiejqA
+GxxVq+TIvpgRFEcLvHYiR1v/YW6X+V0l8Gy9XzxCL1kfvGMFXGjTmBWMFg5koBk3
+iP0EFcU0dl4QXRKBfaMecUqsipoSXbcQ4k/iYm4WXr/YWeIlehI9Rb6/9/jLtxrW
+iV3BXp/3Fbe8bGy+a5pePwY2tlmqyuromX2gc5JYOwpG+EPEoTQfGTLuEW563kcs
+eHcwAMabhFmSxwwGQTPh/qxpyiNjzAbD2MHbx0bO+rvTih6jADzHXXv9tmL6oRQw
+54q5Otb1thEpobW1mbLlpOfp/pat10jJEaR4Hp8TRfU8cYW0qy9O0bccYdeW1QID
+AQABo2QwYjAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHwYD
+VR0jBBgwFoAUtrkpVhXxR16EF3vN5zAF08sezWQwGgYDVR0RBBMwEYIJbG9jYWxo
+b3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBgQB+7GdWjig52iRv4wdCLsML4yih
+BshBk5Jlo4HKjDqA9MQxjFfCO85b6pVbMTPg4gfCGcb10DQ5vp5Y3Hsp8u7bjM1t
+0/AVB/31NCIFGm8NEBAS+XMKRtsGTYFn2c24H2PEkiNe0Er2+1JbUVHjv19bYqB4
+ynefv3Yx1MBQRuqY9gDvs7tkmteIPK+jzL7v2ibkbmyOXUS1PvgsUgtZDVLBgXIQ
+lS9mS+5ZuMRyDRElojcjrnybPsRSyyvq/8YjPzAyp1sKBaG8VrNkMxbe8ANs0Tts
+k8qaVaX8sAZ9mHgQrJY26OxepYZJ+7f2LkUqIuk52Us0iKQ86QH9yctqU4/nHigL
+Kxuq5/bLISI+gYtNgxdJcOU6rKe75eSOH/VL39d38TDgVHI/vknHpLghuvD/mKjY
+jGBO7PJpHOfbjIZtwyeMmVFE8rsB7VOosnbJ9l+STaGM4UPvAo7ayTt9TBNxDutj
+URafNXQI63YUTU+bf4bhd3yeXdiz2FTribww0Cs=
+-----END CERTIFICATE-----

+ 1 - 0
package.json

@@ -37,6 +37,7 @@
     "dp-ui": "file:src/dependencies/dp-ui",
     "fis": "file:src/dependencies/fis",
     "fis-commons": "https://cdn.swopt.com/npm/fis-commons",
+    "onnxruntime-web": "^1.26.0",
     "primeng": "^21.1.1",
     "rxjs": "~7.8.0",
     "tslib": "^2.8.1",

+ 5 - 0
src/app/dashboard/dashboard.component.ts

@@ -53,6 +53,7 @@ export class DashboardComponent extends BaseComponent implements OnInit{
         {value:'leave-approval',label:{key:'leave_approval',default:'Leave Approval'}},
         {value:'palm-analyzer',label:{key:'palm_analyzer',default:'Industrial Grading Studio'}},
         {value:'palm-vault',label:{key:'palm_vault',default:'Historical Records Vault'}},
+        {value:'palm-chat',label:{key:'palm_chat',default:'Intelligence Chat'}},
       ]
     },
     value: 'home',
@@ -82,6 +83,10 @@ export class DashboardComponent extends BaseComponent implements OnInit{
           this.cs.navigate('/src.palm.vision/vault');
           break;
         }
+        case 'palm-chat' : {
+          this.cs.navigate('/src.palm.vision/chat');
+          break;
+        }
         default: break;
       }
     }

+ 3 - 6
src/config/config.json

@@ -1,15 +1,12 @@
 {
     "connection": {
-        "uacp": "https://swopt.com:8081",
-        "uacp_ws": "https://fist.swopt.com/ws",
-        "uacpEmulation": "on",
+        "uacp": "https://localhost:3000",
+        "uacp_ws": "wss://localhost:3000/",
+        "uacpEmulation": "off",
         "auth": {
             "google": "https://api.swopt.com/auth/google"
         }
     },
-    "chat":{
-        "http":{"url":"https://n8n.ai.swopt.com/webhook/236a2ba2-b147-4119-86a8-ee3a5ab30f4f"}
-    },
     "holidayCalendar": "https://api.swopt.com/data/h0/my/sar",
     "sessionTimeoutDuration":1800000,
     "maintenance": {"active":false, "endDatetime":"2026-02-18 (WED) 12:00:00 AM"}

+ 113 - 60
src/src.palm.vision/analyzer/analyzer.component.html

@@ -7,90 +7,144 @@
     <span class="mpob-badge">MPOB Standard</span>
   </div>
 
-  <!-- Engine mode selector -->
+  <!-- Engine selector row -->
   <div class="engine-selector-row">
     <mat-form-field appearance="outline" class="engine-field">
       <mat-label>Inference Engine</mat-label>
-      <mat-select [(ngModel)]="mode" panelClass="engine-select-dropdown-panel">
-        <mat-option value="remote">
-          <mat-icon>cloud</mat-icon>
-          Remote — NestJS Server
-        </mat-option>
-        <mat-option value="local">
-          <mat-icon>memory</mat-icon>
-          Local — Browser WASM
-        </mat-option>
+      <mat-select [(ngModel)]="mode" [disabled]="loading$ | async" panelClass="engine-select-dropdown-panel">
+        @for (opt of engineOptions; track opt.value) {
+          <mat-option [value]="opt.value">
+            <mat-icon>{{ opt.icon }}</mat-icon>
+            {{ opt.label }}
+          </mat-option>
+        }
       </mat-select>
     </mat-form-field>
 
     <span class="engine-status-badge" [ngClass]="mode">
-      @if (mode === 'remote') { Edge Server Active }
-      @else { WASM Runtime }
+      @switch (mode) {
+        @case ('local-onnx') { ONNX Runtime }
+        @case ('local-tflite') { TFLite Runtime }
+        @case ('remote') { Edge Server Active }
+      }
     </span>
   </div>
 
-  <!-- Drop zone / upload canvas -->
-  <div
-    class="drop-zone"
-    [class.drag-over]="isDragOver"
-    (dragover)="onDragOver($event)"
-    (dragleave)="onDragLeave()"
-    (drop)="onDrop($event)"
-  >
-    @if (loading$ | async) {
-      <div class="loading-overlay">
-        <mat-spinner diameter="52"></mat-spinner>
-        <span class="loading-label">Analyzing batch&hellip;</span>
+  <!-- Input mode toggle -->
+  <div class="input-mode-toggle">
+    <button mat-flat-button [disabled]="loading$ | async" [class.active]="inputMode === 'file'" (click)="switchInputMode('file')">
+      <mat-icon>upload_file</mat-icon>
+      File Drop
+    </button>
+    <button mat-flat-button [disabled]="loading$ | async" [class.active]="inputMode === 'camera'" (click)="switchInputMode('camera')">
+      <mat-icon>videocam</mat-icon>
+      Live Camera
+    </button>
+  </div>
+
+  <!-- File drop zone -->
+  @if (inputMode === 'file') {
+    <div
+      class="drop-zone"
+      [class.drag-over]="isDragOver"
+      (dragover)="onDragOver($event)"
+      (dragleave)="onDragLeave()"
+      (drop)="onDrop($event)"
+    >
+      @if ((loading$ | async) && !isCameraActive) {
+        <div class="loading-overlay">
+          <mat-spinner diameter="52"></mat-spinner>
+          <span class="loading-label">Analyzing batch&hellip;</span>
+        </div>
+      } @else {
+        <mat-icon class="drop-icon">image_search</mat-icon>
+        <p class="drop-primary">Drag &amp; drop images here</p>
+        <p class="drop-secondary">or</p>
+        <button mat-raised-button color="primary" type="button" [disabled]="loading$ | async" (click)="fileInput.click()">
+          <mat-icon>upload_file</mat-icon>
+          Select Images
+        </button>
+        <input
+          #fileInput
+          type="file"
+          accept="image/*"
+          multiple
+          hidden
+          (change)="onFileInput($event)"
+        />
+      }
+    </div>
+  }
+
+  <!-- Live camera section -->
+  @if (inputMode === 'camera') {
+    <div class="camera-section">
+      <video
+        #videoEl
+        autoplay
+        playsinline
+        class="camera-preview"
+        [class.visible]="isCameraActive"
+      ></video>
+
+      @if ((loading$ | async) && isCameraActive) {
+        <div class="camera-loading">
+          <mat-spinner diameter="52"></mat-spinner>
+          <span class="loading-label">Analyzing frame&hellip;</span>
+        </div>
+      }
+
+      <div class="camera-controls">
+        @if (!isCameraActive) {
+          <button mat-raised-button color="primary" [disabled]="loading$ | async" (click)="startCamera()">
+            <mat-icon>videocam</mat-icon>
+            Start Camera
+          </button>
+        } @else {
+          <button
+            mat-raised-button
+            color="accent"
+            (click)="captureWebcamFrame()"
+            [disabled]="(loading$ | async) === true"
+          >
+            <mat-icon>camera</mat-icon>
+            Capture Frame
+          </button>
+          <button mat-stroked-button [disabled]="loading$ | async" (click)="stopCamera()">
+            <mat-icon>videocam_off</mat-icon>
+            Stop
+          </button>
+        }
       </div>
-    } @else {
-      <mat-icon class="drop-icon">image_search</mat-icon>
-      <p class="drop-primary">Drag &amp; drop images here</p>
-      <p class="drop-secondary">or</p>
-      <button mat-raised-button color="primary" type="button" (click)="fileInput.click()">
-        <mat-icon>upload_file</mat-icon>
-        Select Images
-      </button>
-      <input
-        #fileInput
-        type="file"
-        accept="image/*"
-        multiple
-        hidden
-        (change)="onFileInput($event)"
-      />
-    }
+    </div>
+  }
+
+  <!-- Canvas bounding box overlay (always in DOM so ViewChild resolves) -->
+  <div class="canvas-container" [class.has-content]="!!currentFrame">
+    <canvas #resultCanvas class="result-canvas"></canvas>
   </div>
 
-  <!-- Inference results -->
-  @if (currentInference$ | async; as frame) {
+  <!-- Detection results -->
+  @if (currentFrame) {
     <div class="results-panel">
 
       <div class="results-header">
         <span class="results-title">
           Batch Result &mdash;
-          <strong>{{ frame.total_count }}</strong> detection(s)
+          <strong>{{ currentFrame.total_count }}</strong> detection(s)
         </span>
         <span class="timing-info">
-          Inference: {{ frame.inference_ms | number:'1.0-0' }}&nbsp;ms
+          Inference: {{ currentFrame.inference_ms | number:'1.0-0' }}&nbsp;ms
           &nbsp;|&nbsp;
-          Processing: {{ frame.processing_ms | number:'1.0-0' }}&nbsp;ms
+          Processing: {{ currentFrame.processing_ms | number:'1.0-0' }}&nbsp;ms
         </span>
       </div>
 
       <div class="results-body">
-
-        <!-- Image preview -->
-        @if (frame.imageDataUrl) {
-          <div class="preview-col">
-            <img [src]="frame.imageDataUrl" alt="Analyzed frame" class="preview-img" />
-          </div>
-        }
-
-        <!-- Detection list + summary -->
         <div class="detections-col">
 
-          @if (frame.detections.length) {
-            @for (det of frame.detections; track det.bunch_id) {
+          @if (currentFrame.detections.length) {
+            @for (det of currentFrame.detections; track det.bunch_id) {
               <mat-card class="detection-card" [class.health-alert]="det.is_health_alert">
                 <mat-card-content>
                   <span
@@ -109,12 +163,11 @@
             <p class="no-detections">No detections in this frame.</p>
           }
 
-          <!-- Industrial summary chips -->
-          @if (frame.total_count > 0) {
+          @if (currentFrame.total_count > 0) {
             <div class="summary-block">
               <h4 class="summary-title">Industrial Summary</h4>
               <div class="summary-chips">
-                @for (entry of frame.industrial_summary | keyvalue; track entry.key) {
+                @for (entry of currentFrame.industrial_summary | keyvalue; track entry.key) {
                   <span
                     class="summary-chip"
                     [ngStyle]="{ 'border-color': gradeColor(entry.key) }"

+ 94 - 14
src/src.palm.vision/analyzer/analyzer.component.scss

@@ -70,6 +70,31 @@
       color: #6a1b9a;
       border: 1px solid #ce93d8;
     }
+
+    &.n8n {
+      background: #fff8e1;
+      color: #e65100;
+      border: 1px solid #ffcc02;
+    }
+  }
+}
+
+// ── Input mode toggle ────────────────────────────────────────────────────────
+
+.input-mode-toggle {
+  display: flex;
+  gap: 0.5rem;
+
+  button {
+    border: 1px solid #c8e6c9;
+    border-radius: 8px;
+    color: #546e7a;
+
+    &.active {
+      background: #e8f5e9;
+      color: #2e7d32;
+      border-color: #a5d6a7;
+    }
   }
 }
 
@@ -127,6 +152,75 @@
   }
 }
 
+// ── Camera section ───────────────────────────────────────────────────────────
+
+.camera-section {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.75rem;
+  padding: 1rem;
+  border: 2px solid #a5d6a7;
+  border-radius: 12px;
+  background: #1a1a2e;
+  min-height: 200px;
+  overflow: hidden;
+
+  .camera-preview {
+    display: none;
+    width: 100%;
+    max-width: 640px;
+    border-radius: 8px;
+
+    &.visible {
+      display: block;
+    }
+  }
+
+  .camera-loading {
+    position: absolute;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.55);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 0.75rem;
+    border-radius: 10px;
+
+    .loading-label {
+      font-size: 0.9rem;
+      color: #fff;
+    }
+  }
+
+  .camera-controls {
+    display: flex;
+    gap: 0.75rem;
+  }
+}
+
+// ── Canvas bounding box overlay ──────────────────────────────────────────────
+
+.canvas-container {
+  display: none;
+  justify-content: center;
+  border-radius: 12px;
+  overflow: hidden;
+  background: #111;
+
+  &.has-content {
+    display: flex;
+  }
+
+  .result-canvas {
+    max-width: 100%;
+    height: auto;
+    display: block;
+  }
+}
+
 // ── Results panel ────────────────────────────────────────────────────────────
 
 .results-panel {
@@ -161,20 +255,6 @@
   padding: 1.25rem;
 }
 
-// ── Preview ──────────────────────────────────────────────────────────────────
-
-.preview-col {
-  flex: 0 0 auto;
-
-  .preview-img {
-    width: 260px;
-    max-height: 260px;
-    object-fit: cover;
-    border-radius: 8px;
-    border: 1px solid #e0e0e0;
-  }
-}
-
 // ── Detection cards ──────────────────────────────────────────────────────────
 
 .detections-col {

+ 151 - 5
src/src.palm.vision/analyzer/analyzer.component.ts

@@ -1,8 +1,16 @@
-import { Component } from '@angular/core';
+import {
+  AfterViewInit,
+  Component,
+  ElementRef,
+  OnDestroy,
+  OnInit,
+  ViewChild,
+} from '@angular/core';
 import { AsyncPipe, DecimalPipe, KeyValuePipe, NgClass, NgStyle } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { Select, Store } from '@ngxs/store';
-import { Observable } from 'rxjs';
+import { Observable, Subscription } from 'rxjs';
+import { filter } from 'rxjs/operators';
 import { MatSelectModule } from '@angular/material/select';
 import { MatFormFieldModule } from '@angular/material/form-field';
 import { MatButtonModule } from '@angular/material/button';
@@ -13,6 +21,8 @@ import { VisionState } from '../store/vision.state';
 import { SubmitBatchAnalysis } from '../store/vision.actions';
 import { InferenceFrame } from '../services/inference.service';
 
+type EngineMode = 'local-onnx' | 'local-tflite' | 'remote';
+
 @Component({
   selector: 'app-analyzer',
   standalone: true,
@@ -33,15 +43,62 @@ import { InferenceFrame } from '../services/inference.service';
   templateUrl: './analyzer.component.html',
   styleUrl: './analyzer.component.scss',
 })
-export class AnalyzerComponent {
+export class AnalyzerComponent implements OnInit, AfterViewInit, OnDestroy {
   @Select(VisionState.loading) loading$!: Observable<boolean>;
-  @Select(VisionState.currentInference) currentInference$!: Observable<InferenceFrame | null>;
 
-  mode: 'local' | 'remote' = 'remote';
+  @ViewChild('resultCanvas') resultCanvasRef!: ElementRef<HTMLCanvasElement>;
+  @ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;
+
+  mode: EngineMode = 'remote';
+  isViewInitialized = false;
+  inputMode: 'file' | 'camera' = 'file';
   isDragOver = false;
+  isCameraActive = false;
+  currentFrame: InferenceFrame | null = null;
+
+  readonly engineOptions: { value: EngineMode; label: string; icon: string }[] = [
+    { value: 'local-onnx', label: 'Local — ONNX WASM', icon: 'memory' },
+    { value: 'local-tflite', label: 'Local — TFLite WASM', icon: 'layers' },
+    { value: 'remote', label: 'Remote — NestJS Server', icon: 'cloud' },
+  ];
+
+  private mediaStream: MediaStream | null = null;
+  private inferenceSub!: Subscription;
 
   constructor(private store: Store) {}
 
+  ngOnInit(): void {
+    this.inferenceSub = (this.store.select(VisionState.currentInference) as Observable<InferenceFrame | null>)
+      .pipe(filter((f): f is InferenceFrame => !!f))
+      .subscribe(frame => {
+        this.currentFrame = frame;
+        if (this.isViewInitialized) {
+          this.renderPredictionsWithBoxes(frame);
+        }
+      });
+  }
+
+  ngAfterViewInit(): void {
+    this.isViewInitialized = true;
+    if (this.currentFrame) {
+      this.renderPredictionsWithBoxes(this.currentFrame);
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.stopCamera();
+    this.inferenceSub?.unsubscribe();
+  }
+
+  // ── Input mode ────────────────────────────────────────────────────────────
+
+  switchInputMode(m: 'file' | 'camera'): void {
+    if (m === 'file') this.stopCamera();
+    this.inputMode = m;
+  }
+
+  // ── File drop ─────────────────────────────────────────────────────────────
+
   onFileInput(event: Event): void {
     const input = event.target as HTMLInputElement;
     if (input.files?.length) {
@@ -68,6 +125,95 @@ export class AnalyzerComponent {
     this.isDragOver = false;
   }
 
+  // ── Camera ────────────────────────────────────────────────────────────────
+
+  async startCamera(): Promise<void> {
+    try {
+      this.mediaStream = await navigator.mediaDevices.getUserMedia({
+        video: { facingMode: 'environment', width: 640, height: 640 },
+      });
+      this.isCameraActive = true;
+      const video = this.videoElRef?.nativeElement;
+      if (video) video.srcObject = this.mediaStream;
+    } catch (err) {
+      console.error('[Analyzer] Camera access denied:', err);
+    }
+  }
+
+  stopCamera(): void {
+    this.mediaStream?.getTracks().forEach(t => t.stop());
+    this.mediaStream = null;
+    this.isCameraActive = false;
+    if (this.videoElRef?.nativeElement) {
+      this.videoElRef.nativeElement.srcObject = null;
+    }
+  }
+
+  captureWebcamFrame(): void {
+    const video = this.videoElRef?.nativeElement;
+    if (!video) return;
+    const offscreen = document.createElement('canvas');
+    offscreen.width = video.videoWidth || 640;
+    offscreen.height = video.videoHeight || 640;
+    offscreen.getContext('2d')!.drawImage(video, 0, 0);
+    offscreen.toBlob(blob => {
+      if (!blob) return;
+      this.submit([new File([blob], `webcam-${Date.now()}.jpg`, { type: 'image/jpeg' })]);
+    }, 'image/jpeg');
+  }
+
+  // ── Canvas renderer ───────────────────────────────────────────────────────
+
+  renderPredictionsWithBoxes(frame: InferenceFrame): void {
+    setTimeout(() => {
+      const canvas = this.resultCanvasRef?.nativeElement;
+      if (!canvas || !frame.imageDataUrl) return;
+
+      const img = new Image();
+      img.onload = () => {
+        canvas.width = img.naturalWidth;
+        canvas.height = img.naturalHeight;
+        const ctx = canvas.getContext('2d')!;
+        ctx.drawImage(img, 0, 0);
+
+        for (const det of frame.detections) {
+          let x: number, y: number, w: number, h: number;
+          if (det.norm_box) {
+            const [nx1, ny1, nx2, ny2] = det.norm_box;
+            x = nx1 * canvas.width;
+            y = ny1 * canvas.height;
+            w = (nx2 - nx1) * canvas.width;
+            h = (ny2 - ny1) * canvas.height;
+          } else if (det.box) {
+            const [x1, y1, x2, y2] = det.box;
+            x = x1;
+            y = y1;
+            w = x2 - x1;
+            h = y2 - y1;
+          } else {
+            continue;
+          }
+          const color = this.gradeColor(det.class);
+
+          ctx.strokeStyle = color;
+          ctx.lineWidth = 2;
+          ctx.strokeRect(x, y, w, h);
+
+          const label = `${det.class} ${(det.confidence * 100).toFixed(1)}%`;
+          ctx.font = 'bold 12px sans-serif';
+          const tw = ctx.measureText(label).width;
+          ctx.fillStyle = color;
+          ctx.fillRect(x, y - 20, tw + 8, 20);
+          ctx.fillStyle = '#fff';
+          ctx.fillText(label, x + 4, y - 5);
+        }
+      };
+      img.src = frame.imageDataUrl;
+    }, 0);
+  }
+
+  // ── Helpers ───────────────────────────────────────────────────────────────
+
   confidencePercent(value: number): string {
     return (value * 100).toFixed(1) + '%';
   }

+ 0 - 1
src/src.palm.vision/config/.gitkeep

@@ -1 +0,0 @@
-# .gitkeep

+ 0 - 11
src/src.palm.vision/config/config.json

@@ -1,11 +0,0 @@
-{
-    "connection": {
-        "uacp": "http://localhost:3000",
-        "uacp_ws": "ws://localhost:3000/socket.io",
-        "uacpEmulation": "on"
-    },
-    "sessionTimeoutDuration": 1800000,
-    "maintenance": {
-        "active": false
-    }
-}

+ 77 - 81
src/src.palm.vision/history/history.component.html

@@ -1,17 +1,15 @@
 <div class="history-root">
 
-  <!-- Header -->
   <div class="history-header">
     <mat-icon class="header-icon">history</mat-icon>
     <h2 class="header-title">Records Vault</h2>
     <span class="mpob-badge">MPOB Standard</span>
-    <button mat-stroked-button color="warn" class="clear-btn" (click)="onClearAll()">
+    <button mat-stroked-button color="warn" class="clear-btn" [disabled]="loading$ | async" (click)="onClearAll()">
       <mat-icon>delete_sweep</mat-icon>
       Clear All
     </button>
   </div>
 
-  <!-- Loading spinner -->
   @if (loading$ | async) {
     <div class="loading-container">
       <mat-spinner diameter="44"></mat-spinner>
@@ -19,102 +17,100 @@
     </div>
   }
 
-  <!-- Batch group list -->
   @if (groups$ | async; as groups) {
     @if (groups.length === 0 && !(loading$ | async)) {
       <div class="empty-state">
         <mat-icon class="empty-icon">inbox</mat-icon>
         <p class="empty-label">No historical records found.</p>
+        <small class="empty-hint">Please verify your Edge Server connection if this is unexpected.</small>
       </div>
     }
 
-    @for (group of groups; track group.batchId) {
-      <div class="batch-card" [class.expanded]="group.isExpanded">
+    @if (groups.length > 0) {
+      @for (group of groups; track group.batchId) {
+        <div class="batch-card" [class.expanded]="group.isExpanded">
 
-        <!-- Master row -->
-        <div class="batch-header" (click)="onToggle(group.batchId)">
-          <mat-icon class="chevron" [class.rotated]="group.isExpanded">chevron_right</mat-icon>
+          <div class="batch-header" (click)="onToggle(group.batchId)">
+            <mat-icon class="chevron" [class.rotated]="group.isExpanded">chevron_right</mat-icon>
 
-          <div class="batch-meta">
-            <span class="batch-timestamp">
-              {{ group.timestamp ? (group.timestamp | date:'dd MMM yyyy, HH:mm') : '—' }}
-            </span>
-            <span class="batch-id-label">{{ group.batchId | slice:0:8 }}&hellip;</span>
-          </div>
-
-          <div class="batch-stats">
-            <span class="stat-chip count">
-              <mat-icon>grain</mat-icon>
-              {{ group.totalCount }} fruit(s)
-            </span>
-            <span class="stat-chip confidence">
-              {{ group.avgConfidencePct | number:'1.1-1' }}% avg conf.
-            </span>
-            <span class="mode-badge" [ngClass]="group.mode">
-              {{ group.mode === 'local' ? 'WASM' : 'Server' }}
-            </span>
-          </div>
-
-          <button
-            mat-icon-button
-            color="warn"
-            class="delete-btn"
-            matTooltip="Delete batch"
-            (click)="onDeleteBatch(group, $event)"
-          >
-            <mat-icon>delete_outline</mat-icon>
-          </button>
-        </div>
-
-        <!-- Detail drawer — lazy-loaded thumbnail grid -->
-        @if (group.isExpanded) {
-          <div class="batch-detail">
-            <div class="thumb-grid">
-              @for (item of group.items; track item.archive_id) {
-                <div class="thumb-card">
+            <div class="batch-meta">
+              <span class="batch-timestamp">
+                {{ group.timestamp ? (group.timestamp | date:'dd MMM yyyy, HH:mm') : '—' }}
+              </span>
+              <span class="batch-id-label">{{ group.batchId | slice:0:8 }}&hellip;</span>
+            </div>
 
-                  @if (item.imageDataUrl) {
-                    <img [src]="item.imageDataUrl" alt="Batch frame" class="thumb-img" />
-                  } @else {
-                    <div class="thumb-placeholder">
-                      <mat-spinner diameter="24"></mat-spinner>
-                    </div>
-                  }
+            <div class="batch-stats">
+              <span class="stat-chip count">
+                <mat-icon>grain</mat-icon>
+                {{ group.totalCount }} fruit(s)
+              </span>
+              <span class="stat-chip confidence">
+                {{ group.avgConfidencePct | number:'1.1-1' }}% avg conf.
+              </span>
+              <span class="mode-badge" [ngClass]="group.mode">
+                @switch (group.mode) {
+                  @case ('local-onnx') { ONNX }
+                  @case ('local-tflite') { TFLite }
+                  @default { Server }
+                }
+              </span>
+            </div>
 
-                  <div class="thumb-overlay">
-                    <span>{{ item.total_count ?? 0 }} det.</span>
-                    <span>{{ item.inference_ms | number:'1.0-0' }}&nbsp;ms</span>
-                  </div>
+            <button
+              mat-icon-button
+              color="warn"
+              class="delete-btn"
+              matTooltip="Delete batch"
+              [disabled]="loading$ | async"
+              (click)="onDeleteBatch(group, $event)"
+            >
+              <mat-icon>delete_outline</mat-icon>
+            </button>
+          </div>
 
-                  @if (item.detections?.length) {
-                    <div class="detection-dots">
-                      @for (det of item.detections; track det.bunch_id) {
-                        <span
-                          class="det-dot"
-                          [ngStyle]="{ background: gradeColor(det.class) }"
-                          [matTooltip]="det.class + ' · ' + confidencePercent(det.confidence)"
-                        ></span>
-                      }
+          @if (group.isExpanded) {
+            <div class="batch-detail">
+              <div class="thumb-grid">
+                @for (item of group.items; track item.archive_id) {
+                  <div class="thumb-card">
+
+                    @if (item.imageDataUrl) {
+                      <canvas
+                        #thumbCanvas
+                        class="historical-frame-canvas"
+                        [attr.data-archive-id]="item.archive_id"
+                      ></canvas>
+                    } @else {
+                      <div class="thumb-placeholder">
+                        <mat-spinner diameter="24"></mat-spinner>
+                      </div>
+                    }
+
+                    <div class="thumb-metrics">
+                      <span>{{ item.total_count ?? 0 }} detections</span>
+                      <span>{{ item.inference_ms | number:'1.0-0' }}&nbsp;ms</span>
                     </div>
-                  }
 
-                  <button
-                    mat-icon-button
-                    class="item-delete-btn"
-                    color="warn"
-                    matTooltip="Delete frame"
-                    (click)="onDeleteItem(item.archive_id, $event)"
-                  >
-                    <mat-icon>close</mat-icon>
-                  </button>
+                    <button
+                      mat-icon-button
+                      class="item-delete-btn"
+                      color="warn"
+                      matTooltip="Delete frame"
+                      [disabled]="loading$ | async"
+                      (click)="onDeleteItem(item.archive_id, $event)"
+                    >
+                      <mat-icon>close</mat-icon>
+                    </button>
 
-                </div>
-              }
+                  </div>
+                }
+              </div>
             </div>
-          </div>
-        }
+          }
 
-      </div>
+        </div>
+      }
     }
   }
 

+ 9 - 20
src/src.palm.vision/history/history.component.scss

@@ -190,6 +190,12 @@
       color: #6a1b9a;
       border: 1px solid #ce93d8;
     }
+
+    &.n8n {
+      background: #fff8e1;
+      color: #e65100;
+      border: 1px solid #ffcc02;
+    }
   }
 
   .delete-btn {
@@ -222,11 +228,11 @@
   border: 1px solid #e0e0e0;
   background: #f5f5f5;
 
-  .thumb-img {
+  .historical-frame-canvas {
     width: 100%;
     height: 120px;
-    object-fit: cover;
     display: block;
+    object-fit: cover;
   }
 
   .thumb-placeholder {
@@ -238,7 +244,7 @@
     background: #eeeeee;
   }
 
-  .thumb-overlay {
+  .thumb-metrics {
     display: flex;
     justify-content: space-between;
     padding: 3px 6px;
@@ -247,23 +253,6 @@
     color: #fff;
   }
 
-  .detection-dots {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 3px;
-    padding: 4px 6px;
-    background: #fff;
-    min-height: 22px;
-
-    .det-dot {
-      width: 10px;
-      height: 10px;
-      border-radius: 50%;
-      cursor: default;
-      flex-shrink: 0;
-    }
-  }
-
   .item-delete-btn {
     position: absolute;
     top: 2px;

+ 78 - 5
src/src.palm.vision/history/history.component.ts

@@ -1,7 +1,15 @@
-import { Component, OnInit } from '@angular/core';
-import { AsyncPipe, DatePipe, DecimalPipe, NgClass, NgStyle, SlicePipe } from '@angular/common';
+import {
+  AfterViewInit,
+  Component,
+  ElementRef,
+  OnDestroy,
+  OnInit,
+  QueryList,
+  ViewChildren,
+} from '@angular/core';
+import { AsyncPipe, DatePipe, DecimalPipe, NgClass, SlicePipe } from '@angular/common';
 import { Select, Store } from '@ngxs/store';
-import { combineLatest, Observable } from 'rxjs';
+import { combineLatest, Observable, Subscription } from 'rxjs';
 import { map } from 'rxjs/operators';
 import { MatButtonModule } from '@angular/material/button';
 import { MatIconModule } from '@angular/material/icon';
@@ -34,7 +42,6 @@ interface BatchGroup {
     DatePipe,
     DecimalPipe,
     NgClass,
-    NgStyle,
     SlicePipe,
     MatButtonModule,
     MatIconModule,
@@ -44,12 +51,17 @@ interface BatchGroup {
   templateUrl: './history.component.html',
   styleUrl: './history.component.scss',
 })
-export class HistoryComponent implements OnInit {
+export class HistoryComponent implements OnInit, AfterViewInit, OnDestroy {
   @Select(VisionState.items) items$!: Observable<any[]>;
   @Select(VisionState.expandedBatchIds) expandedBatchIds$!: Observable<string[]>;
   @Select(VisionState.loading) loading$!: Observable<boolean>;
 
+  @ViewChildren('thumbCanvas') thumbCanvasRefs!: QueryList<ElementRef<HTMLCanvasElement>>;
+
   groups$!: Observable<BatchGroup[]>;
+  private currentGroups: BatchGroup[] = [];
+  private groupsSub!: Subscription;
+  private canvasSub!: Subscription;
 
   constructor(private store: Store) {}
 
@@ -57,9 +69,21 @@ export class HistoryComponent implements OnInit {
     this.groups$ = combineLatest([this.items$, this.expandedBatchIds$]).pipe(
       map(([items, expandedIds]) => this.buildGroups(items, expandedIds)),
     );
+    this.groupsSub = this.groups$.subscribe(g => (this.currentGroups = g));
     this.store.dispatch(new LoadHistory());
   }
 
+  ngAfterViewInit(): void {
+    this.canvasSub = this.thumbCanvasRefs.changes.subscribe(() =>
+      this.paintAllVisibleCanvases(),
+    );
+  }
+
+  ngOnDestroy(): void {
+    this.groupsSub?.unsubscribe();
+    this.canvasSub?.unsubscribe();
+  }
+
   onToggle(batchId: string): void {
     this.store.dispatch(new ToggleBatchGroup({ batchId }));
     this.store.dispatch(new LoadGroupImages({ batchId }));
@@ -81,6 +105,40 @@ export class HistoryComponent implements OnInit {
     this.store.dispatch(new ClearAllHistory());
   }
 
+  renderThumbnailWithBoxes(canvas: HTMLCanvasElement, item: any): void {
+    if (!canvas || !item?.imageDataUrl) return;
+    const img = new Image();
+    img.onload = () => {
+      canvas.width = canvas.offsetWidth || 148;
+      canvas.height = 120;
+      const ctx = canvas.getContext('2d')!;
+      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+
+      for (const det of (item.detections ?? [])) {
+        if (!det.norm_box) continue;
+        const [nx1, ny1, nx2, ny2] = det.norm_box;
+        const x = nx1 * canvas.width;
+        const y = ny1 * canvas.height;
+        const w = (nx2 - nx1) * canvas.width;
+        const h = (ny2 - ny1) * canvas.height;
+        const color = this.gradeColor(det.class);
+
+        ctx.strokeStyle = color;
+        ctx.lineWidth = 1.5;
+        ctx.strokeRect(x, y, w, h);
+
+        ctx.font = 'bold 9px sans-serif';
+        const label = det.class;
+        const tw = ctx.measureText(label).width;
+        ctx.fillStyle = color;
+        ctx.fillRect(x, y - 14, tw + 4, 14);
+        ctx.fillStyle = '#fff';
+        ctx.fillText(label, x + 2, y - 3);
+      }
+    };
+    img.src = item.imageDataUrl;
+  }
+
   confidencePercent(value: number): string {
     return (value * 100).toFixed(1) + '%';
   }
@@ -123,4 +181,19 @@ export class HistoryComponent implements OnInit {
       };
     });
   }
+
+  private paintAllVisibleCanvases(): void {
+    setTimeout(() => {
+      this.thumbCanvasRefs.forEach(ref => {
+        const canvas = ref.nativeElement;
+        const archiveId = canvas.getAttribute('data-archive-id');
+        const item = this.currentGroups
+          .flatMap(g => g.items)
+          .find(i => i.archive_id === archiveId);
+        if (item?.imageDataUrl) {
+          this.renderThumbnailWithBoxes(canvas, item);
+        }
+      });
+    }, 0);
+  }
 }

+ 8 - 2
src/src.palm.vision/services/inference.service.ts

@@ -21,6 +21,7 @@ export interface DetectionResult {
   confidence: number;
   is_health_alert: boolean;
   box: [number, number, number, number];
+  norm_box?: [number, number, number, number];
 }
 
 export interface InferenceFrame {
@@ -32,7 +33,7 @@ export interface InferenceFrame {
   processing_ms: number;
   total_count: number;
   industrial_summary: Record<string, number>;
-  source: 'wasm-local';
+  source: 'wasm-local' | 'remote' | 'n8n';
 }
 
 // ── Preprocessing constants ──────────────────────────────────────────────────
@@ -60,7 +61,7 @@ export class InferenceService implements OnDestroy {
    * Preprocessing runs synchronously on the calling thread; ONNX execution
    * is dispatched to the background worker.
    */
-  analyze(file: File, batchId?: string): Observable<InferenceFrame> {
+  analyze(file: File, batchId?: string, mode: 'local-onnx' | 'local-tflite' = 'local-onnx'): Observable<InferenceFrame> {
     return new Observable<InferenceFrame>(observer => {
       const frameId = crypto.randomUUID();
       const processingStart = performance.now();
@@ -87,6 +88,7 @@ export class InferenceService implements OnDestroy {
               imageDataUrl,
               tensor: tensor.buffer,
               processingStart,
+              mode,
             }, [tensor.buffer]);
           } else {
             // Worker unavailable — emit empty frame so callers can handle gracefully
@@ -169,6 +171,10 @@ export class InferenceService implements OnDestroy {
       this.worker.onerror = (err) => {
         console.warn('[InferenceService] Worker error — falling back to no-op mode', err);
         this.worker = null;
+        // Complete all pending observables so callers never hang
+        for (const [frameId, resolve] of this.pendingMap) {
+          resolve(this.buildEmptyFrame(frameId, undefined, '', performance.now()));
+        }
         this.pendingMap.clear();
       };
     } catch {

+ 16 - 31
src/src.palm.vision/services/remote-inference.service.ts

@@ -1,12 +1,10 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable, OnDestroy } from '@angular/core';
 import { Observable, Subject, take } from 'rxjs';
-import { NgxSocketService } from 'dp-ui/socket/ngxSocket.service';
-import { FisAppMessage, MessageHeader } from 'dp-ui/fisappmessage/apprequestmessagetype';
+import { DpService } from 'dp-ui/dp.service';
+import { FisAppMessage, MessageHeader, AppMessageType } from 'dp-ui/fisappmessage/apprequestmessagetype';
 import { InferenceFrame, DetectionResult, MPOB_CLASSES, HEALTH_ALERT_CLASSES } from './inference.service';
 
-// ── Config shape loaded from ./config/config.json ────────────────────────────
-
 interface PalmVisionConfig {
   connection: {
     uacp: string;
@@ -15,8 +13,6 @@ interface PalmVisionConfig {
   };
 }
 
-// ── Edge-device external result payload ──────────────────────────────────────
-
 export interface EdgeResultPayload {
   frame: string;
   filename?: string;
@@ -34,17 +30,13 @@ export class RemoteInferenceService implements OnDestroy {
 
   constructor(
     private http: HttpClient,
-    private socket: NgxSocketService,
+    private dpService: DpService,
   ) {
     this.http.get<PalmVisionConfig>('./config/config.json')
       .pipe(take(1))
       .subscribe({ next: cfg => (this.config = cfg) });
   }
 
-  /**
-   * Encode a File as Base64 and dispatch to `PalmVision:analyze` via WebSocket.
-   * Emits one `InferenceFrame` and completes.
-   */
   analyze(file: File, sourceLabel?: string, batchId?: string): Observable<InferenceFrame> {
     return new Observable<InferenceFrame>(observer => {
       const reader = new FileReader();
@@ -62,32 +54,22 @@ export class RemoteInferenceService implements OnDestroy {
     });
   }
 
-  /** Fetch the last 50 history records from SQLite via WebSocket. */
   getHistory(): Observable<any[]> {
     return this.send<any[]>('History', 'getAll', undefined);
   }
 
-  /** Delete a single history record and its archived image. */
   deleteRecord(archiveId: string): Observable<{ deleted: boolean }> {
     return this.send('History', 'delete', { archiveId });
   }
 
-  /** Wipe all history records and archived images. */
   clearHistory(): Observable<{ deleted: number }> {
     return this.send('History', 'clearAll', undefined);
   }
 
-  /**
-   * Retrieve an archived image as a Base64 data URL via WebSocket.
-   * Follows ADR-024: REST pathway eliminated; all binary assets stream as Base64 text.
-   */
   getImage(archiveId: string): Observable<{ archiveId: string; image_data: string }> {
     return this.send('PalmHistory', 'GetImage', { archiveId });
   }
 
-  /**
-   * Persist an inference result that was computed on an edge device (no ONNX re-run).
-   */
   saveExternalResult(payload: EdgeResultPayload): Observable<any> {
     return this.send('PalmHistory', 'SaveExternalResult', payload);
   }
@@ -97,27 +79,29 @@ export class RemoteInferenceService implements OnDestroy {
     this.destroyed$.complete();
   }
 
-  // ── Private helpers ────────────────────────────────────────────────────────
-
   /**
-   * Build a minimal FIS envelope and dispatch via NgxSocketService.
-   * The gateway accepts both full FIS header and legacy flat format;
-   * we use the header envelope because NgxSocketService correlates
-   * responses by `header.messageID`.
+   * Builds a compliant FIS envelope and dispatches it via the framework's official DpService stream.
+   * Leverages core multiplexed transport pipelines rather than direct Socket.io interfaces.
    */
   private send<T>(serviceId: string, operation: string, payload: unknown): Observable<T> {
     const messageID = crypto.randomUUID();
+
+    // Package parameters inside a fully compliant enterprise envelope structure
     const message: FisAppMessage = {
       header: {
         messageID,
         serviceId,
         messageName: operation,
+        messageType: AppMessageType.Command
       } as unknown as MessageHeader,
       data: payload,
     };
+
     return new Observable<T>(observer => {
-      this.socket.sendMessage(message).subscribe({
+      // Direct call routing through the shared enterprise stream engine
+      this.dpService.stream(message).subscribe({
         next: (res: any) => {
+          // Extract body mapping parameters directly from enterprise results packets
           const body = typeof res === 'string' ? JSON.parse(res) : (res?.message ? JSON.parse(res.message) : res);
           if (body?.error) {
             observer.error(new Error(body.error));
@@ -138,6 +122,7 @@ export class RemoteInferenceService implements OnDestroy {
       confidence: d.confidence,
       is_health_alert: HEALTH_ALERT_CLASSES.includes(d.class),
       box: d.box,
+      norm_box: d.norm_box,
     }));
 
     const industrial_summary: Record<string, number> = raw?.industrial_summary
@@ -147,15 +132,15 @@ export class RemoteInferenceService implements OnDestroy {
     return {
       frameId: raw?.archive_id ?? crypto.randomUUID(),
       batchId,
-      imageDataUrl: raw?.image_base64
-        ? `data:image/jpeg;base64,${raw.image_base64}`
+      imageDataUrl: raw?.image_data
+        ? `data:image/jpeg;base64,${raw.image_data}`
         : '',
       detections,
       inference_ms: raw?.inference_ms ?? 0,
       processing_ms: raw?.processing_ms ?? 0,
       total_count: detections.length,
       industrial_summary,
-      source: 'wasm-local',
+      source: 'remote',
     };
   }
 }

+ 1 - 1
src/src.palm.vision/store/vision.actions.ts

@@ -1,6 +1,6 @@
 export class SubmitBatchAnalysis {
   static readonly type = '[Vision] SubmitBatchAnalysis';
-  constructor(public payload: { files: File[]; mode: 'local' | 'remote' }) {}
+  constructor(public payload: { files: File[]; mode: 'local-onnx' | 'local-tflite' | 'remote' }) {}
 }
 
 export class ToggleBatchGroup {

+ 42 - 10
src/src.palm.vision/store/vision.state.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@angular/core';
-import { Action, Selector, State, StateContext } from '@ngxs/store';
-import { Observable, catchError, map, merge, of, tap, throwError, toArray } from 'rxjs';
+import { Action, Selector, State, StateContext, NgxsOnInit } from '@ngxs/store';
+import { Observable, catchError, map, merge, of, switchMap, tap, timeout, toArray } from 'rxjs';
 import { InferenceFrame, InferenceService } from '../services/inference.service';
 import { RemoteInferenceService } from '../services/remote-inference.service';
 import {
@@ -31,12 +31,23 @@ const defaults: VisionStateModel = {
   defaults,
 })
 @Injectable()
-export class VisionState {
+export class VisionState implements NgxsOnInit {
   constructor(
     private inferenceService: InferenceService,
     private remoteInferenceService: RemoteInferenceService,
   ) {}
 
+  /**
+   * NGXS Lifecycle Hook — Executes automatically upon application state tree initialization.
+   * Forces the clearing of persistent cache locks to prevent UI deadlocks from local storage.
+   */
+  ngxsOnInit(ctx: StateContext<VisionStateModel>): void {
+    ctx.patchState({
+      loading: false,
+      currentInference: null,
+    });
+  }
+
   @Selector()
   static items(state: VisionStateModel): any[] {
     return state.items;
@@ -65,11 +76,30 @@ export class VisionState {
     ctx.patchState({ loading: true, currentInference: null });
     const batchId = crypto.randomUUID();
 
-    const streams: Observable<InferenceFrame>[] = payload.files.map(file =>
-      payload.mode === 'local'
-        ? this.inferenceService.analyze(file, batchId)
-        : this.remoteInferenceService.analyze(file, undefined, batchId),
-    );
+    const streams: Observable<InferenceFrame>[] = payload.files.map(file => {
+      if (payload.mode === 'remote') {
+        return this.remoteInferenceService.analyze(file, undefined, batchId);
+      } else {
+        // Pass the explicit mode string ('local-onnx' or 'local-tflite') to the local service engine
+        return this.inferenceService.analyze(file, batchId, payload.mode).pipe(
+          switchMap((localFrame: InferenceFrame) =>
+            this.remoteInferenceService.saveExternalResult({
+              frame: localFrame.imageDataUrl,
+              filename: file.name,
+              batchId: batchId,
+              detections: localFrame.detections,
+              industrial_summary: localFrame.industrial_summary,
+              inference_ms: localFrame.inference_ms,
+              processing_ms: localFrame.processing_ms,
+            }).pipe(
+              timeout(5000),
+              map(res => ({ ...localFrame, frameId: res.archive_id, mode: payload.mode })),
+              catchError(() => of({ ...localFrame, mode: payload.mode })),
+            ),
+          ),
+        );
+      }
+    });
 
     return merge(...streams).pipe(
       tap(frame => ctx.patchState({ currentInference: frame })),
@@ -124,10 +154,12 @@ export class VisionState {
   loadHistory(ctx: StateContext<VisionStateModel>): Observable<void> {
     ctx.patchState({ loading: true });
     return this.remoteInferenceService.getHistory().pipe(
+      timeout(5000),
       tap(items => ctx.patchState({ items, loading: false })),
       catchError(err => {
-        ctx.patchState({ loading: false });
-        return throwError(() => err);
+        console.warn('⚠️ [Vault State] Edge network connection lost or timed out:', err.message || err);
+        ctx.patchState({ items: [], loading: false });
+        return of(void 0);
       }),
       map(() => void 0),
     );

+ 101 - 15
src/src.palm.vision/workers/inference.worker.ts

@@ -1,21 +1,107 @@
 /// <reference lib="webworker" />
+import * as ort from 'onnxruntime-web';
 
-import { InferenceFrame } from '../services/inference.service';
+ort.env.wasm.wasmPaths = '/assets/wasm/';
+ort.env.wasm.numThreads = 1;
 
-addEventListener('message', ({ data }) => {
-  const { frameId, batchId, imageDataUrl, processingStart } = data;
+let onnxSession: ort.InferenceSession | null = null;
+let tfliteSession: any = null; // Target holder for parallel TFLite engine weights loops
 
-  const frame: InferenceFrame = {
-    frameId,
-    batchId,
-    imageDataUrl,
-    detections: [],
-    inference_ms: 0,
-    processing_ms: performance.now() - processingStart,
-    total_count: 0,
-    industrial_summary: {},
-    source: 'wasm-local',
-  };
+async function initOnnxSession(): Promise<ort.InferenceSession> {
+  if (onnxSession) return onnxSession;
+  onnxSession = await ort.InferenceSession.create('/assets/models/onnx/best.onnx', {
+    executionProviders: ['wasm'],
+  });
+  return onnxSession;
+}
 
-  postMessage(frame);
+const MPOB_CLASSES = ['Empty_Bunch', 'Underripe', 'Abnormal', 'Ripe', 'Unripe', 'Overripe'];
+const CONF_THRESHOLD = 0.25;
+
+addEventListener('message', async ({ data }) => {
+  const { frameId, batchId, imageDataUrl, tensor, processingStart, mode } = data;
+
+  try {
+    const inferenceStart = performance.now();
+    let detections: any[] = [];
+    const industrialSummary: Record<string, number> = {
+      Empty_Bunch: 0, Underripe: 0, Abnormal: 0, Ripe: 0, Unripe: 0, Overripe: 0,
+    };
+
+    // ── Engine Routing Branch Matrix ──────────────────────────────────────────
+    if (mode === 'local-tflite') {
+      // ⚠️ TFLite WASM Pipeline Fallback Placeholder Loop
+      // Explicitly capture TFLite routing and return structural data without freezing threads
+      console.warn('[Worker] TFLite WASM engine branch hit — utilizing float metrics');
+
+      // Temporary structural pass-through until local .tflite weights parsing loop is active
+      detections = [];
+    } else {
+      // Default to standard local ONNX execution stream
+      const ortSession = await initOnnxSession();
+      const floatData = new Float32Array(tensor);
+      const inputTensor = new ort.Tensor('float32', floatData, [1, 3, 640, 640]);
+
+      const outputs = await ortSession.run({ [ortSession.inputNames[0]]: inputTensor });
+      const outputKey = Object.keys(outputs)[0];
+      const outputData = outputs[outputKey].data as Float32Array;
+      const outputDims = outputs[outputKey].dims; // Shape [1, numCandidates, 6]
+
+      const numCandidates = outputDims[1] as number;
+
+      for (let i = 0; i < numCandidates; i++) {
+        const offset = i * 6;
+        const confidence = outputData[offset + 4];
+        if (confidence < CONF_THRESHOLD) continue;
+
+        const classIdx = Math.round(outputData[offset + 5]);
+        const className = MPOB_CLASSES[classIdx] ?? 'Unknown';
+
+        const nx1 = parseFloat(outputData[offset].toFixed(6));
+        const ny1 = parseFloat(outputData[offset + 1].toFixed(6));
+        const nx2 = parseFloat(outputData[offset + 2].toFixed(6));
+        const ny2 = parseFloat(outputData[offset + 3].toFixed(6));
+
+        industrialSummary[className] = (industrialSummary[className] ?? 0) + 1;
+
+        detections.push({
+          bunch_id: detections.length + 1,
+          class: className,
+          confidence: parseFloat(confidence.toFixed(4)),
+          is_health_alert: ['Abnormal', 'Empty_Bunch'].includes(className),
+          norm_box: [nx1, ny1, nx2, ny2],
+          box: [nx1 * 640, ny1 * 640, nx2 * 640, ny2 * 640],
+        });
+      }
+    }
+
+    const inferenceMs = performance.now() - inferenceStart;
+
+    postMessage({
+      frameId,
+      batchId,
+      imageDataUrl,
+      detections,
+      inference_ms: parseFloat(inferenceMs.toFixed(2)),
+      processing_ms: parseFloat((performance.now() - processingStart).toFixed(2)),
+      total_count: detections.length,
+      industrial_summary: industrialSummary,
+      source: 'wasm-local',
+    });
+
+  } catch (error: any) {
+    console.error('[InferenceWorker] Local calculation stack crash:', error);
+    postMessage({
+      frameId,
+      batchId,
+      imageDataUrl,
+      detections: [],
+      inference_ms: 0,
+      processing_ms: parseFloat((performance.now() - processingStart).toFixed(2)),
+      total_count: 0,
+      industrial_summary: {},
+      source: 'wasm-local',
+      error: error.message,
+    });
+  }
 });