Parcourir la source

feat: add analyzer and chatbot components with routing

- Implemented AnalyzerComponent for batch image analysis with drag-and-drop support.
- Created ChatbotComponent for interactive chat functionality.
- Added routing for the analyzer and chatbot components in PalmVisionModule.

feat: add history component for displaying analysis records

- Developed HistoryComponent to show historical analysis records with expandable batch details.
- Integrated loading state and clear all functionality for history records.
- Styled history component with SCSS for better UI presentation.

feat: implement inference services for local and remote analysis

- Created InferenceService for local WASM-based image analysis.
- Developed RemoteInferenceService for handling remote analysis via WebSocket.
- Added necessary data models and utility functions for inference processing.

feat: establish state management for vision module

- Introduced VisionState for managing application state related to image analysis.
- Implemented actions for submitting batch analysis, loading history, and managing batch groups.
- Integrated NGXS for state management and asynchronous data handling.

chore: add configuration file and global styles

- Added config.json for application configuration settings.
- Updated global styles to fix dropdown panel z-index issues in the Material CDK.

chore: create worker for background inference processing

- Implemented inference.worker.ts to handle image processing in a web worker.
- Ensured efficient processing without blocking the main thread.
Dr-Swopt il y a 1 semaine
Parent
commit
7ac8b83ffa

+ 1 - 0
src/app/app.routes.ts

@@ -8,4 +8,5 @@ export const routes: Routes = [
     { path:'auth', loadChildren: () => import('angularlib/login/login.module').then(m => m.LoginModule)},
     { path:'leave', loadChildren: () => import('fis/leave/leave.module').then(m => m.LeaveModule)},
     { path:'tender', loadChildren: () => import('fis/tender/tender.module').then(m => m.TenderModule)},
+    { path: 'src.palm.vision', loadChildren: () => import('../src.palm.vision/palm-vision.module').then(m => m.PalmVisionModule) },
 ];

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

@@ -51,6 +51,8 @@ export class DashboardComponent extends BaseComponent implements OnInit{
         {value:'leave-new',label:{key:'new_leave',default:'Apply New Leave'}},
         {value:'leave-applied',label:{key:'applied_leave',default:'Applied Leave'}},
         {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: 'home',
@@ -72,6 +74,14 @@ export class DashboardComponent extends BaseComponent implements OnInit{
           this.cs.navigate('/tender',{type:'sales'});
           break;
         }
+        case 'palm-analyzer' : {
+          this.cs.navigate('/src.palm.vision/analyzer');
+          break;
+        }
+        case 'palm-vault' : {
+          this.cs.navigate('/src.palm.vision/vault');
+          break;
+        }
         default: break;
       }
     }

+ 5 - 0
src/app/menu/menu.palm.ts

@@ -0,0 +1,5 @@
+export const PALM_MENU = [
+  { name: 'Industrial Grading', route: 'src.palm.vision/analyzer', icon: 'analytics' },
+  { name: 'Historical Vault', route: 'src.palm.vision/vault', icon: 'history' },
+  { name: 'Intelligence Chat', route: 'src.palm.vision/chat', icon: 'chat' }
+];

+ 1 - 1
src/config/config.json

@@ -2,7 +2,7 @@
     "connection": {
         "uacp": "https://swopt.com:8081",
         "uacp_ws": "https://fist.swopt.com/ws",
-        "uacpEmulation": "off",
+        "uacpEmulation": "on",
         "auth": {
             "google": "https://api.swopt.com/auth/google"
         }

+ 134 - 0
src/src.palm.vision/analyzer/analyzer.component.html

@@ -0,0 +1,134 @@
+<div class="analyzer-root">
+
+  <!-- Header -->
+  <div class="analyzer-header">
+    <mat-icon class="header-icon">analytics</mat-icon>
+    <h2 class="header-title">Industrial Grading Studio</h2>
+    <span class="mpob-badge">MPOB Standard</span>
+  </div>
+
+  <!-- Engine mode selector -->
+  <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>
+    </mat-form-field>
+
+    <span class="engine-status-badge" [ngClass]="mode">
+      @if (mode === 'remote') { Edge Server Active }
+      @else { WASM Runtime }
+    </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>
+      </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>
+
+  <!-- Inference results -->
+  @if (currentInference$ | async; as frame) {
+    <div class="results-panel">
+
+      <div class="results-header">
+        <span class="results-title">
+          Batch Result &mdash;
+          <strong>{{ frame.total_count }}</strong> detection(s)
+        </span>
+        <span class="timing-info">
+          Inference: {{ frame.inference_ms | number:'1.0-0' }}&nbsp;ms
+          &nbsp;|&nbsp;
+          Processing: {{ frame.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) {
+              <mat-card class="detection-card" [class.health-alert]="det.is_health_alert">
+                <mat-card-content>
+                  <span
+                    class="grade-dot"
+                    [ngStyle]="{ background: gradeColor(det.class) }"
+                  ></span>
+                  <span class="grade-label">{{ det.class }}</span>
+                  <span class="confidence-value">{{ confidencePercent(det.confidence) }}</span>
+                  @if (det.is_health_alert) {
+                    <mat-icon class="alert-icon" color="warn">warning</mat-icon>
+                  }
+                </mat-card-content>
+              </mat-card>
+            }
+          } @else {
+            <p class="no-detections">No detections in this frame.</p>
+          }
+
+          <!-- Industrial summary chips -->
+          @if (frame.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) {
+                  <span
+                    class="summary-chip"
+                    [ngStyle]="{ 'border-color': gradeColor(entry.key) }"
+                  >
+                    {{ entry.key }}: <strong>{{ entry.value }}</strong>
+                  </span>
+                }
+              </div>
+            </div>
+          }
+
+        </div>
+      </div>
+    </div>
+  }
+
+</div>

