enzo 4 дней назад
Родитель
Сommit
242dd1e87f

+ 8 - 2
angular.json

@@ -198,14 +198,20 @@
                   "replace": "src/dependencies/angularlib/environments/environment.ts",
                   "replace": "src/dependencies/angularlib/environments/environment.ts",
                   "with":"src/dependencies/angularlib/environments/environment.t.ts"
                   "with":"src/dependencies/angularlib/environments/environment.t.ts"
                 }
                 }
-              ],
-              "serviceWorker": "ngsw-config.json"
+              ]
             }
             }
           },
           },
           "defaultConfiguration": "development"
           "defaultConfiguration": "development"
         },
         },
         "serve": {
         "serve": {
           "builder": "@angular/build:dev-server",
           "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": {
           "configurations": {
             "production": {
             "production": {
               "buildTarget": "fisapp-ui:build: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",
     "dp-ui": "file:src/dependencies/dp-ui",
     "fis": "file:src/dependencies/fis",
     "fis": "file:src/dependencies/fis",
     "fis-commons": "https://cdn.swopt.com/npm/fis-commons",
     "fis-commons": "https://cdn.swopt.com/npm/fis-commons",
+    "onnxruntime-web": "^1.27.0",
     "primeng": "^21.1.1",
     "primeng": "^21.1.1",
     "rxjs": "~7.8.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.8.1",
     "tslib": "^2.8.1",

+ 2 - 2
src/app/app.config.ts

@@ -1,4 +1,4 @@
-import { ApplicationConfig, importProvidersFrom } from '@angular/core';
+import { ApplicationConfig, importProvidersFrom, isDevMode } from '@angular/core';
 import {
 import {
   provideRouter,
   provideRouter,
   withHashLocation,
   withHashLocation,
@@ -35,7 +35,7 @@ export const appConfig: ApplicationConfig = {
     ),
     ),
     provideHttpClient(withXhr()),
     provideHttpClient(withXhr()),
     provideServiceWorker('ngsw-worker.js', {
     provideServiceWorker('ngsw-worker.js', {
-      enabled: true,
+      enabled: !isDevMode(),
       registrationStrategy: 'registerImmediately',
       registrationStrategy: 'registerImmediately',
       // registrationStrategy: 'registerWhenStable:30000'
       // registrationStrategy: 'registerWhenStable:30000'
     }),
     }),

+ 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:'auth', loadChildren: () => import('angularlib/login/login.module').then(m => m.LoginModule)},
     { path:'leave', loadChildren: () => import('fis/leave/leave.module').then(m => m.LeaveModule)},
     { 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:'tender', loadChildren: () => import('fis/tender/tender.module').then(m => m.TenderModule)},
+    { path:'src.palm.vision', loadChildren: () => import('angularlib/palm-vision/palm-vision.module').then(m => m.PalmVisionModule)},
 ];
 ];

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

@@ -68,6 +68,18 @@ export class DashboardComponent extends BaseComponent implements OnInit {
           value: 'leave-approval',
           value: 'leave-approval',
           label: { key: 'leave_approval', default: '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',
     value: 'home',
@@ -89,6 +101,18 @@ export class DashboardComponent extends BaseComponent implements OnInit {
           this.cs.navigate('/tender', { type: 'sales' });
           this.cs.navigate('/tender', { type: 'sales' });
           break;
           break;
         }
         }
+        case 'palm-analyzer': {
+          this.cs.navigate('/src.palm.vision/analyzer');
+          break;
+        }
+        case 'palm-vault': {
+          this.cs.navigate('/src.palm.vision/vault');
+          break;
+        }
+        case 'palm-chat': {
+          this.cs.navigate('/src.palm.vision/chat');
+          break;
+        }
         default:
         default:
           break;
           break;
       }
       }

+ 3 - 3
src/config/config.json

@@ -1,8 +1,8 @@
 {
 {
     "connection": {
     "connection": {
-        "uacp": "https://swopt.com:8081",
-        "uacp_ws": "https://fist.swopt.com/ws",
-        "uacpEmulation": "off",
+        "uacp": "https://192.168.100.100:3000",
+        "uacp_ws": "wss://192.168.100.100:3000",
+        "uacpEmulation": "on",
         "auth": {
         "auth": {
             "google": "https://api.swopt.com/auth/google"
             "google": "https://api.swopt.com/auth/google"
         }
         }

+ 1 - 1
src/dependencies/angularlib

@@ -1 +1 @@
-Subproject commit 7b765b69bea4c46fae59f552aa6718e2a1ecb532
+Subproject commit a28bb0cc42619a8bef1a62301dfbb5b1a1d4e662

+ 2 - 0
src/dependencies/fis-vision/decorators/index.ts

@@ -0,0 +1,2 @@
+export { VisionAnalyzerDecorator, EngineMode } from './vision-analyzer.decorator';
+export { VisionHistoryDecorator, BatchGroup } from './vision-history.decorator';

+ 115 - 0
src/dependencies/fis-vision/decorators/vision-analyzer.decorator.ts

@@ -0,0 +1,115 @@
+import { Decorator } from 'fis-commons/decorator';
+import { Process } from 'fis-commons/process';
+import { Store } from '@ngxs/store';
+import { Observable } from 'rxjs';
+import { VisionState } from '../store/vision.state';
+import { SubmitBatchAnalysis } from '../store/vision.actions';
+import { InferenceFrame } from '../services/inference.service';
+
+export type EngineMode = 'local-onnx' | 'local-tflite' | 'remote';
+
+export class VisionAnalyzerDecorator extends Decorator {
+  readonly process: Process = new Process();
+
+  mode: EngineMode = 'remote';
+  inputMode: 'file' | 'camera' = 'file';
+  isDragOver = false;
+  isCameraActive = false;
+  batchFrames: InferenceFrame[] = [];
+  activeBatchId: string | null = null;
+
+  readonly loading$: Observable<boolean>;
+
+  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;
+
+  constructor(private store: Store) {
+    super();
+    this.loading$ = store.select(VisionState.loading);
+    store
+      .select(VisionState.batchFrames)
+      .pipe(this.untilDestroyed())
+      .subscribe(raw => {
+        const frames = raw as InferenceFrame[];
+        this.batchFrames = frames;
+        this.activeBatchId = frames.length > 0 ? (frames[0]?.batchId ?? null) : null;
+      });
+  }
+
+  switchInputMode(m: 'file' | 'camera'): void {
+    if (m === 'file') this.stopCamera();
+    this.inputMode = m;
+  }
+
+  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;
+  }
+
+  async startCamera(videoEl: HTMLVideoElement): Promise<void> {
+    try {
+      this.mediaStream = await navigator.mediaDevices.getUserMedia({
+        video: { facingMode: 'environment', width: 640, height: 640 },
+      });
+      this.isCameraActive = true;
+      if (videoEl) videoEl.srcObject = this.mediaStream;
+    } catch (err) {
+      console.error('[Analyzer] Camera access denied:', err);
+    }
+  }
+
+  stopCamera(videoEl?: HTMLVideoElement): void {
+    this.mediaStream?.getTracks().forEach(t => t.stop());
+    this.mediaStream = null;
+    this.isCameraActive = false;
+    if (videoEl) videoEl.srcObject = null;
+  }
+
+  captureWebcamFrame(videoEl: HTMLVideoElement): void {
+    if (!videoEl) return;
+    const offscreen = document.createElement('canvas');
+    offscreen.width = videoEl.videoWidth || 640;
+    offscreen.height = videoEl.videoHeight || 640;
+    offscreen.getContext('2d')!.drawImage(videoEl, 0, 0);
+    offscreen.toBlob(blob => {
+      if (!blob) return;
+      this.submit([new File([blob], `webcam-${Date.now()}.jpg`, { type: 'image/jpeg' })]);
+    }, 'image/jpeg');
+  }
+
+  resetScan(): void {
+    this.stopCamera();
+    this.activeBatchId = null;
+    this.batchFrames = [];
+  }
+
+  submit(files: File[]): void {
+    this.store.dispatch(new SubmitBatchAnalysis({ files, mode: this.mode }));
+  }
+}

+ 88 - 0
src/dependencies/fis-vision/decorators/vision-history.decorator.ts

@@ -0,0 +1,88 @@
+import { Decorator } from 'fis-commons/decorator';
+import { Process } from 'fis-commons/process';
+import { Store } from '@ngxs/store';
+import { combineLatest, Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { VisionState } from '../store/vision.state';
+import {
+  ClearAllHistory,
+  DeleteHistoryRecord,
+  LoadHistory,
+  ToggleBatchGroup,
+} from '../store/vision.actions';
+
+export interface BatchGroup {
+  batchId: string;
+  timestamp: string;
+  totalCount: number;
+  avgConfidencePct: number;
+  mode: string;
+  items: any[];
+  isExpanded: boolean;
+}
+
+export class VisionHistoryDecorator extends Decorator {
+  readonly process: Process = new Process();
+  readonly loading$: Observable<boolean>;
+  readonly groups$: Observable<BatchGroup[]>;
+
+  constructor(private store: Store) {
+    super();
+    this.loading$ = store.select(VisionState.loading);
+    this.groups$ = combineLatest([
+      store.select(VisionState.items),
+      store.select(VisionState.expandedBatchIds),
+    ]).pipe(map(([items, expandedIds]) => this.buildGroups(items, expandedIds)));
+    console.log(`groups$: ${JSON.stringify(this.groups$)}`)
+  }
+
+  /** Dispatch the initial history load — called by the component's ngOnInit. */
+  init(): void {
+    this.store.dispatch(new LoadHistory());
+  }
+
+  onToggle(batchId: string): void {
+    this.store.dispatch(new ToggleBatchGroup({ batchId }));
+  }
+
+  onDeleteBatch(group: BatchGroup, event: MouseEvent): void {
+    event.stopPropagation();
+    for (const item of group.items) {
+      this.store.dispatch(new DeleteHistoryRecord({ id: item.archive_id }));
+    }
+  }
+
+  onClearAll(): void {
+    this.store.dispatch(new ClearAllHistory());
+  }
+
+  private buildGroups(items: any[], expandedIds: string[]): BatchGroup[] {
+    if (!items || !Array.isArray(items)) return [];
+
+    const UNGROUPED = '__ungrouped__';
+    const groupMap = new Map<string, any[]>();
+    for (const item of items ?? []) {
+      const bid = item.batch_id ?? UNGROUPED;
+      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),
+      };
+    });
+  }
+}

+ 8 - 0
src/dependencies/fis-vision/fis-vision.module.ts

@@ -0,0 +1,8 @@
+import { NgModule } from '@angular/core';
+
+@NgModule({
+    imports: [],
+    exports: [],
+    providers: [],
+})
+export class FisVisionModule {}

+ 49 - 0
src/dependencies/fis-vision/index.ts

@@ -0,0 +1,49 @@
+export { FisVisionModule } from './fis-vision.module';
+
+// Domain types
+export {
+  InferenceService,
+  InferenceFrame,
+  DetectionResult,
+  HistoryRecord,
+  MPOB_CLASSES,
+  HEALTH_ALERT_CLASSES,
+} from './services/inference.service';
+export {
+  RemoteInferenceService,
+  EdgeResultPayload,
+  SaveExternalResultResponse,
+  ImageRecord,
+} from './services/remote-inference.service';
+
+// State
+export { VisionState, VisionStateModel } from './store/vision.state';
+export {
+  SubmitBatchAnalysis,
+  ToggleBatchGroup,
+  LoadGroupImages,
+  LoadHistory,
+  DeleteHistoryRecord,
+  ClearAllHistory,
+} from './store/vision.actions';
+
+// Chat / Intelligence Portal state
+export { ChatVisionState, ChatVisionStateModel } from './store/chat.state';
+export { AppendChatMessage, ResetChatSession } from './store/chat.actions';
+export {
+  ChatResponse,
+  ChatVisualData,
+  ChatVisualDataset,
+  SupportedChartType,
+  IntelligenceMessage,
+  TextMessage,
+  DataMessage,
+  buildIntelligenceMessage,
+  makeUserMessage,
+  makeWelcomeMessage,
+  toChartConfiguration,
+} from './store/chat.model';
+
+// Decorators
+export { VisionAnalyzerDecorator, EngineMode } from './decorators/vision-analyzer.decorator';
+export { VisionHistoryDecorator, BatchGroup } from './decorators/vision-history.decorator';

+ 10 - 0
src/dependencies/fis-vision/package.json

@@ -0,0 +1,10 @@
+{
+    "name": "fis-vision",
+    "version": "0.0.1",
+    "scripts": {},
+    "private": true,
+    "dependencies": {
+        "@angular/core": "*",
+        "rxjs": "~7.8.0"
+    }
+}

+ 226 - 0
src/dependencies/fis-vision/services/inference.service.ts

@@ -0,0 +1,226 @@
+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];
+  norm_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' | 'remote' | 'n8n';
+}
+
+/** Typed shape of a persisted archive record returned by History:getAll. */
+export interface HistoryRecord {
+  archive_id: string;
+  batch_id: string | null;
+  created_at: string;
+  mode: string;
+  total_count: number;
+  detections: DetectionResult[];
+  industrial_summary: Record<string, number>;
+  inference_ms: number;
+  processing_ms: number;
+  imageDataUrl?: string; // populated lazily via LoadGroupImages
+}
+
+// ── 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, mode: 'local-onnx' | 'local-tflite' = 'local-onnx'): 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,
+              mode,
+            }, [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 co-located under fis-vision/workers/; resolved via import.meta.url
+      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;
+        // 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 {
+      // 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',
+    };
+  }
+}

+ 188 - 0
src/dependencies/fis-vision/services/remote-inference.service.ts

@@ -0,0 +1,188 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, OnDestroy } from '@angular/core';
+import { map, Observable, Subject, take } from 'rxjs';
+import { DpService } from 'dp-ui/dp.service';
+import { FisAppMessage, MessageHeader, AppMessageType } from 'dp-ui/fisappmessage/apprequestmessagetype';
+import {
+  DetectionResult,
+  HistoryRecord,
+  InferenceFrame,
+  HEALTH_ALERT_CLASSES,
+  MPOB_CLASSES,
+} from './inference.service';
+
+interface PalmVisionConfig {
+  connection: {
+    uacp: string;
+    uacp_ws: string;
+    uacpEmulation: string;
+  };
+}
+
+export interface EdgeResultPayload {
+  frame: string;
+  filename?: string;
+  batchId?: string;
+  mode: string;
+  detections: DetectionResult[];
+  industrial_summary: Record<string, number>;
+  inference_ms: number;
+  processing_ms?: number;
+}
+
+export interface SaveExternalResultResponse {
+  archive_id: string;
+}
+
+export interface ImageRecord {
+  archive_id: string;
+  image_data: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class RemoteInferenceService implements OnDestroy {
+  private config: PalmVisionConfig | null = null;
+  private destroyed$ = new Subject<void>();
+
+  constructor(
+    private http: HttpClient,
+    private dpService: DpService,
+  ) {
+    this.http.get<PalmVisionConfig>('./config/config.json')
+      .pipe(take(1))
+      .subscribe({ next: cfg => (this.config = cfg) });
+  }
+
+  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<unknown>('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);
+    });
+  }
+
+  getHistory(): Observable<HistoryRecord[]> {
+    return this.send<unknown>('History', 'getAll', undefined).pipe(
+      map(body => this.normalizeHistoryResponse(body)),
+    );
+  }
+
+  deleteRecord(archiveId: string): Observable<{ deleted: boolean }> {
+    return this.send('History', 'delete', { archiveId });
+  }
+
+  clearHistory(): Observable<{ deleted: number }> {
+    return this.send('History', 'clearAll', undefined);
+  }
+
+  getImage(archiveId: string): Observable<ImageRecord> {
+    return this.send<ImageRecord>('PalmHistory', 'GetImage', { archiveId });
+  }
+
+  getBatchDetails(batchId: string): Observable<unknown> {
+    return this.send<unknown>('History', 'getBatchDetails', { batchId });
+  }
+
+  saveExternalResult(payload: EdgeResultPayload): Observable<SaveExternalResultResponse> {
+    return this.send<SaveExternalResultResponse>('PalmHistory', 'SaveExternalResult', payload);
+  }
+
+  ngOnDestroy(): void {
+    this.destroyed$.next();
+    this.destroyed$.complete();
+  }
+
+  /**
+   * 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();
+
+    const message: FisAppMessage = {
+      header: {
+        messageID,
+        serviceId,
+        messageName: operation,
+        messageType: AppMessageType.Command
+      } as unknown as MessageHeader,
+      data: payload,
+    };
+
+    return new Observable<T>(observer => {
+      this.dpService.stream(message).subscribe({
+        next: (res: any) => {
+          const body = typeof res === 'string' ? JSON.parse(res) : (res?.message ? (typeof res.message === 'string' ? JSON.parse(res.message) : 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(),
+      });
+    });
+  }
+
+  /**
+   * Resolves heterogeneous backend envelope shapes into a flat HistoryRecord array.
+   * Candidate keys are tried in priority order; the Object.values() heuristic is intentionally
+   * excluded because it produces non-deterministic results when the envelope has mixed keys.
+   */
+  private normalizeHistoryResponse(body: unknown): HistoryRecord[] {
+    if (Array.isArray(body)) return body as HistoryRecord[];
+
+    if (body && typeof body === 'object') {
+      const envelope = body as Record<string, unknown>;
+      for (const key of ['records', 'data', 'items', 'history', 'response'] as const) {
+        if (Array.isArray(envelope[key])) return envelope[key] as HistoryRecord[];
+      }
+
+      // Protobuf-spread shape: { 0: r, 1: r, ..., request: protoReq } — numeric keys only,
+      // discarding any non-numeric metadata keys injected by the transport layer.
+      const numericKeys = Object.keys(envelope).filter(k => /^\d+$/.test(k));
+      if (numericKeys.length > 0) {
+        return numericKeys.map(k => envelope[k]) as HistoryRecord[];
+      }
+    }
+
+    return [];
+  }
+
+  private mapAnalysisResponse(raw: unknown, batchId?: string): InferenceFrame {
+    const r = raw as Record<string, any>;
+    const detections: DetectionResult[] = (r?.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,
+      norm_box: d.norm_box,
+    }));
+
+    const industrial_summary: Record<string, number> =
+      r?.industrial_summary ?? r?.technical_evidence?.industrial_summary ?? {};
+
+    return {
+      frameId: r?.archive_id ?? crypto.randomUUID(),
+      batchId,
+      imageDataUrl: r?.image_data ?? r?.imageDataUrl ?? '',
+      detections,
+      inference_ms: r?.inference_ms ?? 0,
+      processing_ms: r?.processing_ms ?? 0,
+      total_count: detections.length,
+      industrial_summary,
+      source: 'remote',
+    };
+  }
+}