+ 259 - 0
src/src.palm.vision/analyzer/analyzer.component.scss

@@ -0,0 +1,259 @@
+.analyzer-root {
+  display: flex;
+  flex-direction: column;
+  gap: 1.5rem;
+  padding: 1.5rem;
+  max-width: 900px;
+  margin: 0 auto;
+}
+
+// ── Header ───────────────────────────────────────────────────────────────────
+
+.analyzer-header {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+
+  .header-icon {
+    font-size: 2rem;
+    width: 2rem;
+    height: 2rem;
+    color: #558b2f;
+  }
+
+  .header-title {
+    margin: 0;
+    font-size: 1.4rem;
+    font-weight: 600;
+    color: #1b5e20;
+  }
+
+  .mpob-badge {
+    margin-left: auto;
+    background: #e8f5e9;
+    color: #2e7d32;
+    border: 1px solid #a5d6a7;
+    border-radius: 12px;
+    padding: 2px 10px;
+    font-size: 0.72rem;
+    font-weight: 600;
+    letter-spacing: 0.04em;
+    text-transform: uppercase;
+  }
+}
+
+// ── Engine selector row ──────────────────────────────────────────────────────
+
+.engine-selector-row {
+  display: flex;
+  align-items: center;
+  gap: 1rem;
+
+  .engine-field {
+    width: 280px;
+  }
+
+  .engine-status-badge {
+    padding: 4px 12px;
+    border-radius: 8px;
+    font-size: 0.8rem;
+    font-weight: 600;
+
+    &.remote {
+      background: #e3f2fd;
+      color: #1565c0;
+      border: 1px solid #90caf9;
+    }
+
+    &.local {
+      background: #f3e5f5;
+      color: #6a1b9a;
+      border: 1px solid #ce93d8;
+    }
+  }
+}
+
+// ── Drop zone ────────────────────────────────────────────────────────────────
+
+.drop-zone {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 0.6rem;
+  min-height: 220px;
+  border: 2px dashed #a5d6a7;
+  border-radius: 12px;
+  background: #f9fbe7;
+  transition: border-color 0.2s, background 0.2s;
+  cursor: pointer;
+
+  &.drag-over {
+    border-color: #2e7d32;
+    background: #e8f5e9;
+  }
+
+  .drop-icon {
+    font-size: 3rem;
+    width: 3rem;
+    height: 3rem;
+    color: #81c784;
+  }
+
+  .drop-primary {
+    margin: 0;
+    font-size: 1rem;
+    color: #37474f;
+    font-weight: 500;
+  }
+
+  .drop-secondary {
+    margin: 0;
+    font-size: 0.85rem;
+    color: #90a4ae;
+  }
+
+  .loading-overlay {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 0.75rem;
+
+    .loading-label {
+      font-size: 0.9rem;
+      color: #546e7a;
+    }
+  }
+}
+
+// ── Results panel ────────────────────────────────────────────────────────────
+
+.results-panel {
+  border: 1px solid #c8e6c9;
+  border-radius: 12px;
+  background: #fff;
+  overflow: hidden;
+}
+
+.results-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.75rem 1.25rem;
+  background: #e8f5e9;
+  border-bottom: 1px solid #c8e6c9;
+
+  .results-title {
+    font-size: 0.95rem;
+    color: #1b5e20;
+  }
+
+  .timing-info {
+    font-size: 0.78rem;
+    color: #607d8b;
+  }
+}
+
+.results-body {
+  display: flex;
+  gap: 1.25rem;
+  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 {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+}
+
+.detection-card {
+  &.health-alert {
+    border-left: 4px solid #f44336;
+  }
+
+  mat-card-content {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    padding: 0.5rem 0.75rem !important;
+  }
+
+  .grade-dot {
+    width: 12px;
+    height: 12px;
+    border-radius: 50%;
+    flex-shrink: 0;
+  }
+
+  .grade-label {
+    flex: 1;
+    font-weight: 500;
+    font-size: 0.9rem;
+  }
+
+  .confidence-value {
+    font-size: 0.85rem;
+    color: #546e7a;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .alert-icon {
+    font-size: 1.1rem;
+    width: 1.1rem;
+    height: 1.1rem;
+  }
+}
+
+.no-detections {
+  font-size: 0.9rem;
+  color: #90a4ae;
+  font-style: italic;
+  margin: 0.5rem 0;
+}
+
+// ── Industrial summary ───────────────────────────────────────────────────────
+
+.summary-block {
+  margin-top: 0.75rem;
+
+  .summary-title {
+    font-size: 0.8rem;
+    text-transform: uppercase;
+    letter-spacing: 0.06em;
+    color: #607d8b;
+    margin: 0 0 0.4rem;
+  }
+
+  .summary-chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.4rem;
+  }
+
+  .summary-chip {
+    padding: 2px 10px;
+    border-radius: 10px;
+    border: 1.5px solid;
+    font-size: 0.78rem;
+    background: #fafafa;
+    color: #37474f;
+  }
+}