+ 10 - 0
src/dependencies/fis-vision/store/chat.actions.ts

@@ -0,0 +1,10 @@
+import { IntelligenceMessage } from './chat.model';
+
+export class AppendChatMessage {
+  static readonly type = '[Chat] AppendChatMessage';
+  constructor(public payload: { message: IntelligenceMessage }) {}
+}
+
+export class ResetChatSession {
+  static readonly type = '[Chat] ResetChatSession';
+}

+ 123 - 0
src/dependencies/fis-vision/store/chat.model.ts

@@ -0,0 +1,123 @@
+import { ChartConfiguration } from 'chart.js/auto';
+
+// ── n8n enforced output schema ──────────────────────────────────────────────
+// visual_data.type is restricted to the chart types registered on the
+// in-house <chart> component (angularlib/chart) that share this flat
+// labels[] + datasets[{label,data:number[]}] shape. 'bubble'/'scatter' are
+// intentionally excluded — they require {x,y}/{x,y,r} point objects instead.
+
+export type SupportedChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polarArea';
+
+export interface ChatVisualDataset {
+  label: string;
+  data: number[];
+}
+
+export interface ChatVisualData {
+  type: SupportedChartType;
+  title: string;
+  labels: string[];
+  datasets: ChatVisualDataset[];
+}
+
+export interface ChatResponse {
+  text: string;
+  has_visuals: boolean;
+  visual_data: ChatVisualData | null;
+}
+
+// ── Message contracts ───────────────────────────────────────────────────────
+
+export interface TextMessage {
+  type: 'text';
+  sender: 'user' | 'assistant';
+  content: string;
+  id: string;
+  timestamp: number;
+  durationMs?: number; // time taken by the intelligence layer to respond; assistant messages only
+}
+
+export interface DataMessage {
+  type: 'data';
+  sender: 'assistant';
+  content: string;
+  chartConfig: ChartConfiguration;
+  id: string;
+  timestamp: number;
+  durationMs?: number;
+}
+
+export type IntelligenceMessage = TextMessage | DataMessage;
+
+// ── visual_data → Chart.js config mapper ────────────────────────────────────
+
+const PALETTE = ['#4caf50', '#ff9800', '#ffeb3b', '#9c27b0', '#f44336', '#607d8b', '#3f51b5', '#00bcd4'];
+
+export function toChartConfiguration(visual: ChatVisualData): ChartConfiguration {
+  const isFilled = visual.type === 'pie' || visual.type === 'doughnut' || visual.type === 'polarArea';
+
+  return {
+    type: visual.type,
+    data: {
+      labels: visual.labels,
+      datasets: visual.datasets.map((ds, i) => ({
+        label: ds.label,
+        data: ds.data,
+        backgroundColor: isFilled
+          ? ds.data.map((_, j) => PALETTE[j % PALETTE.length])
+          : PALETTE[i % PALETTE.length],
+        borderColor: PALETTE[i % PALETTE.length],
+        borderWidth: 1,
+      })),
+    },
+    options: {
+      responsive: true,
+      maintainAspectRatio: false,
+      animation: false,
+      plugins: {
+        title: { display: !!visual.title, text: visual.title },
+        legend: { display: true, align: 'start' },
+      },
+    },
+  };
+}
+
+// ── Response → message builder ──────────────────────────────────────────────
+
+export function buildIntelligenceMessage(
+  res: ChatResponse,
+  sessionId: string,
+  durationMs?: number,
+): IntelligenceMessage {
+  const id = `${sessionId}-${Date.now()}`;
+  const timestamp = Date.now();
+
+  if (res.has_visuals && res.visual_data) {
+    return {
+      type: 'data',
+      sender: 'assistant',
+      content: res.text,
+      chartConfig: toChartConfiguration(res.visual_data),
+      id,
+      timestamp,
+      durationMs,
+    };
+  }
+
+  return { type: 'text', sender: 'assistant', content: res.text, id, timestamp, durationMs };
+}
+
+export function makeWelcomeMessage(): IntelligenceMessage {
+  return {
+    type: 'text',
+    id: 'system-welcome',
+    sender: 'assistant',
+    content:
+      'Welcome to the Industrial Intelligence Portal. Ask me about batch yield summaries, ripeness distributions, ABW trends, or anomaly flags from your production data.',
+    timestamp: Date.now(),
+  };
+}
+
+export function makeUserMessage(text: string): IntelligenceMessage {
+  return { type: 'text', id: crypto.randomUUID(), sender: 'user', content: text, timestamp: Date.now() };
+}