+ 90 - 0
src/src.palm.vision/analyzer/analyzer.component.ts

@@ -0,0 +1,90 @@
+import { Component } 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 { MatSelectModule } from '@angular/material/select';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatButtonModule } from '@angular/material/button';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatCardModule } from '@angular/material/card';
+import { MatIconModule } from '@angular/material/icon';
+import { VisionState } from '../store/vision.state';
+import { SubmitBatchAnalysis } from '../store/vision.actions';
+import { InferenceFrame } from '../services/inference.service';
+
+@Component({
+  selector: 'app-analyzer',
+  standalone: true,
+  imports: [
+    AsyncPipe,
+    DecimalPipe,
+    KeyValuePipe,
+    NgClass,
+    NgStyle,
+    FormsModule,
+    MatSelectModule,
+    MatFormFieldModule,
+    MatButtonModule,
+    MatProgressSpinnerModule,
+    MatCardModule,
+    MatIconModule,
+  ],
+  templateUrl: './analyzer.component.html',
+  styleUrl: './analyzer.component.scss',
+})
+export class AnalyzerComponent {
+  @Select(VisionState.loading) loading$!: Observable<boolean>;
+  @Select(VisionState.currentInference) currentInference$!: Observable<InferenceFrame | null>;
+
+  mode: 'local' | 'remote' = 'remote';
+  isDragOver = false;
+
+  constructor(private store: Store) {}
+
+  onFileInput(event: Event): void {
+    const input = event.target as HTMLInputElement;
+    if (input.files?.length) {
+      this.submit(Array.from(input.files));
+      input.value = '';
+    }
+  }
+
+  onDrop(event: DragEvent): void {
+    event.preventDefault();
+    this.isDragOver = false;
+    const files = Array.from(event.dataTransfer?.files ?? []).filter(f =>
+      f.type.startsWith('image/'),
+    );
+    if (files.length) this.submit(files);
+  }
+
+  onDragOver(event: DragEvent): void {
+    event.preventDefault();
+    this.isDragOver = true;
+  }
+
+  onDragLeave(): void {
+    this.isDragOver = false;
+  }
+
+  confidencePercent(value: number): string {
+    return (value * 100).toFixed(1) + '%';
+  }
+
+  gradeColor(cls: string): string {
+    const palette: Record<string, string> = {
+      Ripe: '#4caf50',
+      Unripe: '#ff9800',
+      Underripe: '#ffeb3b',
+      Overripe: '#9c27b0',
+      Abnormal: '#f44336',
+      Empty_Bunch: '#607d8b',
+    };
+    return palette[cls] ?? '#757575';
+  }
+
+  private submit(files: File[]): void {
+    this.store.dispatch(new SubmitBatchAnalysis({ files, mode: this.mode }));
+  }
+}

+ 22 - 0
src/src.palm.vision/chatbot/chatbot.component.ts

@@ -0,0 +1,22 @@
+import { Component } from '@angular/core';
+import { ChatComponent } from 'angularlib/chat/chat.component';
+
+@Component({
+  selector: 'app-chatbot',
+  standalone: true,
+  imports: [ChatComponent],
+  template: `
+    <div class="chatbot-container">
+      <chat title="Industrial Intelligence Portal"></chat>
+    </div>
+  `,
+  styles: [`
+    .chatbot-container {
+      width: 100%;
+      height: calc(100vh - 64px);
+      padding: 16px;
+      box-sizing: border-box;
+    }
+  `]
+})
+export class ChatbotComponent {}

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

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

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

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

+ 121 - 0
src/src.palm.vision/history/history.component.html

@@ -0,0 +1,121 @@
+<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()">
+      <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>
+      <span class="loading-label">Loading records&hellip;</span>
+    </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>
+      </div>
+    }
+
+    @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-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">
+
+                  @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="thumb-overlay">
+                    <span>{{ item.total_count ?? 0 }} det.</span>
+                    <span>{{ item.inference_ms | number:'1.0-0' }}&nbsp;ms</span>
+                  </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>
+                      }
+                    </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>
+
+                </div>
+              }
+            </div>
+          </div>
+        }
+
+      </div>
+    }
+  }
+
+</div>

+ 281 - 0
src/src.palm.vision/history/history.component.scss

@@ -0,0 +1,281 @@
+.history-root {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  padding: 1.5rem;
+  max-width: 960px;
+  margin: 0 auto;
+}
+
+// ── Header ────────────────────────────────────────────────────────────────────
+
+.history-header {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+
+  .header-icon {
+    font-size: 2rem;
+    width: 2rem;
+    height: 2rem;
+    color: #558b2f;
+  }
+
+  .header-title {
+    margin: 0;
+    font-size: 1.4rem;
+    font-weight: 600;
+    color: #1b5e20;
+  }
+
+  .mpob-badge {
+    background: #e8f5e9;
+    color: #2e7d32;
+    border: 1px solid #a5d6a7;
+    border-radius: 12px;
+    padding: 2px 10px;
+    font-size: 0.72rem;
+    font-weight: 600;
+    letter-spacing: 0.04em;
+    text-transform: uppercase;
+  }
+
+  .clear-btn {
+    margin-left: auto;
+  }
+}
+
+// ── Loading ───────────────────────────────────────────────────────────────────
+
+.loading-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.75rem;
+  padding: 2rem;
+
+  .loading-label {
+    font-size: 0.9rem;
+    color: #546e7a;
+  }
+}
+
+// ── Empty state ───────────────────────────────────────────────────────────────
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 3rem;
+  color: #90a4ae;
+
+  .empty-icon {
+    font-size: 3rem;
+    width: 3rem;
+    height: 3rem;
+  }
+
+  .empty-label {
+    font-size: 0.95rem;
+    font-style: italic;
+    margin: 0;
+  }
+}
+
+// ── Batch card ────────────────────────────────────────────────────────────────
+
+.batch-card {
+  border: 1px solid #c8e6c9;
+  border-radius: 10px;
+  overflow: hidden;
+  background: #fff;
+
+  &.expanded {
+    border-color: #81c784;
+  }
+}
+
+// ── Master row ────────────────────────────────────────────────────────────────
+
+.batch-header {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+  padding: 0.75rem 1rem;
+  background: #f1f8e9;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.15s;
+
+  &:hover {
+    background: #e8f5e9;
+  }
+
+  .chevron {
+    color: #558b2f;
+    transition: transform 0.2s;
+    flex-shrink: 0;
+
+    &.rotated {
+      transform: rotate(90deg);
+    }
+  }
+
+  .batch-meta {
+    display: flex;
+    flex-direction: column;
+    min-width: 160px;
+
+    .batch-timestamp {
+      font-size: 0.85rem;
+      font-weight: 500;
+      color: #37474f;
+    }
+
+    .batch-id-label {
+      font-size: 0.7rem;
+      color: #90a4ae;
+      font-family: monospace;
+    }
+  }
+
+  .batch-stats {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    flex: 1;
+    flex-wrap: wrap;
+  }
+
+  .stat-chip {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.2rem;
+    padding: 2px 8px;
+    border-radius: 8px;
+    font-size: 0.78rem;
+
+    mat-icon {
+      font-size: 0.9rem;
+      width: 0.9rem;
+      height: 0.9rem;
+    }
+
+    &.count {
+      background: #e8f5e9;
+      color: #2e7d32;
+    }
+
+    &.confidence {
+      background: #e3f2fd;
+      color: #1565c0;
+    }
+  }
+
+  .mode-badge {
+    padding: 2px 8px;
+    border-radius: 8px;
+    font-size: 0.75rem;
+    font-weight: 600;
+
+    &.remote {
+      background: #e3f2fd;
+      color: #1565c0;
+      border: 1px solid #90caf9;
+    }
+
+    &.local {
+      background: #f3e5f5;
+      color: #6a1b9a;
+      border: 1px solid #ce93d8;
+    }
+  }
+
+  .delete-btn {
+    margin-left: auto;
+    flex-shrink: 0;
+  }
+}
+
+// ── Detail drawer ─────────────────────────────────────────────────────────────
+
+.batch-detail {
+  padding: 1rem;
+  border-top: 1px solid #c8e6c9;
+  background: #fafafa;
+}
+
+// ── Thumbnail grid ────────────────────────────────────────────────────────────
+
+.thumb-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.75rem;
+}
+
+.thumb-card {
+  position: relative;
+  width: 160px;
+  border-radius: 8px;
+  overflow: hidden;
+  border: 1px solid #e0e0e0;
+  background: #f5f5f5;
+
+  .thumb-img {
+    width: 100%;
+    height: 120px;
+    object-fit: cover;
+    display: block;
+  }
+
+  .thumb-placeholder {
+    width: 100%;
+    height: 120px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #eeeeee;
+  }
+
+  .thumb-overlay {
+    display: flex;
+    justify-content: space-between;
+    padding: 3px 6px;
+    background: rgba(0, 0, 0, 0.6);
+    font-size: 0.7rem;
+    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;
+    right: 2px;
+    width: 24px;
+    height: 24px;
+    line-height: 24px;
+
+    mat-icon {
+      font-size: 1rem;
+      width: 1rem;
+      height: 1rem;
+    }
+  }
+}

+ 126 - 0
src/src.palm.vision/history/history.component.ts