+ 45 - 0
src/dependencies/fis-vision/store/chat.state.ts

@@ -0,0 +1,45 @@
+import { Injectable } from '@angular/core';
+import { Action, Selector, State, StateContext } from '@ngxs/store';
+import { IntelligenceMessage, makeWelcomeMessage } from './chat.model';
+import { AppendChatMessage, ResetChatSession } from './chat.actions';
+
+export interface ChatVisionStateModel {
+  sessionId: string;
+  messages: IntelligenceMessage[];
+}
+
+const defaults: ChatVisionStateModel = {
+  sessionId: crypto.randomUUID(),
+  messages: [makeWelcomeMessage()],
+};
+
+@State<ChatVisionStateModel>({
+  name: 'chatVisionState',
+  defaults,
+})
+@Injectable()
+export class ChatVisionState {
+  @Selector()
+  static sessionId(state: ChatVisionStateModel): string {
+    return state.sessionId;
+  }
+
+  @Selector()
+  static messages(state: ChatVisionStateModel): IntelligenceMessage[] {
+    return state.messages;
+  }
+
+  @Action(AppendChatMessage)
+  appendChatMessage(ctx: StateContext<ChatVisionStateModel>, { payload }: AppendChatMessage): void {
+    const { messages } = ctx.getState();
+    ctx.patchState({ messages: [...messages, payload.message] });
+  }
+
+  @Action(ResetChatSession)
+  resetChatSession(ctx: StateContext<ChatVisionStateModel>): void {
+    ctx.patchState({
+      sessionId: crypto.randomUUID(),
+      messages: [makeWelcomeMessage()],
+    });
+  }
+}