@@ -0,0 +1,126 @@
+import { Component, OnInit } from '@angular/core';
+import { AsyncPipe, DatePipe, DecimalPipe, NgClass, NgStyle, SlicePipe } from '@angular/common';
+import { Select, Store } from '@ngxs/store';
+import { combineLatest, Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { VisionState } from '../store/vision.state';
+import {
+  ClearAllHistory,
+  DeleteHistoryRecord,
+  LoadGroupImages,
+  LoadHistory,
+  ToggleBatchGroup,
+} from '../store/vision.actions';
+
+interface BatchGroup {
+  batchId: string;
+  timestamp: string;
+  totalCount: number;
+  avgConfidencePct: number;
+  mode: string;
+  items: any[];
+  isExpanded: boolean;
+}
+
+@Component({
+  selector: 'app-history',
+  standalone: true,
+  imports: [
+    AsyncPipe,
+    DatePipe,
+    DecimalPipe,
+    NgClass,
+    NgStyle,
+    SlicePipe,
+    MatButtonModule,
+    MatIconModule,
+    MatProgressSpinnerModule,
+    MatTooltipModule,
+  ],
+  templateUrl: './history.component.html',
+  styleUrl: './history.component.scss',
+})
+export class HistoryComponent implements OnInit {
+  @Select(VisionState.items) items$!: Observable<any[]>;
+  @Select(VisionState.expandedBatchIds) expandedBatchIds$!: Observable<string[]>;
+  @Select(VisionState.loading) loading$!: Observable<boolean>;
+
+  groups$!: Observable<BatchGroup[]>;
+
+  constructor(private store: Store) {}
+
+  ngOnInit(): void {
+    this.groups$ = combineLatest([this.items$, this.expandedBatchIds$]).pipe(
+      map(([items, expandedIds]) => this.buildGroups(items, expandedIds)),
+    );
+    this.store.dispatch(new LoadHistory());
+  }
+
+  onToggle(batchId: string): void {
+    this.store.dispatch(new ToggleBatchGroup({ batchId }));
+    this.store.dispatch(new LoadGroupImages({ batchId }));
+  }
+
+  onDeleteBatch(group: BatchGroup, event: MouseEvent): void {
+    event.stopPropagation();
+    for (const item of group.items) {
+      this.store.dispatch(new DeleteHistoryRecord({ id: item.archive_id }));
+    }
+  }
+
+  onDeleteItem(archiveId: string, event: MouseEvent): void {
+    event.stopPropagation();
+    this.store.dispatch(new DeleteHistoryRecord({ id: archiveId }));
+  }
+
+  onClearAll(): void {
+    this.store.dispatch(new ClearAllHistory());
+  }
+
+  confidencePercent(value: number): string {
+    return (value * 100).toFixed(1) + '%';
+  }
+
+  gradeColor(cls: string): string {
+    const palette: Record<string, string> = {
+      Ripe: '#4caf50',
+      Unripe: '#ff9800',
+      Underripe: '#ffeb3b',
+      Overripe: '#9c27b0',
+      Abnormal: '#f44336',
+      Empty_Bunch: '#607d8b',
+    };
+    return palette[cls] ?? '#757575';
+  }
+
+  private buildGroups(items: any[], expandedIds: string[]): BatchGroup[] {
+    const groupMap = new Map<string, any[]>();
+    for (const item of items) {
+      const bid = item.batch_id ?? 'unknown';
+      if (!groupMap.has(bid)) groupMap.set(bid, []);
+      groupMap.get(bid)!.push(item);
+    }
+
+    return Array.from(groupMap.entries()).map(([batchId, batchItems]) => {
+      const totalCount = batchItems.reduce((s, i) => s + (i.total_count ?? 0), 0);
+      const allDetections: any[] = batchItems.flatMap((i: any) => i.detections ?? []);
+      const avgConfidencePct = allDetections.length
+        ? (allDetections.reduce((s, d) => s + (d.confidence ?? 0), 0) / allDetections.length) * 100
+        : 0;
+
+      return {
+        batchId,
+        timestamp: batchItems[0]?.created_at ?? batchItems[0]?.timestamp ?? '',
+        totalCount,
+        avgConfidencePct,
+        mode: batchItems[0]?.mode ?? 'remote',
+        items: batchItems,
+        isExpanded: expandedIds.includes(batchId),
+      };
+    });
+  }
+}

+ 18 - 0
src/src.palm.vision/palm-vision.module.ts

@@ -0,0 +1,18 @@
+import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+import { provideStates } from '@ngxs/store';
+import { VisionState } from './store/vision.state';
+
+const routes: Routes = [
+  { path: '', redirectTo: 'analyzer', pathMatch: 'full' },
+  { path: 'analyzer', loadComponent: () => import('./analyzer/analyzer.component').then(m => m.AnalyzerComponent) },
+  { path: 'vault', loadComponent: () => import('./history/history.component').then(m => m.HistoryComponent) },
+  { path: 'chat', loadComponent: () => import('./chatbot/chatbot.component').then(m => m.ChatbotComponent) }
+];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: [provideStates([VisionState])],
+})
+export class PalmVisionModule {}

+ 207 - 0
src/src.palm.vision/services/inference.service.ts

@@ -0,0 +1,207 @@
+import { Injectable, OnDestroy } from '@angular/core';
+import { Observable, Subject, BehaviorSubject } from 'rxjs';
+
+// ── MPOB standard detection classes (indices 0–5) ───────────────────────────
+export const MPOB_CLASSES: string[] = [
+  'Empty_Bunch',
+  'Underripe',
+  'Abnormal',
+  'Ripe',
+  'Unripe',
+  'Overripe',
+];
+
+export const HEALTH_ALERT_CLASSES: string[] = ['Abnormal', 'Empty_Bunch'];
+
+// ── Domain types ─────────────────────────────────────────────────────────────
+
+export interface DetectionResult {
+  bunch_id: number;
+  class: string;
+  confidence: number;
+  is_health_alert: boolean;
+  box: [number, number, number, number];
+}
+
+export 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';
+}
+
+// ── Preprocessing constants ──────────────────────────────────────────────────
+
+const MODEL_INPUT_SIZE = 640;
+
+@Injectable({ providedIn: 'root' })
+export class InferenceService implements OnDestroy {
+  /** Emits each completed inference frame to subscribers */
+  readonly results$ = new Subject<InferenceFrame>();
+
+  /** Tracks number of frames pending in the processing queue */
+  readonly queueDepth$ = new BehaviorSubject<number>(0);
+
+  private worker: Worker | null = null;
+  private destroyed$ = new Subject<void>();
+  private pendingMap = new Map<string, (frame: InferenceFrame) => void>();
+
+  constructor() {
+    this.initWorker();
+  }
+
+  /**
+   * Submit a file for local WASM inference.
+   * Preprocessing runs synchronously on the calling thread; ONNX execution
+   * is dispatched to the background worker.
+   */
+  analyze(file: File, batchId?: string): Observable<InferenceFrame> {
+    return new Observable<InferenceFrame>(observer => {
+      const frameId = crypto.randomUUID();
+      const processingStart = performance.now();
+
+      this.queueDepth$.next(this.queueDepth$.value + 1);
+
+      const reader = new FileReader();
+      reader.onload = async () => {
+        try {
+          const imageDataUrl = reader.result as string;
+          const tensor = await this.preprocessImage(imageDataUrl);
+
+          if (this.worker) {
+            this.pendingMap.set(frameId, (frame) => {
+              this.queueDepth$.next(Math.max(0, this.queueDepth$.value - 1));
+              observer.next(frame);
+              observer.complete();
+              this.results$.next(frame);
+            });
+
+            this.worker.postMessage({
+              frameId,
+              batchId,
+              imageDataUrl,
+              tensor: tensor.buffer,
+              processingStart,
+            }, [tensor.buffer]);
+          } else {
+            // Worker unavailable — emit empty frame so callers can handle gracefully
+            const frame: InferenceFrame = this.buildEmptyFrame(
+              frameId, batchId, imageDataUrl, processingStart
+            );
+            this.queueDepth$.next(Math.max(0, this.queueDepth$.value - 1));
+            observer.next(frame);
+            observer.complete();
+            this.results$.next(frame);
+          }
+        } catch (err) {
+          this.queueDepth$.next(Math.max(0, this.queueDepth$.value - 1));
+          observer.error(err);
+        }
+      };
+      reader.onerror = () => {
+        this.queueDepth$.next(Math.max(0, this.queueDepth$.value - 1));
+        observer.error(new Error('FileReader failed to read image'));
+      };
+      reader.readAsDataURL(file);
+    });
+  }
+
+  /**
+   * Decode image, resize to 640×640, strip alpha, normalize [0,1], return CHW Float32Array.
+   * Output shape: [1, 3, 640, 640]
+   */
+  async preprocessImage(imageDataUrl: string): Promise<Float32Array> {
+    const img = await this.loadImage(imageDataUrl);
+
+    const canvas = document.createElement('canvas');
+    canvas.width = MODEL_INPUT_SIZE;
+    canvas.height = MODEL_INPUT_SIZE;
+    const ctx = canvas.getContext('2d')!;
+    ctx.drawImage(img, 0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
+
+    const { data } = ctx.getImageData(0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
+    const pixelCount = MODEL_INPUT_SIZE * MODEL_INPUT_SIZE;
+    const tensor = new Float32Array(3 * pixelCount);
+
+    // RGBA → CHW: R channel, then G channel, then B channel
+    for (let i = 0; i < pixelCount; i++) {
+      tensor[i]                    = data[i * 4]     / 255.0; // R
+      tensor[pixelCount + i]       = data[i * 4 + 1] / 255.0; // G
+      tensor[2 * pixelCount + i]   = data[i * 4 + 2] / 255.0; // B
+    }
+
+    return tensor;
+  }
+
+  ngOnDestroy(): void {
+    this.destroyed$.next();
+    this.destroyed$.complete();
+    this.worker?.terminate();
+    this.worker = null;
+    this.results$.complete();
+    this.queueDepth$.complete();
+  }
+
+  // ── Private helpers ────────────────────────────────────────────────────────
+
+  private initWorker(): void {
+    try {
+      // Worker script is expected at assets/workers/inference.worker.js
+      // Built separately; graceful degradation if absent
+      this.worker = new Worker(
+        new URL('../workers/inference.worker', import.meta.url),
+        { type: 'module' }
+      );
+
+      this.worker.onmessage = ({ data }: MessageEvent<InferenceFrame>) => {
+        const resolve = this.pendingMap.get(data.frameId);
+        if (resolve) {
+          this.pendingMap.delete(data.frameId);
+          resolve(data);
+        }
+      };
+
+      this.worker.onerror = (err) => {
+        console.warn('[InferenceService] Worker error — falling back to no-op mode', err);
+        this.worker = null;
+        this.pendingMap.clear();
+      };
+    } catch {
+      // Worker URL may not exist during initial scaffolding — safe to ignore
+      this.worker = null;
+    }
+  }
+
+  private loadImage(dataUrl: string): Promise<HTMLImageElement> {
+    return new Promise((resolve, reject) => {
+      const img = new Image();
+      img.onload = () => resolve(img);
+      img.onerror = reject;
+      img.src = dataUrl;
+    });
+  }
+
+  private buildEmptyFrame(
+    frameId: string,
+    batchId: string | undefined,
+    imageDataUrl: string,
+    processingStart: number,
+  ): InferenceFrame {
+    return {
+      frameId,
+      batchId,
+      imageDataUrl,
+      detections: [],
+      inference_ms: 0,
+      processing_ms: performance.now() - processingStart,
+      total_count: 0,
+      industrial_summary: {},
+      source: 'wasm-local',
+    };
+  }
+}

+ 161 - 0
src/src.palm.vision/services/remote-inference.service.ts

@@ -0,0 +1,161 @@
+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 { InferenceFrame, DetectionResult, MPOB_CLASSES, HEALTH_ALERT_CLASSES } from './inference.service';
+
+// ── Config shape loaded from ./config/config.json ────────────────────────────
+
+interface PalmVisionConfig {
+  connection: {
+    uacp: string;
+    uacp_ws: string;
+    uacpEmulation: string;
+  };
+}
+
+// ── Edge-device external result payload ──────────────────────────────────────
+
+export interface EdgeResultPayload {
+  frame: string;
+  filename?: string;
+  batchId?: string;
+  detections: DetectionResult[];
+  industrial_summary: Record<string, number>;
+  inference_ms: number;
+  processing_ms?: number;
+}
+
+@Injectable({ providedIn: 'root' })
+export class RemoteInferenceService implements OnDestroy {
+  private config: PalmVisionConfig | null = null;
+  private destroyed$ = new Subject<void>();
+
+  constructor(
+    private http: HttpClient,
+    private socket: NgxSocketService,
+  ) {
+    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();
+      reader.onload = () => {
+        const frame = reader.result as string;
+        this.send<any>('PalmVision', 'analyze', { frame, sourceLabel, batchId })
+          .subscribe({
+            next: raw => observer.next(this.mapAnalysisResponse(raw, batchId)),
+            error: err => observer.error(err),
+            complete: () => observer.complete(),
+          });
+      };
+      reader.onerror = () => observer.error(new Error('FileReader failed to read image'));
+      reader.readAsDataURL(file);
+    });
+  }
+
+  /** 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);
+  }
+
+  ngOnDestroy(): void {
+    this.destroyed$.next();
+    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`.
+   */
+  private send<T>(serviceId: string, operation: string, payload: unknown): Observable<T> {
+    const messageID = crypto.randomUUID();
+    const message: FisAppMessage = {
+      header: {
+        messageID,
+        serviceId,
+        messageName: operation,
+      } as unknown as MessageHeader,
+      data: payload,
+    };
+    return new Observable<T>(observer => {
+      this.socket.sendMessage(message).subscribe({
+        next: (res: any) => {
+          const body = typeof res === 'string' ? JSON.parse(res) : (res?.message ? JSON.parse(res.message) : res);
+          if (body?.error) {
+            observer.error(new Error(body.error));
+          } else {
+            observer.next(body as T);
+          }
+        },
+        error: err => observer.error(err),
+        complete: () => observer.complete(),
+      });
+    });
+  }
+
+  private mapAnalysisResponse(raw: any, batchId?: string): InferenceFrame {
+    const detections: DetectionResult[] = (raw?.detections ?? []).map((d: any) => ({
+      bunch_id: d.bunch_id,
+      class: d.class,
+      confidence: d.confidence,
+      is_health_alert: HEALTH_ALERT_CLASSES.includes(d.class),
+      box: d.box,
+    }));
+
+    const industrial_summary: Record<string, number> = raw?.industrial_summary
+      ?? raw?.technical_evidence?.industrial_summary
+      ?? {};
+
+    return {
+      frameId: raw?.archive_id ?? crypto.randomUUID(),
+      batchId,
+      imageDataUrl: raw?.image_base64
+        ? `data:image/jpeg;base64,${raw.image_base64}`
+        : '',
+      detections,
+      inference_ms: raw?.inference_ms ?? 0,
+      processing_ms: raw?.processing_ms ?? 0,
+      total_count: detections.length,
+      industrial_summary,
+      source: 'wasm-local',
+    };
+  }
+}