+ 27 - 0
src/dependencies/fis-vision/store/vision.actions.ts

@@ -0,0 +1,27 @@
+export class SubmitBatchAnalysis {
+  static readonly type = '[Vision] SubmitBatchAnalysis';
+  constructor(public payload: { files: File[]; mode: 'local-onnx' | 'local-tflite' | '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';
+}

+ 245 - 0
src/dependencies/fis-vision/store/vision.state.ts

@@ -0,0 +1,245 @@
+import { Injectable } from '@angular/core';
+import { Action, Selector, State, StateContext, NgxsOnInit } from '@ngxs/store';
+import {
+  catchError,
+  EMPTY,
+  map,
+  merge,
+  Observable,
+  of,
+  switchMap,
+  tap,
+  timeout,
+  toArray,
+} from 'rxjs';
+import { InferenceFrame, InferenceService, HistoryRecord } from '../services/inference.service';
+import { ImageRecord, RemoteInferenceService } from '../services/remote-inference.service';
+import {
+  ClearAllHistory,
+  DeleteHistoryRecord,
+  LoadGroupImages,
+  LoadHistory,
+  SubmitBatchAnalysis,
+  ToggleBatchGroup,
+} from './vision.actions';
+
+export interface VisionStateModel {
+  items: HistoryRecord[];
+  loading: boolean;
+  expandedBatchIds: string[];
+  currentInference: InferenceFrame | null;
+  batchFrames: InferenceFrame[];
+  selectedFrameIndex: number | null;
+}
+
+const defaults: VisionStateModel = {
+  items: [],
+  loading: false,
+  expandedBatchIds: [],
+  currentInference: null,
+  batchFrames: [],
+  selectedFrameIndex: null,
+};
+
+@State<VisionStateModel>({
+  name: 'visionState',
+  defaults,
+})
+@Injectable()
+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): HistoryRecord[] {
+    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): InferenceFrame | null {
+    return state.currentInference;
+  }
+
+  @Selector()
+  static batchFrames(state: VisionStateModel): InferenceFrame[] {
+    return state.batchFrames;
+  }
+
+  @Action(SubmitBatchAnalysis)
+  submitBatchAnalysis(
+    ctx: StateContext<VisionStateModel>,
+    { payload }: SubmitBatchAnalysis,
+  ): Observable<InferenceFrame[]> {
+    ctx.patchState({ loading: true, currentInference: null });
+    const batchId = crypto.randomUUID();
+
+    /**
+     * Per-file stream builder.
+     * Each stream is individually error-bounded — a failure in any single file's
+     * processing pipeline emits EMPTY instead of propagating, ensuring the remaining
+     * files in the batch continue through merge() unaffected.
+     */
+    const perFileStream = (file: File): Observable<InferenceFrame> => {
+      let base: Observable<InferenceFrame>;
+
+      if (payload.mode === 'remote') {
+        base = this.remoteInferenceService.analyze(file, undefined, batchId);
+      } else {
+        base = this.inferenceService.analyze(file, batchId, payload.mode).pipe(
+          switchMap((localFrame: InferenceFrame) =>
+            this.remoteInferenceService.saveExternalResult({
+              frame: localFrame.imageDataUrl,
+              filename: file.name,
+              batchId,
+              mode: payload.mode,
+              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 })),
+              // Persistence failure: keep the in-memory frame, archive ID will be missing
+              catchError(() => of(localFrame)),
+            ),
+          ),
+        );
+      }
+
+      // Outer per-stream error boundary
+      return base.pipe(
+        catchError(err => {
+          console.warn('[VisionState] Frame isolated from batch — error:', err?.message ?? err);
+          return EMPTY;
+        }),
+      );
+    };
+
+    return merge(...payload.files.map(perFileStream)).pipe(
+      tap(frame => ctx.patchState({ currentInference: frame })),
+      toArray(),
+      tap(frames => {
+        ctx.patchState({
+          batchFrames: frames,
+          selectedFrameIndex: frames.length > 0 ? 0 : null,
+          loading: false,
+        });
+        ctx.dispatch(new LoadHistory());
+      }),
+    );
+  }
+
+  @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 targetBatchId = payload.batchId === '__ungrouped__' ? null : payload.batchId;
+    const pending = items.filter(
+      item => item.batch_id === targetBatchId && !item.imageDataUrl,
+    );
+
+    if (!pending.length) return of(void 0);
+
+    return merge(
+      ...pending.map(item =>
+        this.remoteInferenceService.getImage(item.archive_id).pipe(
+          tap((res: ImageRecord) => {
+            const current = ctx.getState().items;
+            ctx.patchState({
+              items: current.map(i =>
+                i.archive_id === res.archive_id ? { ...i, imageDataUrl: res.image_data } : i,
+              ),
+            });
+          }),
+          catchError(() => {
+            const current = ctx.getState().items;
+            ctx.patchState({
+              items: current.map(i =>
+                i.archive_id === item.archive_id ? { ...i, imageDataUrl: 'error' } : i,
+              ),
+            });
+            return of(void 0);
+          }),
+        ),
+      ),
+    ).pipe(map(() => void 0));
+  }
+
+  @Action(LoadHistory)
+  loadHistory(ctx: StateContext<VisionStateModel>): Observable<void> {
+    ctx.patchState({ loading: true });
+    return this.remoteInferenceService.getHistory().pipe(
+      timeout(30000),
+      tap(items => ctx.patchState({ items, loading: false })),
+      catchError(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),
+    );
+  }
+
+  @Action(DeleteHistoryRecord)
+  deleteHistoryRecord(
+    ctx: StateContext<VisionStateModel>,
+    { payload }: DeleteHistoryRecord,
+  ): Observable<void> {
+    const { items } = ctx.getState();
+    ctx.patchState({ items: items.filter(i => i.archive_id !== payload.id) });
+    return this.remoteInferenceService.deleteRecord(payload.id).pipe(
+      catchError(err => {
+        console.warn('[Vault State] Delete confirmation failed — record already removed from view:', err?.message || err);
+        return of(void 0);
+      }),
+      map(() => void 0),
+    );
+  }
+
+  @Action(ClearAllHistory)
+  clearAllHistory(ctx: StateContext<VisionStateModel>): Observable<void> {
+    return this.remoteInferenceService.clearHistory().pipe(
+      tap(() => ctx.patchState({ items: [], expandedBatchIds: [] })),
+      map(() => void 0),
+    );
+  }
+}

+ 242 - 0
src/dependencies/fis-vision/workers/inference.worker.ts

@@ -0,0 +1,242 @@
+/// <reference lib="webworker" />
+import * as ort from 'onnxruntime-web';
+
+ort.env.wasm.wasmPaths = '/assets/wasm/';
+ort.env.wasm.numThreads = 1;
+
+let onnxSession: ort.InferenceSession | null = null;
+let tfliteSession: any = null;
+
+async function initOnnxSession(): Promise<ort.InferenceSession> {
+  if (onnxSession) return onnxSession;
+  onnxSession = await ort.InferenceSession.create('/assets/models/onnx/best.onnx', {
+    executionProviders: ['wasm'],
+  });
+  return onnxSession;
+}
+
+async function initTfliteSession(): Promise<any> {
+  if (tfliteSession) return tfliteSession;
+
+  if (!(globalThis as any).Module) {
+    (globalThis as any).Module = {
+      locateFile: (path: string) => {
+        // Direct the internal loader to fetch the correct compiled _cc binaries explicitly
+        return `/assets/tflite-wasm/${path}`;
+      },
+    };
+  }
+
+  if (!(globalThis as any).tfweb) {
+    // Mask the path in a runtime variable to hide it from esbuild static analysis
+    const runtimeAssetPath = '/assets/tflite-wasm/tflite_web_api_client.js';
+    await import(/* @vite-ignore */ runtimeAssetPath as any);
+  }
+
+  const tfwebGlobal = (globalThis as any).tfweb;
+  if (!tfwebGlobal || !tfwebGlobal.TFLiteWebModelRunner) {
+    throw new Error('[TFLite] TFLiteWebModelRunner constructor not found on global scope');
+  }
+
+  if (tfwebGlobal.tflite_web_api && typeof tfwebGlobal.tflite_web_api.setWasmPath === 'function') {
+    tfwebGlobal.tflite_web_api.setWasmPath('/assets/tflite-wasm/');
+  }
+
+  tfliteSession = await tfwebGlobal.TFLiteWebModelRunner.create(
+    '/assets/models/tflite/best_float16.tflite',
+  );
+  return tfliteSession;
+}
+
+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') {
+      const runner = await initTfliteSession();
+
+      // Reconstruct typed view over the transferred ArrayBuffer — prevents undefined/NaN indexing
+      const rawPlanarData = new Float32Array(tensor);
+      const pixelCount = 640 * 640;
+
+      // Build a clean intermediate HWC buffer before touching WASM memory
+      const hwcData = new Float32Array(pixelCount * 3);
+      for (let i = 0; i < pixelCount; i++) {
+        hwcData[i * 3]     = rawPlanarData[i];                  // R
+        hwcData[i * 3 + 1] = rawPlanarData[pixelCount + i];     // G
+        hwcData[i * 3 + 2] = rawPlanarData[2 * pixelCount + i]; // B
+      }
+
+      const inputTensor = runner.getInputs()[0];
+      if (!inputTensor) {
+        throw new Error('[TFLite] Missing input tensor description definition');
+      }
+
+      // Resolve the true underlying memory buffer — data may be a function closure over WASM heap
+      const inputBuffer = typeof inputTensor.data === 'function'
+        ? (inputTensor.data as any)()
+        : inputTensor.data;
+
+      if (inputBuffer) {
+        if (typeof inputBuffer.set === 'function') {
+          inputBuffer.set(hwcData);
+        } else {
+          for (let i = 0; i < hwcData.length; i++) {
+            inputBuffer[i] = hwcData[i];
+          }
+        }
+      } else {
+        throw new Error('[TFLite] Failed to initialize a valid WebAssembly memory view for input data');
+      }
+
+      runner.infer();
+
+      const outputs = runner.getOutputs();
+      console.log('[TFLite Debug] Input Matrix Shape:', runner.getInputs()[0].shape);
+
+      const outputTensor = outputs[0];
+      let outputShape: number[] = [];
+
+      if (typeof outputTensor.shape === 'string') {
+        outputShape = (outputTensor.shape as any).split(',').map((v: string) => parseInt(v, 10));
+      } else if (Array.isArray(outputTensor.shape)) {
+        outputShape = outputTensor.shape;
+      } else if (outputTensor.shape && typeof (outputTensor.shape as any).toArray === 'function') {
+        outputShape = (outputTensor.shape as any).toArray();
+      } else {
+        outputShape = [1, 300, 6];
+      }
+      console.log('[TFLite Debug] Final Parsed Dimensions Array:', outputShape);
+
+      // Native engine accessor API — bypasses function-closure indirection over WASM heap
+      let rawValues: any = null;
+      if (typeof (runner as any).getOutputTensorData === 'function') {
+        rawValues = (runner as any).getOutputTensorData(0);
+      } else if (typeof (runner as any).getOutputData === 'function') {
+        rawValues = (runner as any).getOutputData(0);
+      } else {
+        rawValues = outputTensor && typeof outputTensor.data === 'function'
+          ? (outputTensor.data as any)()
+          : (outputTensor ? outputTensor.data : null);
+      }
+      console.log('[TFLite Debug] Real Array Check:', Array.isArray(rawValues), rawValues?.constructor?.name);
+      const debugSlice = rawValues
+        ? Array.from(rawValues.subarray ? rawValues.subarray(0, 12) : (rawValues.slice ? rawValues.slice(0, 12) : rawValues))
+        : 'null';
+      console.log('[TFLite Debug] Real Slice:', debugSlice);
+
+      // Model has internal NMS — output is [1, 300, 6] post-NMS candidates
+      // Bypass manual CONF_THRESHOLD: model already filtered; skip only zero-confidence empty slots
+      const numCandidates = outputShape[1] || 300;
+      const stride = outputShape[2] || 6;
+
+      for (let i = 0; i < numCandidates; i++) {
+        const offset = i * stride;
+        const confidence = rawValues[offset + 4];
+
+        // Relax the gateway filter to capture both live detections explicitly
+        if (isNaN(confidence) || confidence < 0.20) {
+          continue;
+        }
+
+        // Extract the true class index predicted by the WebAssembly model graph
+        const classIdx = Math.round(rawValues[offset + 5]);
+        const className = MPOB_CLASSES[classIdx] || 'Unknown';
+
+        // Map TFLite NMS format [ymin, xmin, ymax, xmax] to standard UI layout [x1, y1, x2, y2]
+        const ny1 = parseFloat(rawValues[offset + 0].toFixed(6)); // ymin
+        const nx1 = parseFloat(rawValues[offset + 1].toFixed(6)); // xmin
+        const ny2 = parseFloat(rawValues[offset + 2].toFixed(6)); // ymax
+        const nx2 = parseFloat(rawValues[offset + 3].toFixed(6)); // xmax
+
+        industrialSummary[className] = (industrialSummary[className] ?? 0) + 1;
+
+        console.log(`[TFLite Loop Success] Pushing candidate ${i}, Conf: ${confidence.toFixed(4)}, Class: ${classIdx}`);
+        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],
+        });
+      }
+    } 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(Math.max(0, 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(Math.max(0, performance.now() - processingStart).toFixed(2)),
+      total_count: 0,
+      industrial_summary: {},
+      source: 'wasm-local',
+      error: error.message,
+    });
+  }
+});

+ 3 - 0
tsconfig.json

@@ -41,6 +41,9 @@
       "fis/*": [
       "fis/*": [
         "src/dependencies/fis/*"
         "src/dependencies/fis/*"
       ],
       ],
+      "fis-vision/*": [
+        "src/dependencies/fis-vision/*"
+      ],
       "assets/*": [
       "assets/*": [
         "src/assets/*"
         "src/assets/*"
        ]
        ]