+ 27 - 0
src/src.palm.vision/store/vision.actions.ts

@@ -0,0 +1,27 @@
+export class SubmitBatchAnalysis {
+  static readonly type = '[Vision] SubmitBatchAnalysis';
+  constructor(public payload: { files: File[]; mode: 'local' | 'remote' }) {}
+}
+
+export class ToggleBatchGroup {
+  static readonly type = '[Vision] ToggleBatchGroup';
+  constructor(public payload: { batchId: string }) {}
+}
+
+export class LoadGroupImages {
+  static readonly type = '[Vision] LoadGroupImages';
+  constructor(public payload: { batchId: string }) {}
+}
+
+export class LoadHistory {
+  static readonly type = '[Vision] LoadHistory';
+}
+
+export class DeleteHistoryRecord {
+  static readonly type = '[Vision] DeleteHistoryRecord';
+  constructor(public payload: { id: string }) {}
+}
+
+export class ClearAllHistory {
+  static readonly type = '[Vision] ClearAllHistory';
+}

+ 159 - 0
src/src.palm.vision/store/vision.state.ts

@@ -0,0 +1,159 @@
+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 { InferenceFrame, InferenceService } from '../services/inference.service';
+import { RemoteInferenceService } from '../services/remote-inference.service';
+import {
+  ClearAllHistory,
+  DeleteHistoryRecord,
+  LoadGroupImages,
+  LoadHistory,
+  SubmitBatchAnalysis,
+  ToggleBatchGroup,
+} from './vision.actions';
+
+export interface VisionStateModel {
+  items: any[];
+  loading: boolean;
+  expandedBatchIds: string[];
+  currentInference: any | null;
+}
+
+const defaults: VisionStateModel = {
+  items: [],
+  loading: false,
+  expandedBatchIds: [],
+  currentInference: null,
+};
+
+@State<VisionStateModel>({
+  name: 'visionState',
+  defaults,
+})
+@Injectable()
+export class VisionState {
+  constructor(
+    private inferenceService: InferenceService,
+    private remoteInferenceService: RemoteInferenceService,
+  ) {}
+
+  @Selector()
+  static items(state: VisionStateModel): any[] {
+    return state.items;
+  }
+
+  @Selector()
+  static loading(state: VisionStateModel): boolean {
+    return state.loading;
+  }
+
+  @Selector()
+  static expandedBatchIds(state: VisionStateModel): string[] {
+    return state.expandedBatchIds;
+  }
+
+  @Selector()
+  static currentInference(state: VisionStateModel): any | null {
+    return state.currentInference;
+  }
+
+  @Action(SubmitBatchAnalysis)
+  submitBatchAnalysis(
+    ctx: StateContext<VisionStateModel>,
+    { payload }: SubmitBatchAnalysis,
+  ): Observable<InferenceFrame[]> {
+    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),
+    );
+
+    return merge(...streams).pipe(
+      tap(frame => ctx.patchState({ currentInference: frame })),
+      toArray(),
+      tap(() => ctx.patchState({ loading: false })),
+    );
+  }
+
+  @Action(ToggleBatchGroup)
+  toggleBatchGroup(
+    ctx: StateContext<VisionStateModel>,
+    { payload }: ToggleBatchGroup,
+  ): void {
+    const { expandedBatchIds } = ctx.getState();
+    const isExpanded = expandedBatchIds.includes(payload.batchId);
+    ctx.patchState({
+      expandedBatchIds: isExpanded
+        ? expandedBatchIds.filter(id => id !== payload.batchId)
+        : [...expandedBatchIds, payload.batchId],
+    });
+  }
+
+  @Action(LoadGroupImages)
+  loadGroupImages(
+    ctx: StateContext<VisionStateModel>,
+    { payload }: LoadGroupImages,
+  ): Observable<void> {
+    const { items } = ctx.getState();
+    const pending = items.filter(
+      (item: any) => item.batch_id === payload.batchId && !item.imageDataUrl,
+    );
+
+    if (!pending.length) return of(void 0);
+
+    return merge(
+      ...pending.map((item: any) =>
+        this.remoteInferenceService.getImage(item.archive_id).pipe(
+          tap(res => {
+            const current = ctx.getState().items;
+            ctx.patchState({
+              items: current.map((i: any) =>
+                i.archive_id === res.archiveId ? { ...i, imageDataUrl: res.image_data } : i,
+              ),
+            });
+          }),
+        ),
+      ),
+    ).pipe(map(() => void 0));
+  }
+
+  @Action(LoadHistory)
+  loadHistory(ctx: StateContext<VisionStateModel>): Observable<void> {
+    ctx.patchState({ loading: true });
+    return this.remoteInferenceService.getHistory().pipe(
+      tap(items => ctx.patchState({ items, loading: false })),
+      catchError(err => {
+        ctx.patchState({ loading: false });
+        return throwError(() => err);
+      }),
+      map(() => void 0),
+    );
+  }
+
+  @Action(DeleteHistoryRecord)
+  deleteHistoryRecord(
+    ctx: StateContext<VisionStateModel>,
+    { payload }: DeleteHistoryRecord,
+  ): Observable<void> {
+    return this.remoteInferenceService.deleteRecord(payload.id).pipe(
+      tap(() => {
+        const { items } = ctx.getState();
+        ctx.patchState({
+          items: items.filter((i: any) => i.archive_id !== payload.id),
+        });
+      }),
+      map(() => void 0),
+    );
+  }
+
+  @Action(ClearAllHistory)
+  clearAllHistory(ctx: StateContext<VisionStateModel>): Observable<void> {
+    return this.remoteInferenceService.clearHistory().pipe(
+      tap(() => ctx.patchState({ items: [], expandedBatchIds: [] })),
+      map(() => void 0),
+    );
+  }
+}

+ 21 - 0
src/src.palm.vision/workers/inference.worker.ts

@@ -0,0 +1,21 @@
+/// <reference lib="webworker" />
+
+import { InferenceFrame } from '../services/inference.service';
+
+addEventListener('message', ({ data }) => {
+  const { frameId, batchId, imageDataUrl, processingStart } = data;
+
+  const frame: InferenceFrame = {
+    frameId,
+    batchId,
+    imageDataUrl,
+    detections: [],
+    inference_ms: 0,
+    processing_ms: performance.now() - processingStart,
+    total_count: 0,
+    industrial_summary: {},
+    source: 'wasm-local',
+  };
+
+  postMessage(frame);
+});

+ 9 - 0
src/styles.scss

@@ -2,4 +2,13 @@
 body {
     margin: 0;
     font-family: Arial, Helvetica, sans-serif;
+}
+
+/* Palm Vision — engine selector overlay fix.
+   The Material CDK portal renders this panel outside the component view,
+   so the rule must live in global scope to avoid z-index clipping by
+   enterprise layout panels (sidebars, mat-sidenav, sticky toolbars). */
+.engine-select-dropdown-panel {
+  z-index: 9999 !important;
+  position: absolute !important;
 }