Dr-Swopt 2 週間 前
コミット
cc3175be4c
30 ファイル変更418 行追加2579 行削除
  1. 21 4
      CLAUDE.md
  2. 1 1
      src/app/app.routes.ts
  3. 1 1
      src/dependencies/angularlib
  4. 2 0
      src/dependencies/fis-vision/decorators/index.ts
  5. 115 0
      src/dependencies/fis-vision/decorators/vision-analyzer.decorator.ts
  6. 88 0
      src/dependencies/fis-vision/decorators/vision-history.decorator.ts
  7. 8 0
      src/dependencies/fis-vision/fis-vision.module.ts
  8. 32 0
      src/dependencies/fis-vision/index.ts
  9. 10 0
      src/dependencies/fis-vision/package.json
  10. 15 2
      src/dependencies/fis-vision/services/inference.service.ts
  11. 62 59
      src/dependencies/fis-vision/services/remote-inference.service.ts
  12. 0 0
      src/dependencies/fis-vision/store/vision.actions.ts
  13. 60 29
      src/dependencies/fis-vision/store/vision.state.ts
  14. 0 0
      src/dependencies/fis-vision/workers/inference.worker.ts
  15. 0 117
      src/src.palm.vision/analyzer/analyzer.component.html
  16. 0 255
      src/src.palm.vision/analyzer/analyzer.component.scss
  17. 0 158
      src/src.palm.vision/analyzer/analyzer.component.ts
  18. 0 229
      src/src.palm.vision/chatbot/chatbot.component.scss
  19. 0 221
      src/src.palm.vision/chatbot/chatbot.component.ts
  20. 0 168
      src/src.palm.vision/chatbot/components/abw-chart.component.ts
  21. 0 87
      src/src.palm.vision/chatbot/models/intelligence.model.ts
  22. 0 116
      src/src.palm.vision/history/batch-report/batch-report.component.html
  23. 0 273
      src/src.palm.vision/history/batch-report/batch-report.component.scss
  24. 0 165
      src/src.palm.vision/history/batch-report/batch-report.component.ts
  25. 0 214
      src/src.palm.vision/history/batch-report/frame-inspector-dialog.component.ts
  26. 0 83
      src/src.palm.vision/history/history.component.html
  27. 0 260
      src/src.palm.vision/history/history.component.scss
  28. 0 119
      src/src.palm.vision/history/history.component.ts
  29. 0 18
      src/src.palm.vision/palm-vision.module.ts
  30. 3 0
      tsconfig.json

+ 21 - 4
CLAUDE.md

@@ -48,6 +48,7 @@ Lazy-loaded at `/src.palm.vision`. Provides its own NGXS `VisionState` via `prov
 **`RemoteInferenceService`** — WebSocket bridge to NestJS backend:
 - `analyze(file, sourceLabel?, batchId?)` → `Observable<InferenceFrame>` — sends `PalmVision:analyze` via FIS envelope
 - `getHistory()` → `Observable<any[]>` — `History:getAll`
+- `getBatchDetails(batchId)` → `Observable<any>` — `History:getBatchDetails` (aggregated stats)
 - `deleteRecord(archiveId)` → `Observable<{ deleted: boolean }>` — `History:delete`
 - `clearHistory()` → `Observable<{ deleted: number }>` — `History:clearAll`
 - `getImage(archiveId)` → `Observable<{ archiveId, image_data }>` — `PalmHistory:GetImage`
@@ -57,7 +58,7 @@ All calls are wrapped in the FIS envelope and routed via `DpService.stream()`.
 
 Connection config loaded from `src/src.palm.vision/config/config.json`:
 ```json
-{ "connection": { "uacp": "http://localhost:3000", "uacp_ws": "ws://localhost:3000/socket.io", "uacpEmulation": "on" } }
+{ "connection": { "uacp": "https://localhost:3000", "uacp_ws": "wss://localhost:3000/socket.io", "uacpEmulation": "on" } }
 ```
 
 #### State (`src/src.palm.vision/store/`)
@@ -104,13 +105,29 @@ Connection config loaded from `src/src.palm.vision/config/config.json`:
 - `paintAllVisibleCanvases()` with `setTimeout(0)` deferral; `data-archive-id` attribute maps canvas elements to data items
 - Mode badge variants: `ONNX`, `TFLite`, `Server`
 
-**`ChatbotComponent`** — Thin wrapper around `angularlib/chat/chat.component` with title "Industrial Intelligence Portal".
+**`BatchReportComponent`** — Batch detail viewer (`history/batch-report/`):
+- Fetches batch details via `getBatchDetails(batchId)` on open
+- Displays scalar metrics: `frameCount`, `totalDetections`, `avgInferenceMs`, `avgProcessingMs`, `batchStart`, `batchEnd`
+- Shows `classTally` breakdown with MPOB color-coded percentage bars
+- Frame gallery grid — clicking a frame opens `FrameInspectorDialogComponent`
+
+**`FrameInspectorDialogComponent`** — Inline standalone dialog (`history/batch-report/frame-inspector-dialog.component.ts`):
+- Opened via `MatDialog.open()` with `maxWidth: '90vw'` and no forced width — centers to content on desktop
+- Layout: column flex — canvas centered on top, detection chips (`flex-wrap`) below
+- Canvas capped at 720px natural width; CSS `max-width: 100%` handles narrower viewports
+- Bounding boxes drawn using `norm_box` coords preferentially, falls back to `box` pixel coords scaled by the paint scale factor
+- Footer shows `inference_ms` and `processing_ms` timing
+
+**`ChatbotComponent`** — Thin wrapper around `angularlib/chat/chat.component`:
+- Signals for messages, loading state, and `sessionId`
+- Sends via `Chat:send` FIS envelope; clears session via `Chat:clear`
+- Session UUID tracked server-side in `client.data.sessionId`
 
 #### Web Worker (`src/src.palm.vision/workers/inference.worker.ts`)
 
 Receives `{ frameId, batchId, imageDataUrl, tensor: ArrayBuffer (transferred), processingStart, mode }`.
 
-- **ONNX path (`local-onnx`):** Loads ONNX session once from `/assets/models/onnx/best.onnx`; runs via `onnxruntime-web`; output shape `[1, N, 6]` (`x, y, x, y, conf, classIdx`); filters at confidence ≥ 0.25.
+- **ONNX path (`local-onnx`):** Loads ONNX session once from `/assets/models/onnx/best.onnx`; runs via `onnxruntime-web`; output shape `[1, N, 6]` (`x1, y1, x2, y2, conf, classIdx`); filters at confidence ≥ 0.25.
 - **TFLite path (`local-tflite`):** Dynamically imports `/assets/tflite-wasm/tflite_web_api_client.js`; initializes `TFLiteWebModelRunner` for `/assets/models/tflite/best_float16.tflite`; converts CHW→HWC before upload; model includes internal NMS; output `[1, 300, 6]`; filters at confidence ≥ 0.20; maps `[ymin, xmin, ymax, xmax]` → `[nx1, ny1, nx2, ny2]`.
 
 Returns `InferenceFrame`-shaped `postMessage` correlated by `frameId`.
@@ -175,4 +192,4 @@ Service worker registered immediately (`registerImmediately`) via `ngsw-config.j
 
 ### CORS / Connection
 
-Frontend connects to NestJS backend at `https://localhost:3000` (configurable via `src/config/config.json`). Backend CORS whitelist is hardcoded in `server-desktop/src/main.ts` and `server-android/src/main.ts` — add new device IPs there.
+Frontend connects to NestJS backend at `https://localhost:3000` (configurable via `src/src.palm.vision/config/config.json`). Backend CORS whitelist is hardcoded in `server-desktop/src/main.ts` — add new device IPs there.

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

@@ -8,5 +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) },
+    { path: 'src.palm.vision', loadChildren: () => import('angularlib/palm-vision/palm-vision.module').then(m => m.PalmVisionModule) },
 ];

+ 1 - 1
src/dependencies/angularlib

@@ -1 +1 @@
-Subproject commit 4738cc8b6bed714634a75434de6be1ccf7cd1240
+Subproject commit e939bd3c58aa5fb802f4b4d471578fbbcd1916c7

+ 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 {}

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

@@ -0,0 +1,32 @@
+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';
+
+// 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"
+    }
+}

+ 15 - 2
src/src.palm.vision/services/inference.service.ts → src/dependencies/fis-vision/services/inference.service.ts

@@ -36,6 +36,20 @@ export interface InferenceFrame {
   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;
@@ -153,8 +167,7 @@ export class InferenceService implements OnDestroy {
 
   private initWorker(): void {
     try {
-      // Worker script is expected at assets/workers/inference.worker.js
-      // Built separately; graceful degradation if absent
+      // 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' }

+ 62 - 59
src/src.palm.vision/services/remote-inference.service.ts → src/dependencies/fis-vision/services/remote-inference.service.ts

@@ -3,7 +3,13 @@ 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 { InferenceFrame, DetectionResult, MPOB_CLASSES, HEALTH_ALERT_CLASSES } from './inference.service';
+import {
+  DetectionResult,
+  HistoryRecord,
+  InferenceFrame,
+  HEALTH_ALERT_CLASSES,
+  MPOB_CLASSES,
+} from './inference.service';
 
 interface PalmVisionConfig {
   connection: {
@@ -17,12 +23,22 @@ 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;
@@ -42,7 +58,7 @@ export class RemoteInferenceService implements OnDestroy {
       const reader = new FileReader();
       reader.onload = () => {
         const frame = reader.result as string;
-        this.send<any>('PalmVision', 'analyze', { frame, sourceLabel, batchId })
+        this.send<unknown>('PalmVision', 'analyze', { frame, sourceLabel, batchId })
           .subscribe({
             next: raw => observer.next(this.mapAnalysisResponse(raw, batchId)),
             error: err => observer.error(err),
@@ -54,29 +70,9 @@ export class RemoteInferenceService implements OnDestroy {
     });
   }
 
-  getHistory(): Observable<any[]> {
-    return this.send<any>('History', 'getAll', undefined).pipe(
-      map(body => {
-        console.log('[Vault Transport Debug] Raw History Network Payload:', body);
-
-        if (Array.isArray(body)) {
-          return body;
-        }
-
-        if (body && typeof body === 'object') {
-          const extractedRecords = body.records || body.data || body.items || body.history;
-          if (Array.isArray(extractedRecords)) {
-            return extractedRecords;
-          }
-
-          const values = Object.values(body);
-          if (values.length > 0 && values.every(v => v !== null && typeof v === 'object')) {
-            return values as any[];
-          }
-        }
-
-        return [];
-      })
+  getHistory(): Observable<HistoryRecord[]> {
+    return this.send<unknown>('History', 'getAll', undefined).pipe(
+      map(body => this.normalizeHistoryResponse(body)),
     );
   }
 
@@ -88,25 +84,16 @@ export class RemoteInferenceService implements OnDestroy {
     return this.send('History', 'clearAll', undefined);
   }
 
-  getImage(archiveId: string): Observable<{ archiveId: string; image_data: string }> {
-    return this.send('PalmHistory', 'GetImage', { archiveId });
+  getImage(archiveId: string): Observable<ImageRecord> {
+    return this.send<ImageRecord>('PalmHistory', 'GetImage', { archiveId });
   }
 
-  getBatchDetails(batchId: string): Observable<any> {
-    return this.send<any>('History', 'getBatchDetails', { batchId });
+  getBatchDetails(batchId: string): Observable<unknown> {
+    return this.send<unknown>('History', 'getBatchDetails', { batchId });
   }
 
-  saveExternalResult(payload: EdgeResultPayload): Observable<any> {
-    const adjustedPayload: any = {
-      ...payload,
-      frame: (payload as any).frame || (payload as any).imageDataUrl || '',
-    };
-
-    if (adjustedPayload.imageDataUrl) {
-      delete adjustedPayload.imageDataUrl;
-    }
-
-    return this.send('PalmHistory', 'SaveExternalResult', adjustedPayload);
+  saveExternalResult(payload: EdgeResultPayload): Observable<SaveExternalResultResponse> {
+    return this.send<SaveExternalResultResponse>('PalmHistory', 'SaveExternalResult', payload);
   }
 
   ngOnDestroy(): void {
@@ -121,7 +108,6 @@ export class RemoteInferenceService implements OnDestroy {
   private send<T>(serviceId: string, operation: string, payload: unknown): Observable<T> {
     const messageID = crypto.randomUUID();
 
-    // Package parameters inside a fully compliant enterprise envelope structure
     const message: FisAppMessage = {
       header: {
         messageID,
@@ -133,17 +119,9 @@ export class RemoteInferenceService implements OnDestroy {
     };
 
     return new Observable<T>(observer => {
-      // Direct call routing through the shared enterprise stream engine
       this.dpService.stream(message).subscribe({
         next: (res: any) => {
-          // Gracefully intercept and isolate system finalization frames before parsing
-          if (res && res.complete === true) {
-            observer.complete();
-            return;
-          }
-
-          // Extract body mapping parameters directly from enterprise results packets
-          const body = typeof res === 'string' ? JSON.parse(res) : (res?.message ? JSON.parse(res.message) : res);
+          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 {
@@ -156,8 +134,34 @@ export class RemoteInferenceService implements OnDestroy {
     });
   }
 
-  private mapAnalysisResponse(raw: any, batchId?: string): InferenceFrame {
-    const detections: DetectionResult[] = (raw?.detections ?? []).map((d: any) => ({
+  /**
+   * 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,
@@ -166,17 +170,16 @@ export class RemoteInferenceService implements OnDestroy {
       norm_box: d.norm_box,
     }));
 
-    const industrial_summary: Record<string, number> = raw?.industrial_summary
-      ?? raw?.technical_evidence?.industrial_summary
-      ?? {};
+    const industrial_summary: Record<string, number> =
+      r?.industrial_summary ?? r?.technical_evidence?.industrial_summary ?? {};
 
     return {
-      frameId: raw?.archive_id ?? crypto.randomUUID(),
+      frameId: r?.archive_id ?? crypto.randomUUID(),
       batchId,
-      imageDataUrl: raw?.image_data ?? raw?.imageDataUrl ?? '',
+      imageDataUrl: r?.image_data ?? r?.imageDataUrl ?? '',
       detections,
-      inference_ms: raw?.inference_ms ?? 0,
-      processing_ms: raw?.processing_ms ?? 0,
+      inference_ms: r?.inference_ms ?? 0,
+      processing_ms: r?.processing_ms ?? 0,
       total_count: detections.length,
       industrial_summary,
       source: 'remote',

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


+ 60 - 29
src/src.palm.vision/store/vision.state.ts → src/dependencies/fis-vision/store/vision.state.ts

@@ -1,8 +1,19 @@
 import { Injectable } from '@angular/core';
 import { Action, Selector, State, StateContext, NgxsOnInit } from '@ngxs/store';
-import { Observable, catchError, map, merge, of, switchMap, tap, timeout, toArray } from 'rxjs';
-import { InferenceFrame, InferenceService } from '../services/inference.service';
-import { RemoteInferenceService } from '../services/remote-inference.service';
+import {
+  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,
@@ -13,11 +24,11 @@ import {
 } from './vision.actions';
 
 export interface VisionStateModel {
-  items: any[];
+  items: HistoryRecord[];
   loading: boolean;
   expandedBatchIds: string[];
-  currentInference: any | null;
-  batchFrames: any[];
+  currentInference: InferenceFrame | null;
+  batchFrames: InferenceFrame[];
   selectedFrameIndex: number | null;
 }
 
@@ -53,7 +64,7 @@ export class VisionState implements NgxsOnInit {
   }
 
   @Selector()
-  static items(state: VisionStateModel): any[] {
+  static items(state: VisionStateModel): HistoryRecord[] {
     return state.items;
   }
 
@@ -68,10 +79,15 @@ export class VisionState implements NgxsOnInit {
   }
 
   @Selector()
-  static currentInference(state: VisionStateModel): any | null {
+  static currentInference(state: VisionStateModel): InferenceFrame | null {
     return state.currentInference;
   }
 
+  @Selector()
+  static batchFrames(state: VisionStateModel): InferenceFrame[] {
+    return state.batchFrames;
+  }
+
   @Action(SubmitBatchAnalysis)
   submitBatchAnalysis(
     ctx: StateContext<VisionStateModel>,
@@ -80,32 +96,49 @@ export class VisionState implements NgxsOnInit {
     ctx.patchState({ loading: true, currentInference: null });
     const batchId = crypto.randomUUID();
 
-    const streams: Observable<InferenceFrame>[] = payload.files.map(file => {
+    /**
+     * 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') {
-        return this.remoteInferenceService.analyze(file, undefined, batchId);
+        base = this.remoteInferenceService.analyze(file, undefined, batchId);
       } else {
-        // Pass the explicit mode string ('local-onnx' or 'local-tflite') to the local service engine
-        return this.inferenceService.analyze(file, batchId, payload.mode).pipe(
+        base = this.inferenceService.analyze(file, batchId, payload.mode).pipe(
           switchMap((localFrame: InferenceFrame) =>
             this.remoteInferenceService.saveExternalResult({
               frame: localFrame.imageDataUrl,
               filename: file.name,
-              batchId: batchId ?? 'unbatched_edge_studio',
+              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, mode: payload.mode })),
-              catchError(() => of({ ...localFrame, mode: payload.mode })),
+              map(res => ({ ...localFrame, frameId: res.archive_id })),
+              // Persistence failure: keep the in-memory frame, archive ID will be missing
+              catchError(() => of(localFrame)),
             ),
           ),
         );
       }
-    });
 
-    return merge(...streams).pipe(
+      // 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 => {
@@ -139,30 +172,28 @@ export class VisionState implements NgxsOnInit {
     { payload }: LoadGroupImages,
   ): Observable<void> {
     const { items } = ctx.getState();
+    const targetBatchId = payload.batchId === '__ungrouped__' ? null : payload.batchId;
     const pending = items.filter(
-      (item: any) => item.batch_id === payload.batchId && !item.imageDataUrl,
+      item => item.batch_id === targetBatchId && !item.imageDataUrl,
     );
 
     if (!pending.length) return of(void 0);
 
     return merge(
-      ...pending.map((item: any) =>
+      ...pending.map(item =>
         this.remoteInferenceService.getImage(item.archive_id).pipe(
-          tap(res => {
+          tap((res: ImageRecord) => {
             const current = ctx.getState().items;
             ctx.patchState({
-              items: current.map((i: any) => {
-                // Resolve the identifier matching key from snake_case or camelCase network variants
-                const r = res as any;
-                const inboundId = r?.archive_id || r?.archiveId;
-                return i.archive_id === inboundId ? { ...i, imageDataUrl: (r?.image_data || r?.imageDataUrl) } : i;
-              }),
+              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: any) =>
+              items: current.map(i =>
                 i.archive_id === item.archive_id ? { ...i, imageDataUrl: 'error' } : i,
               ),
             });
@@ -177,7 +208,7 @@ export class VisionState implements NgxsOnInit {
   loadHistory(ctx: StateContext<VisionStateModel>): Observable<void> {
     ctx.patchState({ loading: true });
     return this.remoteInferenceService.getHistory().pipe(
-      timeout(30000), // Extended to 30s to shield slow edge database sweeps safely
+      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);
@@ -194,7 +225,7 @@ export class VisionState implements NgxsOnInit {
     { payload }: DeleteHistoryRecord,
   ): Observable<void> {
     const { items } = ctx.getState();
-    ctx.patchState({ items: items.filter((i: any) => i.archive_id !== payload.id) });
+    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);

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


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

@@ -1,117 +0,0 @@
-<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 selector row -->
-  <div class="engine-selector-row">
-    <mat-form-field appearance="outline" class="engine-field">
-      <mat-label>Inference Engine</mat-label>
-      <mat-select [(ngModel)]="mode" [disabled]="loading$ | async" panelClass="engine-select-dropdown-panel">
-        @for (opt of engineOptions; track opt.value) {
-        <mat-option [value]="opt.value">
-          <mat-icon>{{ opt.icon }}</mat-icon>
-          {{ opt.label }}
-        </mat-option>
-        }
-      </mat-select>
-    </mat-form-field>
-
-    <span class="engine-status-badge" [ngClass]="mode">
-      @switch (mode) {
-      @case ('local-onnx') { ONNX Runtime }
-      @case ('local-tflite') { TFLite Runtime }
-      @case ('remote') { Edge Server Active }
-      }
-    </span>
-  </div>
-
-  <!-- Input mode toggle -->
-  <div class="input-mode-toggle">
-    <button mat-flat-button [disabled]="loading$ | async" [class.active]="inputMode === 'file'"
-      (click)="switchInputMode('file')">
-      <mat-icon>upload_file</mat-icon>
-      File Drop
-    </button>
-    <button mat-flat-button [disabled]="loading$ | async" [class.active]="inputMode === 'camera'"
-      (click)="switchInputMode('camera')">
-      <mat-icon>videocam</mat-icon>
-      Live Camera
-    </button>
-  </div>
-
-  <!-- File drop zone -->
-  @if (inputMode === 'file') {
-  <div class="drop-zone" [class.drag-over]="isDragOver" (dragover)="onDragOver($event)" (dragleave)="onDragLeave()"
-    (drop)="onDrop($event)">
-    @if ((loading$ | async) && !isCameraActive) {
-    <div class="loading-overlay">
-      <mat-spinner diameter="52"></mat-spinner>
-      <span class="loading-label">Analyzing batch&hellip;</span>
-    </div>
-    } @else {
-    <mat-icon class="drop-icon">image_search</mat-icon>
-    <p class="drop-primary">Drag &amp; drop images here</p>
-    <p class="drop-secondary">or</p>
-    <button mat-raised-button color="primary" type="button" [disabled]="loading$ | async" (click)="fileInput.click()">
-      <mat-icon>upload_file</mat-icon>
-      Select Images
-    </button>
-    <input #fileInput type="file" accept="image/*" multiple hidden (change)="onFileInput($event)" />
-    }
-  </div>
-  }
-
-  <!-- Live camera section -->
-  @if (inputMode === 'camera') {
-  <div class="camera-section">
-    <video #videoEl autoplay playsinline class="camera-preview" [class.visible]="isCameraActive"></video>
-
-    @if ((loading$ | async) && isCameraActive) {
-    <div class="camera-loading">
-      <mat-spinner diameter="52"></mat-spinner>
-      <span class="loading-label">Analyzing frame&hellip;</span>
-    </div>
-    }
-
-    <div class="camera-controls">
-      @if (!isCameraActive) {
-      <button mat-raised-button color="primary" [disabled]="loading$ | async" (click)="startCamera()">
-        <mat-icon>videocam</mat-icon>
-        Start Camera
-      </button>
-      } @else {
-      <button mat-raised-button color="accent" (click)="captureWebcamFrame()" [disabled]="(loading$ | async) === true">
-        <mat-icon>camera</mat-icon>
-        Capture Frame
-      </button>
-      <button mat-stroked-button [disabled]="loading$ | async" (click)="stopCamera()">
-        <mat-icon>videocam_off</mat-icon>
-        Stop
-      </button>
-      }
-    </div>
-  </div>
-  }
-
-  <!-- Live batch report view -->
-  @if (activeBatchId) {
-    <div class="live-report-wrapper">
-      <div class="live-report-header">
-        <mat-icon class="report-icon">assessment</mat-icon>
-        <span class="report-label">Batch Complete</span>
-        <button mat-stroked-button class="new-scan-btn" (click)="resetScan()">
-          <mat-icon>refresh</mat-icon>
-          Clear Report
-        </button>
-      </div>
-      <app-batch-report [batchId]="activeBatchId" [startCollapsed]="true" [lightMode]="true"></app-batch-report>
-    </div>
-  }
-
-
-</div>

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

@@ -1,255 +0,0 @@
-.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;
-    }
-
-    &.n8n {
-      background: #fff8e1;
-      color: #e65100;
-      border: 1px solid #ffcc02;
-    }
-  }
-}
-
-// ── Input mode toggle ────────────────────────────────────────────────────────
-
-.input-mode-toggle {
-  display: flex;
-  gap: 0.5rem;
-
-  button {
-    border: 1px solid #c8e6c9;
-    border-radius: 8px;
-    color: #546e7a;
-
-    &.active {
-      background: #e8f5e9;
-      color: #2e7d32;
-      border-color: #a5d6a7;
-    }
-  }
-}
-
-// ── 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;
-    }
-  }
-}
-
-// ── Camera section ───────────────────────────────────────────────────────────
-
-.camera-section {
-  position: relative;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 0.75rem;
-  padding: 1rem;
-  border: 2px solid #a5d6a7;
-  border-radius: 12px;
-  background: #1a1a2e;
-  min-height: 200px;
-  overflow: hidden;
-
-  .camera-preview {
-    display: none;
-    width: 100%;
-    max-width: 640px;
-    border-radius: 8px;
-
-    &.visible {
-      display: block;
-    }
-  }
-
-  .camera-loading {
-    position: absolute;
-    inset: 0;
-    background: rgba(0, 0, 0, 0.55);
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-    gap: 0.75rem;
-    border-radius: 10px;
-
-    .loading-label {
-      font-size: 0.9rem;
-      color: #fff;
-    }
-  }
-
-  .camera-controls {
-    display: flex;
-    gap: 0.75rem;
-  }
-}
-
-// ── Live batch report wrapper ────────────────────────────────────────────────
-
-.live-report-wrapper {
-  border: 1px solid #a5d6a7;
-  border-radius: 12px;
-  overflow: hidden;
-  background: #ffffff;
-}
-
-.live-report-header {
-  display: flex;
-  align-items: center;
-  gap: 0.6rem;
-  padding: 0.65rem 1rem;
-  background: #f1f8e9;
-  border-bottom: 1px solid #c8e6c9;
-
-  .report-icon {
-    color: #558b2f;
-    font-size: 1.1rem;
-    width: 1.1rem;
-    height: 1.1rem;
-  }
-
-  .report-label {
-    font-size: 0.82rem;
-    font-weight: 600;
-    color: #37474f;
-    flex: 1;
-  }
-
-  .new-scan-btn {
-    color: #546e7a;
-    border-color: #a5d6a7;
-    font-size: 0.78rem;
-    height: 30px;
-    line-height: 30px;
-
-    mat-icon {
-      font-size: 0.9rem;
-      width: 0.9rem;
-      height: 0.9rem;
-    }
-
-    &:hover {
-      color: #2e7d32;
-      border-color: #2e7d32;
-    }
-  }
-}
-
-

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

@@ -1,158 +0,0 @@
-import {
-  Component,
-  ElementRef,
-  OnDestroy,
-  OnInit,
-  ViewChild,
-} from '@angular/core';
-import { AsyncPipe, NgClass } from '@angular/common';
-import { FormsModule } from '@angular/forms';
-import { Select, Store } from '@ngxs/store';
-import { Observable, Subscription } 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 { MatIconModule } from '@angular/material/icon';
-import { VisionState } from '../store/vision.state';
-import { SubmitBatchAnalysis } from '../store/vision.actions';
-import { InferenceFrame } from '../services/inference.service';
-import { BatchReportComponent } from '../history/batch-report/batch-report.component';
-
-type EngineMode = 'local-onnx' | 'local-tflite' | 'remote';
-
-@Component({
-  selector: 'app-analyzer',
-  standalone: true,
-  imports: [
-    AsyncPipe,
-    NgClass,
-    FormsModule,
-    MatSelectModule,
-    MatFormFieldModule,
-    MatButtonModule,
-    MatProgressSpinnerModule,
-    MatIconModule,
-    BatchReportComponent,
-  ],
-  templateUrl: './analyzer.component.html',
-  styleUrl: './analyzer.component.scss',
-})
-export class AnalyzerComponent implements OnInit, OnDestroy {
-  @Select(VisionState.loading) loading$!: Observable<boolean>;
-
-  @ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;
-
-  mode: EngineMode = 'remote';
-  inputMode: 'file' | 'camera' = 'file';
-  isDragOver = false;
-  isCameraActive = false;
-  batchFrames: InferenceFrame[] = [];
-  activeBatchId: string | null = null;
-
-  readonly engineOptions: { value: EngineMode; label: string; icon: string }[] = [
-    { value: 'local-onnx', label: 'Local — ONNX WASM', icon: 'memory' },
-    { value: 'local-tflite', label: 'Local — TFLite WASM', icon: 'layers' },
-    { value: 'remote', label: 'Remote — NestJS Server', icon: 'cloud' },
-  ];
-
-  private mediaStream: MediaStream | null = null;
-  private batchSub!: Subscription;
-
-  constructor(private store: Store) {}
-
-  ngOnInit(): void {
-    this.batchSub = (this.store.select((state: any) => state.visionState?.batchFrames ?? []) as Observable<InferenceFrame[]>)
-      .subscribe(frames => {
-        this.batchFrames = frames;
-        this.activeBatchId = frames.length > 0 ? (frames[0]?.batchId ?? null) : null;
-      });
-  }
-
-  ngOnDestroy(): void {
-    this.stopCamera();
-    this.batchSub?.unsubscribe();
-  }
-
-  // ── Input mode ────────────────────────────────────────────────────────────
-
-  switchInputMode(m: 'file' | 'camera'): void {
-    if (m === 'file') this.stopCamera();
-    this.inputMode = m;
-  }
-
-  // ── File drop ─────────────────────────────────────────────────────────────
-
-  onFileInput(event: Event): void {
-    const input = event.target as HTMLInputElement;
-    if (input.files?.length) {
-      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;
-  }
-
-  // ── Camera ────────────────────────────────────────────────────────────────
-
-  async startCamera(): Promise<void> {
-    try {
-      this.mediaStream = await navigator.mediaDevices.getUserMedia({
-        video: { facingMode: 'environment', width: 640, height: 640 },
-      });
-      this.isCameraActive = true;
-      const video = this.videoElRef?.nativeElement;
-      if (video) video.srcObject = this.mediaStream;
-    } catch (err) {
-      console.error('[Analyzer] Camera access denied:', err);
-    }
-  }
-
-  stopCamera(): void {
-    this.mediaStream?.getTracks().forEach(t => t.stop());
-    this.mediaStream = null;
-    this.isCameraActive = false;
-    if (this.videoElRef?.nativeElement) {
-      this.videoElRef.nativeElement.srcObject = null;
-    }
-  }
-
-  captureWebcamFrame(): void {
-    const video = this.videoElRef?.nativeElement;
-    if (!video) return;
-    const offscreen = document.createElement('canvas');
-    offscreen.width = video.videoWidth || 640;
-    offscreen.height = video.videoHeight || 640;
-    offscreen.getContext('2d')!.drawImage(video, 0, 0);
-    offscreen.toBlob(blob => {
-      if (!blob) return;
-      this.submit([new File([blob], `webcam-${Date.now()}.jpg`, { type: 'image/jpeg' })]);
-    }, 'image/jpeg');
-  }
-
-  resetScan(): void {
-    this.stopCamera();
-    this.activeBatchId = null;
-    this.batchFrames = [];
-  }
-
-  private submit(files: File[]): void {
-    this.store.dispatch(new SubmitBatchAnalysis({ files, mode: this.mode }));
-  }
-}

+ 0 - 229
src/src.palm.vision/chatbot/chatbot.component.scss

@@ -1,229 +0,0 @@
-/* ── Theme tokens — driven by ancestor Material M2 theme class ─────────────── */
-/* Default resolves to dark-theme (indigo dark baseline)                        */
-
-:host {
-  --chat-bg:         #121212;
-  --chat-header-bg:  #283593;   /* indigo 800 — dark-readable primary */
-  --chat-surface:    #1e1e1e;
-  --chat-border:     rgba(255, 255, 255, 0.08);
-  --chat-text:       rgba(255, 255, 255, 0.87);
-  --chat-text-sub:   rgba(255, 255, 255, 0.45);
-  --chat-user-bg:    rgba(63, 81, 181, 0.32);
-  --chat-user-fg:    #e8eaf6;
-  --chat-bot-bg:     rgba(255, 255, 255, 0.05);
-  --chat-bot-fg:     rgba(255, 255, 255, 0.72);
-  --chat-accent:     #7986cb;   /* indigo 300 */
-  --chat-header-fg:  #ffffff;
-  --chat-muted:      rgba(255, 255, 255, 0.28);
-
-  display: flex;
-  flex-direction: column;
-  height: 100%;
-  overflow: hidden;
-  background: var(--chat-bg);
-  color: var(--chat-text);
-}
-
-:host-context(.dark-theme) {
-  /* matches the default block above — explicit for clarity */
-  --chat-bg:         #121212;
-  --chat-header-bg:  #283593;
-  --chat-surface:    #1e1e1e;
-  --chat-border:     rgba(255, 255, 255, 0.08);
-  --chat-text:       rgba(255, 255, 255, 0.87);
-  --chat-text-sub:   rgba(255, 255, 255, 0.45);
-  --chat-user-bg:    rgba(63, 81, 181, 0.32);
-  --chat-user-fg:    #e8eaf6;
-  --chat-bot-bg:     rgba(255, 255, 255, 0.05);
-  --chat-bot-fg:     rgba(255, 255, 255, 0.72);
-  --chat-accent:     #7986cb;
-  --chat-header-fg:  #ffffff;
-  --chat-muted:      rgba(255, 255, 255, 0.28);
-}
-
-:host-context(.dark-theme-pink) {
-  --chat-bg:         #120e14;
-  --chat-header-bg:  #880e4f;   /* pink 900 */
-  --chat-surface:    #1e1520;
-  --chat-border:     rgba(255, 255, 255, 0.08);
-  --chat-text:       rgba(255, 255, 255, 0.87);
-  --chat-text-sub:   rgba(255, 255, 255, 0.45);
-  --chat-user-bg:    rgba(233, 30, 99, 0.28);
-  --chat-user-fg:    #fce4ec;
-  --chat-bot-bg:     rgba(255, 255, 255, 0.05);
-  --chat-bot-fg:     rgba(255, 255, 255, 0.72);
-  --chat-accent:     #f48fb1;   /* pink 200 */
-  --chat-header-fg:  #ffffff;
-  --chat-muted:      rgba(255, 255, 255, 0.28);
-}
-
-:host-context(.theme) {
-  --chat-bg:         #f5f5f7;
-  --chat-header-bg:  #3f51b5;   /* indigo 500 — matches toolbar */
-  --chat-surface:    #ffffff;
-  --chat-border:     rgba(0, 0, 0, 0.09);
-  --chat-text:       rgba(0, 0, 0, 0.87);
-  --chat-text-sub:   rgba(0, 0, 0, 0.54);
-  --chat-user-bg:    #3f51b5;
-  --chat-user-fg:    #ffffff;
-  --chat-bot-bg:     #ffffff;
-  --chat-bot-fg:     rgba(0, 0, 0, 0.75);
-  --chat-accent:     #3f51b5;
-  --chat-header-fg:  #ffffff;
-  --chat-muted:      rgba(0, 0, 0, 0.36);
-}
-
-:host-context(.theme-pink) {
-  --chat-bg:         #fdf0f4;
-  --chat-header-bg:  #c2185b;   /* pink 700 — matches toolbar */
-  --chat-surface:    #ffffff;
-  --chat-border:     rgba(0, 0, 0, 0.09);
-  --chat-text:       rgba(0, 0, 0, 0.87);
-  --chat-text-sub:   rgba(0, 0, 0, 0.54);
-  --chat-user-bg:    #c2185b;
-  --chat-user-fg:    #ffffff;
-  --chat-bot-bg:     #ffffff;
-  --chat-bot-fg:     rgba(0, 0, 0, 0.75);
-  --chat-accent:     #c2185b;
-  --chat-header-fg:  #ffffff;
-  --chat-muted:      rgba(0, 0, 0, 0.36);
-}
-
-/* ── Shell ─────────────────────────────────────────────────────────────────── */
-
-.chat-shell {
-  display: flex;
-  flex-direction: column;
-  height: 100%;
-  overflow: hidden;
-}
-
-/* ── Header ────────────────────────────────────────────────────────────────── */
-
-.chat-header {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  padding: 10px 14px;
-  background: var(--chat-header-bg);
-  color: var(--chat-header-fg);
-  border-bottom: 1px solid var(--chat-border);
-  flex-shrink: 0;
-}
-
-.header-icon {
-  font-size: 20px;
-  height: 20px;
-  width: 20px;
-  opacity: 0.88;
-}
-
-.header-title {
-  font-size: 13px;
-  font-weight: 600;
-  letter-spacing: 0.4px;
-  flex: 1;
-}
-
-.session-tag {
-  font-size: 10px;
-  font-family: 'Courier New', monospace;
-  opacity: 0.55;
-  letter-spacing: 0.3px;
-  user-select: none;
-}
-
-.reset-btn {
-  color: var(--chat-header-fg) !important;
-  opacity: 0.75;
-
-  &:hover { opacity: 1; }
-}
-
-/* ── Feed ──────────────────────────────────────────────────────────────────── */
-
-.chat-feed {
-  flex: 1;
-  min-height: 0;
-  overflow-y: auto;
-  padding: 20px max(16px, calc((100% - 760px) / 2)) 12px;
-  display: flex;
-  flex-direction: column;
-  gap: 14px;
-  scrollbar-width: thin;
-  scrollbar-color: var(--chat-border) transparent;
-}
-
-/* ── Message bubbles ───────────────────────────────────────────────────────── */
-
-.chat-msg {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-  align-items: flex-start;
-
-  &.chat-msg--user { align-items: flex-end; }
-}
-
-.chat-msg__label {
-  font-size: 9.5px;
-  font-weight: 600;
-  text-transform: uppercase;
-  letter-spacing: 0.7px;
-  color: var(--chat-muted);
-}
-
-.chat-msg__bubble {
-  max-width: min(88%, 600px);
-  padding: 10px 14px;
-  border-radius: 14px;
-  font-size: 13.5px;
-  line-height: 1.65;
-  white-space: pre-wrap;
-  word-break: break-word;
-
-  .chat-msg--user & {
-    background: var(--chat-user-bg);
-    color: var(--chat-user-fg);
-    border-bottom-right-radius: 3px;
-  }
-
-  .chat-msg:not(.chat-msg--user) & {
-    background: var(--chat-bot-bg);
-    color: var(--chat-bot-fg);
-    border: 1px solid var(--chat-border);
-    border-bottom-left-radius: 3px;
-  }
-}
-
-.chat-thinking {
-  display: flex;
-  align-items: center;
-  gap: 9px;
-  font-size: 12px;
-  color: var(--chat-text-sub);
-  padding: 2px 0;
-}
-
-/* ── Composer ──────────────────────────────────────────────────────────────── */
-
-.chat-composer {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  padding: 8px max(10px, calc((100% - 760px) / 2));
-  border-top: 1px solid var(--chat-border);
-  background: var(--chat-surface);
-  flex-shrink: 0;
-}
-
-.chat-input {
-  flex: 1;
-}
-
-.send-btn {
-  color: var(--chat-accent) !important;
-  flex-shrink: 0;
-
-  &[disabled] { color: var(--chat-muted) !important; }
-}

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

@@ -1,221 +0,0 @@
-import {
-  AfterViewChecked,
-  Component,
-  ElementRef,
-  inject,
-  OnDestroy,
-  OnInit,
-  signal,
-  ViewChild,
-} from '@angular/core';
-import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
-import { Subject, takeUntil } from 'rxjs';
-import { MatButtonModule } from '@angular/material/button';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatIconModule } from '@angular/material/icon';
-import { MatInputModule } from '@angular/material/input';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatTooltipModule } from '@angular/material/tooltip';
-import { DpService } from 'dp-ui/dp.service';
-import { FisAppMessage, MessageHeader, AppMessageType } from 'dp-ui/fisappmessage/apprequestmessagetype';
-
-interface ChatMessage {
-  id: string;
-  sender: 'user' | 'assistant';
-  content: string;
-  timestamp: number;
-}
-
-function makeWelcome(): ChatMessage {
-  return {
-    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(),
-  };
-}
-
-@Component({
-  selector: 'app-chatbot',
-  standalone: true,
-  imports: [
-    ReactiveFormsModule,
-    MatButtonModule,
-    MatFormFieldModule,
-    MatIconModule,
-    MatInputModule,
-    MatProgressSpinnerModule,
-    MatTooltipModule,
-  ],
-  styleUrl: './chatbot.component.scss',
-  template: `
-    <div class="chat-shell">
-
-      <!-- Header -->
-      <div class="chat-header">
-        <mat-icon class="header-icon">psychology</mat-icon>
-        <span class="header-title">Intelligence Portal</span>
-        <span class="session-tag" [title]="'Session: ' + sessionId()">
-          SID·{{ sessionId().slice(-6) }}
-        </span>
-        <button mat-icon-button
-                class="reset-btn"
-                type="button"
-                (click)="resetSession()"
-                matTooltip="Reset session context"
-                [disabled]="loading()">
-          <mat-icon>refresh</mat-icon>
-        </button>
-      </div>
-
-      <!-- Message feed -->
-      <div class="chat-feed" #messagesEl>
-        @for (msg of messages(); track msg.id) {
-          <div class="chat-msg" [class.chat-msg--user]="msg.sender === 'user'">
-            <span class="chat-msg__label">
-              {{ msg.sender === 'user' ? 'You' : 'Intelligence' }}
-            </span>
-            <div class="chat-msg__bubble">{{ msg.content }}</div>
-          </div>
-        }
-        @if (loading()) {
-          <div class="chat-thinking">
-            <mat-spinner diameter="14"></mat-spinner>
-            <span>Processing…</span>
-          </div>
-        }
-      </div>
-
-      <!-- Composer -->
-      <form class="chat-composer" (submit)="send($event)">
-        <mat-form-field appearance="outline" subscriptSizing="dynamic" class="chat-input">
-          <mat-label>Ask the intelligence layer…</mat-label>
-          <input matInput [formControl]="inputControl" autocomplete="off" />
-        </mat-form-field>
-        <button mat-icon-button
-                type="submit"
-                class="send-btn"
-                [disabled]="loading() || inputControl.invalid">
-          <mat-icon>send</mat-icon>
-        </button>
-      </form>
-
-    </div>
-  `,
-})
-export class ChatbotComponent implements OnInit, AfterViewChecked, OnDestroy {
-  private readonly dpService = inject(DpService);
-
-  @ViewChild('messagesEl') messagesEl!: ElementRef<HTMLDivElement>;
-
-  inputControl = new FormControl('', {
-    nonNullable: true,
-    validators: [Validators.required, Validators.minLength(1)],
-  });
-
-  messages = signal<ChatMessage[]>([]);
-  loading = signal(false);
-  sessionId = signal(crypto.randomUUID());
-
-  private readonly destroy$ = new Subject<void>();
-  private pendingScroll = false;
-
-  ngOnInit(): void {
-    this.messages.set([makeWelcome()]);
-  }
-
-  ngAfterViewChecked(): void {
-    if (this.pendingScroll) {
-      const el = this.messagesEl?.nativeElement;
-      if (el) el.scrollTop = el.scrollHeight;
-      this.pendingScroll = false;
-    }
-  }
-
-  ngOnDestroy(): void {
-    this.destroy$.next();
-    this.destroy$.complete();
-  }
-
-  resetSession(): void {
-    this.fisStream('Chat', 'clear', {}).pipe(takeUntil(this.destroy$)).subscribe();
-    this.sessionId.set(crypto.randomUUID());
-    this.messages.set([makeWelcome()]);
-    this.inputControl.reset();
-  }
-
-  send(event: Event): void {
-    event.preventDefault();
-    const text = this.inputControl.value.trim();
-    if (!text || this.loading()) return;
-
-    this.inputControl.reset();
-    this.messages.update(m => [
-      ...m,
-      {
-        id: crypto.randomUUID(),
-        sender: 'user',
-        content: text,
-        timestamp: Date.now(),
-      },
-    ]);
-    this.loading.set(true);
-    this.pendingScroll = true;
-
-    this.fisStream('Chat', 'send', { message: text, sessionId: this.sessionId() })
-      .pipe(takeUntil(this.destroy$))
-      .subscribe({
-        next: (res: any) => {
-          const content: string =
-            typeof res === 'string'
-              ? res
-              : Array.isArray(res)
-              ? (res[0]?.output ?? res[0]?.text ?? res[0]?.message ?? JSON.stringify(res))
-              : (res?.output ?? res?.text ?? res?.message ?? JSON.stringify(res));
-
-          this.messages.update(m => [
-            ...m,
-            {
-              id: crypto.randomUUID(),
-              sender: 'assistant',
-              content,
-              timestamp: Date.now(),
-            },
-          ]);
-          this.loading.set(false);
-          this.pendingScroll = true;
-        },
-        error: () => this.appendError(),
-      });
-  }
-
-  private fisStream(serviceId: string, operation: string, payload: unknown) {
-    const messageID = crypto.randomUUID();
-    const message: FisAppMessage = {
-      header: {
-        messageID,
-        serviceId,
-        messageName: operation,
-        messageType: AppMessageType.Command,
-      } as unknown as MessageHeader,
-      data: payload,
-    };
-    return this.dpService.stream(message);
-  }
-
-  private appendError(): void {
-    this.messages.update(m => [
-      ...m,
-      {
-        id: crypto.randomUUID(),
-        sender: 'assistant',
-        content:
-          'Unable to reach the intelligence layer. Verify the WebSocket connection is active and the backend is running.',
-        timestamp: Date.now(),
-      },
-    ]);
-    this.loading.set(false);
-    this.pendingScroll = true;
-  }
-}

+ 0 - 168
src/src.palm.vision/chatbot/components/abw-chart.component.ts

@@ -1,168 +0,0 @@
-import {
-  AfterViewInit,
-  Component,
-  effect,
-  ElementRef,
-  input,
-  ViewChild,
-} from '@angular/core';
-import { AbwChartPayload } from '../models/intelligence.model';
-
-@Component({
-  selector: 'app-abw-chart',
-  standalone: true,
-  template: `
-    <div class="abw-host">
-      <h3 class="abw-title">{{ payload()?.title || 'Average Bunch Weight (ABW) Curve' }}</h3>
-      <canvas #chartCanvas class="abw-canvas"></canvas>
-      <div class="abw-unit">{{ payload()?.unit }}</div>
-    </div>
-  `,
-  styles: [`
-    .abw-host {
-      display: flex;
-      flex-direction: column;
-      height: 100%;
-      padding: 20px 24px 16px;
-      box-sizing: border-box;
-    }
-    .abw-title {
-      margin: 0 0 12px;
-      font-size: 13px;
-      font-weight: 600;
-      color: #90caf9;
-      letter-spacing: 0.5px;
-      flex-shrink: 0;
-    }
-    .abw-canvas {
-      flex: 1;
-      width: 100%;
-      min-height: 0;
-      display: block;
-    }
-    .abw-unit {
-      text-align: right;
-      font-size: 11px;
-      color: #546e7a;
-      margin-top: 6px;
-      flex-shrink: 0;
-    }
-  `],
-})
-export class AbwChartComponent implements AfterViewInit {
-  payload = input<AbwChartPayload | null>(null);
-
-  @ViewChild('chartCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
-
-  private viewReady = false;
-
-  constructor() {
-    effect(() => {
-      const p = this.payload();
-      if (this.viewReady && p) this.scheduleRender(p);
-    });
-  }
-
-  ngAfterViewInit(): void {
-    this.viewReady = true;
-    const p = this.payload();
-    if (p) this.scheduleRender(p);
-  }
-
-  // setTimeout(0) defers until after Angular layout pass — matches AnalyzerComponent pattern.
-  private scheduleRender(p: AbwChartPayload): void {
-    setTimeout(() => this.render(p), 0);
-  }
-
-  private render(p: AbwChartPayload): void {
-    const canvas = this.canvasRef?.nativeElement;
-    if (!canvas) return;
-
-    const rect = canvas.getBoundingClientRect();
-    if (rect.width === 0 || rect.height === 0) return;
-
-    const dpr = window.devicePixelRatio || 1;
-    canvas.width = rect.width * dpr;
-    canvas.height = rect.height * dpr;
-
-    const ctx = canvas.getContext('2d')!;
-    ctx.scale(dpr, dpr);
-
-    const W = rect.width;
-    const H = rect.height;
-    const PAD = { top: 20, right: 20, bottom: 44, left: 56 };
-    const cW = W - PAD.left - PAD.right;
-    const cH = H - PAD.top - PAD.bottom;
-
-    ctx.clearRect(0, 0, W, H);
-
-    const series = p.series;
-    if (!series.length) return;
-
-    const values = series.map(d => d.value);
-    const minVal = Math.min(...values);
-    const maxVal = Math.max(...values);
-    const range = maxVal - minVal || 1;
-
-    const toX = (i: number) => PAD.left + (i / Math.max(series.length - 1, 1)) * cW;
-    const toY = (v: number) => PAD.top + cH - ((v - minVal) / range) * cH;
-
-    // Horizontal grid lines + y-axis labels
-    const gridLines = 4;
-    for (let g = 0; g <= gridLines; g++) {
-      const y = PAD.top + (cH / gridLines) * g;
-      ctx.strokeStyle = 'rgba(255,255,255,0.06)';
-      ctx.lineWidth = 1;
-      ctx.beginPath();
-      ctx.moveTo(PAD.left, y);
-      ctx.lineTo(PAD.left + cW, y);
-      ctx.stroke();
-
-      const labelVal = maxVal - (range / gridLines) * g;
-      ctx.fillStyle = '#546e7a';
-      ctx.font = '10px sans-serif';
-      ctx.textAlign = 'right';
-      ctx.fillText(labelVal.toFixed(1), PAD.left - 8, y + 4);
-    }
-
-    // Area fill beneath the line
-    ctx.beginPath();
-    ctx.moveTo(toX(0), toY(values[0]));
-    for (let i = 1; i < series.length; i++) ctx.lineTo(toX(i), toY(values[i]));
-    ctx.lineTo(toX(series.length - 1), PAD.top + cH);
-    ctx.lineTo(toX(0), PAD.top + cH);
-    ctx.closePath();
-    const grad = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + cH);
-    grad.addColorStop(0, 'rgba(76,175,80,0.30)');
-    grad.addColorStop(1, 'rgba(76,175,80,0.02)');
-    ctx.fillStyle = grad;
-    ctx.fill();
-
-    // Line stroke
-    ctx.beginPath();
-    ctx.strokeStyle = '#4caf50';
-    ctx.lineWidth = 2;
-    ctx.lineJoin = 'round';
-    ctx.moveTo(toX(0), toY(values[0]));
-    for (let i = 1; i < series.length; i++) ctx.lineTo(toX(i), toY(values[i]));
-    ctx.stroke();
-
-    // Data point dots
-    for (let i = 0; i < series.length; i++) {
-      ctx.beginPath();
-      ctx.arc(toX(i), toY(values[i]), 3.5, 0, Math.PI * 2);
-      ctx.fillStyle = '#4caf50';
-      ctx.fill();
-    }
-
-    // x-axis labels (auto-thin when many points)
-    ctx.fillStyle = '#546e7a';
-    ctx.font = '10px sans-serif';
-    ctx.textAlign = 'center';
-    const maxLabels = Math.max(2, Math.floor(cW / 56));
-    const step = Math.ceil(series.length / maxLabels);
-    for (let i = 0; i < series.length; i += step) {
-      ctx.fillText(series[i].label, toX(i), H - PAD.bottom + 18);
-    }
-  }
-}

+ 0 - 87
src/src.palm.vision/chatbot/models/intelligence.model.ts

@@ -1,87 +0,0 @@
-export type MpobClass = 'Ripe' | 'Unripe' | 'Underripe' | 'Overripe' | 'Abnormal' | 'Empty_Bunch';
-
-// ── Visualization payload contracts ────────────────────────────────────────
-
-export interface AbwDataPoint {
-  label: string;
-  value: number;
-}
-
-export interface AbwChartPayload {
-  chartType: 'abw-curve';
-  series: AbwDataPoint[];
-  unit: string;
-  title?: string;
-}
-
-// Stub contracts — not yet rendered; adding @case in chatbot.component.ts activates each.
-export interface HeatmapPayload {
-  chartType: 'heatmap';
-  rows: string[];
-  cols: string[];
-  matrix: number[][];
-  unit?: string;
-}
-
-export interface VelocityPayload {
-  chartType: 'velocity-chart';
-  series: { label: string; value: number; delta: number }[];
-  unit: string;
-}
-
-export interface ClusterPayload {
-  chartType: 'cluster-map';
-  points: { x: number; y: number; class: MpobClass }[];
-}
-
-export type VisualizationPayload =
-  | AbwChartPayload
-  | HeatmapPayload
-  | VelocityPayload
-  | ClusterPayload;
-
-// ── Message contracts ───────────────────────────────────────────────────────
-
-export interface TextMessage {
-  type: 'text';
-  sender: 'user' | 'assistant';
-  content: string;
-  id: string;
-  timestamp: number;
-}
-
-export interface DataMessage {
-  type: 'data';
-  sender: 'assistant';
-  content: string;
-  payload: VisualizationPayload;
-  id: string;
-  timestamp: number;
-}
-
-export type IntelligenceMessage = TextMessage | DataMessage;
-
-// ── Response parser ─────────────────────────────────────────────────────────
-// Expects the LLM/n8n to embed structured payloads inside ```json fences.
-// Strips the fence from content so the feed only shows narrative text.
-
-export function parseIntelligenceResponse(
-  raw: string,
-  sessionId: string,
-): IntelligenceMessage {
-  const id = `${sessionId}-${Date.now()}`;
-  const timestamp = Date.now();
-
-  const fenceMatch = raw.match(/```json\s*([\s\S]*?)\s*```/);
-  if (fenceMatch) {
-    try {
-      const payload = JSON.parse(fenceMatch[1]) as VisualizationPayload;
-      if (payload?.chartType) {
-        const content = raw.replace(/```json[\s\S]*?```/, '').trim();
-        return { type: 'data', sender: 'assistant', content, payload, id, timestamp };
-      }
-    } catch { /* fall through to plain text */ }
-  }
-
-  return { type: 'text', sender: 'assistant', content: raw, id, timestamp };
-}

+ 0 - 116
src/src.palm.vision/history/batch-report/batch-report.component.html

@@ -1,116 +0,0 @@
-<div class="batch-report" [class.light]="lightMode">
-
-  @if (loading) {
-    <div class="report-loading">
-      <mat-spinner diameter="40"></mat-spinner>
-      <span>Loading batch details&hellip;</span>
-    </div>
-  }
-
-  @if (error) {
-    <div class="report-error">
-      <mat-icon>error_outline</mat-icon>
-      <span>{{ error }}</span>
-    </div>
-  }
-
-  @if (data && !loading) {
-
-    <!-- Row 1: KPI Scalar Metrics -->
-    <div class="kpi-row">
-      <div class="kpi-card">
-        <span class="kpi-value">{{ data.frameCount }}</span>
-        <span class="kpi-label">Frames</span>
-      </div>
-      <div class="kpi-card">
-        <span class="kpi-value">{{ data.totalDetections }}</span>
-        <span class="kpi-label">Detections</span>
-      </div>
-      <div class="kpi-card">
-        <span class="kpi-value">{{ data.avgInferenceMs | number:'1.0-0' }}&thinsp;ms</span>
-        <span class="kpi-label">Avg Inference</span>
-      </div>
-      <div class="kpi-card">
-        <span class="kpi-value">{{ data.avgProcessingMs | number:'1.0-0' }}&thinsp;ms</span>
-        <span class="kpi-label">Avg Processing</span>
-      </div>
-      <div class="kpi-card">
-        <span class="kpi-value">{{ data.batchStart ? (data.batchStart | date:'HH:mm:ss') : '—' }}</span>
-        <span class="kpi-label">Start</span>
-      </div>
-      <div class="kpi-card">
-        <span class="kpi-value">{{ data.batchEnd ? (data.batchEnd | date:'HH:mm:ss') : '—' }}</span>
-        <span class="kpi-label">End</span>
-      </div>
-    </div>
-
-    <!-- Row 2: Cumulative Quality Distribution Bar -->
-    @if (totalInTally() > 0) {
-      <div class="distribution-section">
-        <span class="section-title">Quality Distribution</span>
-        <div class="distribution-bar">
-          @for (entry of data.classTally | keyvalue; track entry.key) {
-            @if (entry.value > 0) {
-              <div
-                class="bar-segment"
-                [style.width.%]="tallyPercent(entry.value)"
-                [style.background-color]="gradeColor(entry.key)"
-                [title]="entry.key + ': ' + entry.value"
-              ></div>
-            }
-          }
-        </div>
-        <div class="distribution-legend">
-          @for (entry of data.classTally | keyvalue; track entry.key) {
-            @if (entry.value > 0) {
-              <span class="legend-item">
-                <span class="legend-dot" [style.background-color]="gradeColor(entry.key)"></span>
-                {{ entry.key }} ({{ entry.value }})
-              </span>
-            }
-          }
-        </div>
-      </div>
-    }
-
-    <!-- Row 3: Thumbnail Gallery Grid -->
-    @if (data.frames.length > 0) {
-      <div class="frames-section">
-        <button class="gallery-toggle-row" type="button" (click)="toggleGallery()">
-          <mat-icon class="toggle-chevron" [class.rotated]="galleryOpen">expand_more</mat-icon>
-          <span>{{ galleryOpen ? 'Hide' : 'Show' }} Frame Breakdowns ({{ data.frameCount }})</span>
-        </button>
-
-        @if (galleryOpen) {
-          <div class="frame-grid">
-            @for (frame of data.frames; track frame.archive_id) {
-              <div
-                class="frame-card"
-                [class.clickable]="!!frameImages[frame.archive_id]"
-                (click)="openFrameInspector(frame, frameImages[frame.archive_id])"
-              >
-                @if (frameImages[frame.archive_id]; as imgUrl) {
-                  <img class="frame-thumb" [src]="imgUrl" [alt]="frame.archive_id" />
-                } @else if (imageErrors[frame.archive_id]) {
-                  <div class="frame-thumb-placeholder error">
-                    <mat-icon>broken_image</mat-icon>
-                  </div>
-                } @else {
-                  <div class="frame-thumb-placeholder">
-                    <mat-spinner diameter="20"></mat-spinner>
-                  </div>
-                }
-                <div class="frame-meta">
-                  <span>{{ frame.total_count ?? 0 }} det.</span>
-                  <span>{{ frame.inference_ms | number:'1.0-0' }}&thinsp;ms</span>
-                </div>
-              </div>
-            }
-          </div>
-        }
-      </div>
-    }
-
-  }
-
-</div>

+ 0 - 273
src/src.palm.vision/history/batch-report/batch-report.component.scss

@@ -1,273 +0,0 @@
-.batch-report {
-  max-width: 760px;
-  margin: 0 auto;
-  padding: 16px;
-  display: flex;
-  flex-direction: column;
-  gap: 20px;
-
-  @media (max-width: 480px) {
-    padding: 12px;
-    gap: 14px;
-  }
-}
-
-// ── Loading / Error states ─────────────────────────────────────────────────────
-
-.report-loading,
-.report-error {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 12px;
-  padding: 40px 16px;
-  color: rgba(255, 255, 255, 0.5);
-  font-size: 0.875rem;
-}
-
-.report-error {
-  color: #f44336;
-}
-
-// ── Row 1: KPI scalar metrics ──────────────────────────────────────────────────
-
-.kpi-row {
-  display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
-  gap: 10px;
-}
-
-.kpi-card {
-  background: rgba(255, 255, 255, 0.05);
-  border: 1px solid rgba(255, 255, 255, 0.10);
-  border-radius: 8px;
-  padding: 14px 8px 10px;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 4px;
-  text-align: center;
-}
-
-.kpi-value {
-  font-size: 1.15rem;
-  font-weight: 600;
-  color: #ffffff;
-  line-height: 1.2;
-  white-space: nowrap;
-}
-
-.kpi-label {
-  font-size: 0.68rem;
-  color: rgba(255, 255, 255, 0.45);
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
-}
-
-// ── Row 2: Quality distribution bar ───────────────────────────────────────────
-
-.distribution-section {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.section-title {
-  font-size: 0.72rem;
-  font-weight: 600;
-  color: rgba(255, 255, 255, 0.45);
-  text-transform: uppercase;
-  letter-spacing: 0.08em;
-}
-
-.distribution-bar {
-  display: flex;
-  height: 18px;
-  border-radius: 4px;
-  overflow: hidden;
-  width: 100%;
-  background: rgba(255, 255, 255, 0.06);
-}
-
-.bar-segment {
-  height: 100%;
-  min-width: 2px;
-  transition: width 0.35s ease;
-}
-
-.distribution-legend {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px 14px;
-}
-
-.legend-item {
-  display: flex;
-  align-items: center;
-  gap: 5px;
-  font-size: 0.75rem;
-  color: rgba(255, 255, 255, 0.65);
-}
-
-.legend-dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  flex-shrink: 0;
-}
-
-// ── Row 3: Thumbnail gallery grid ─────────────────────────────────────────────
-
-.frames-section {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-}
-
-.gallery-toggle-row {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  background: rgba(255, 255, 255, 0.04);
-  border: 1px solid rgba(255, 255, 255, 0.08);
-  border-radius: 6px;
-  padding: 6px 12px;
-  cursor: pointer;
-  width: 100%;
-  text-align: left;
-  color: rgba(255, 255, 255, 0.55);
-  font-size: 0.75rem;
-  font-weight: 500;
-  letter-spacing: 0.03em;
-  transition: background 0.15s, color 0.15s;
-
-  &:hover {
-    background: rgba(255, 255, 255, 0.08);
-    color: rgba(255, 255, 255, 0.80);
-  }
-
-  .toggle-chevron {
-    font-size: 1rem;
-    width: 1rem;
-    height: 1rem;
-    transition: transform 0.2s;
-
-    &.rotated {
-      transform: rotate(180deg);
-    }
-  }
-}
-
-.frame-grid {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
-  gap: 8px;
-}
-
-.frame-card {
-  background: rgba(255, 255, 255, 0.04);
-  border: 1px solid rgba(255, 255, 255, 0.08);
-  border-radius: 6px;
-  overflow: hidden;
-  display: flex;
-  flex-direction: column;
-  cursor: default;
-  transition: border-color 0.15s, background 0.15s;
-
-  &.clickable {
-    cursor: pointer;
-
-    &:hover {
-      border-color: rgba(129, 199, 132, 0.50);
-      background: rgba(255, 255, 255, 0.07);
-    }
-  }
-}
-
-.frame-thumb {
-  width: 100%;
-  height: 76px;
-  object-fit: cover;
-  display: block;
-}
-
-.frame-thumb-placeholder {
-  width: 100%;
-  height: 76px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: rgba(255, 255, 255, 0.03);
-  color: rgba(255, 255, 255, 0.25);
-
-  &.error {
-    color: rgba(244, 67, 54, 0.55);
-  }
-}
-
-.frame-meta {
-  padding: 4px 7px;
-  display: flex;
-  justify-content: space-between;
-  font-size: 0.65rem;
-  color: rgba(255, 255, 255, 0.45);
-  line-height: 1.5;
-}
-
-// ── Light-mode variant (analyzer context only) ─────────────────────────────────
-
-.batch-report.light {
-  .kpi-card {
-    background: #ffffff;
-    border-color: #c8e6c9;
-  }
-
-  .kpi-value {
-    color: #1b5e20;
-  }
-
-  .kpi-label {
-    color: #607d8b;
-  }
-
-  .section-title {
-    color: #607d8b;
-  }
-
-  .legend-item {
-    color: #37474f;
-  }
-
-  .gallery-toggle-row {
-    background: #f1f8e9;
-    border-color: #c8e6c9;
-    color: #2e7d32;
-
-    &:hover {
-      background: #e8f5e9;
-      color: #1b5e20;
-    }
-  }
-
-  .frame-card {
-    background: #ffffff;
-    border-color: #c8e6c9;
-
-    &.clickable:hover {
-      border-color: #81c784;
-      background: #e8f5e9;
-    }
-  }
-
-  .frame-meta {
-    color: #607d8b;
-  }
-
-  .report-loading {
-    color: #546e7a;
-  }
-
-  .report-error {
-    color: #f44336;
-  }
-}

+ 0 - 165
src/src.palm.vision/history/batch-report/batch-report.component.ts

@@ -1,165 +0,0 @@
-import {
-  Component,
-  Input,
-  OnChanges,
-  OnDestroy,
-  OnInit,
-  SimpleChanges,
-} from '@angular/core';
-import { DatePipe, DecimalPipe, KeyValuePipe } from '@angular/common';
-import { Subject, takeUntil } from 'rxjs';
-import { MatButtonModule } from '@angular/material/button';
-import { MatDialog } from '@angular/material/dialog';
-import { MatIconModule } from '@angular/material/icon';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { RemoteInferenceService } from '../../services/remote-inference.service';
-import {
-  FrameInspectorDialogComponent,
-  FrameInspectorData,
-} from './frame-inspector-dialog.component';
-
-export interface BatchDetails {
-  batchId: string;
-  frameCount: number;
-  totalDetections: number;
-  avgInferenceMs: number;
-  avgProcessingMs: number;
-  batchStart: string | null;
-  batchEnd: string | null;
-  classTally: Record<string, number>;
-  frames: any[];
-}
-
-@Component({
-  selector: 'app-batch-report',
-  standalone: true,
-  imports: [
-    DatePipe,
-    DecimalPipe,
-    KeyValuePipe,
-    MatButtonModule,
-    MatIconModule,
-    MatProgressSpinnerModule,
-  ],
-  templateUrl: './batch-report.component.html',
-  styleUrl: './batch-report.component.scss',
-})
-export class BatchReportComponent implements OnInit, OnChanges, OnDestroy {
-  @Input() batchId = '';
-  @Input() startCollapsed = false;
-  @Input() lightMode = false;
-
-  data: BatchDetails | null = null;
-  loading = false;
-  error: string | null = null;
-  frameImages: Record<string, string> = {};
-  imageErrors: Record<string, boolean> = {};
-  galleryOpen = true;
-
-  readonly MPOB_COLORS: Record<string, string> = {
-    Ripe: '#4caf50',
-    Unripe: '#ff9800',
-    Underripe: '#ffeb3b',
-    Overripe: '#9c27b0',
-    Abnormal: '#f44336',
-    Empty_Bunch: '#607d8b',
-  };
-
-  private destroyed$ = new Subject<void>();
-
-  constructor(
-    private remoteInference: RemoteInferenceService,
-    private dialog: MatDialog,
-  ) {}
-
-  ngOnInit(): void {
-    this.galleryOpen = !this.startCollapsed;
-  }
-
-  ngOnChanges(changes: SimpleChanges): void {
-    if (changes['batchId'] && this.batchId) {
-      this.fetch();
-    }
-  }
-
-  ngOnDestroy(): void {
-    this.destroyed$.next();
-    this.destroyed$.complete();
-  }
-
-  totalInTally(): number {
-    if (!this.data?.classTally) return 0;
-    return Object.values(this.data.classTally).reduce((s, v) => s + v, 0);
-  }
-
-  tallyPercent(count: number): number {
-    const total = this.totalInTally();
-    return total > 0 ? (count / total) * 100 : 0;
-  }
-
-  gradeColor(cls: string): string {
-    return this.MPOB_COLORS[cls] ?? '#757575';
-  }
-
-  toggleGallery(): void {
-    this.galleryOpen = !this.galleryOpen;
-  }
-
-  openFrameInspector(frame: any, imgUrl: string | undefined): void {
-    if (!imgUrl) return;
-    const data: FrameInspectorData = {
-      frame,
-      imgUrl,
-      mpobColors: this.MPOB_COLORS,
-    };
-    this.dialog.open(FrameInspectorDialogComponent, {
-      data,
-      panelClass: 'frame-inspector-panel',
-      maxWidth: '90vw',
-      autoFocus: false,
-    });
-  }
-
-  private fetch(): void {
-    this.loading = true;
-    this.error = null;
-    this.data = null;
-    this.frameImages = {};
-    this.imageErrors = {};
-    this.galleryOpen = !this.startCollapsed;
-
-    this.remoteInference.getBatchDetails(this.batchId)
-      .pipe(takeUntil(this.destroyed$))
-      .subscribe({
-        next: details => {
-          this.data = details as BatchDetails;
-          this.loading = false;
-          this.loadFrameImages(details.frames ?? []);
-        },
-        error: err => {
-          this.error = err?.message ?? 'Failed to load batch details.';
-          this.loading = false;
-        },
-      });
-  }
-
-  private loadFrameImages(frames: any[]): void {
-    for (const frame of frames) {
-      const id: string = frame.archive_id;
-      this.remoteInference.getImage(id)
-        .pipe(takeUntil(this.destroyed$))
-        .subscribe({
-          next: res => {
-            if (res?.image_data) {
-              this.frameImages = { ...this.frameImages, [id]: res.image_data };
-            } else {
-              this.imageErrors = { ...this.imageErrors, [id]: true };
-            }
-          },
-          error: () => {
-            this.imageErrors = { ...this.imageErrors, [id]: true };
-          },
-        });
-    }
-  }
-}

+ 0 - 214
src/src.palm.vision/history/batch-report/frame-inspector-dialog.component.ts

@@ -1,214 +0,0 @@
-import {
-  AfterViewInit,
-  Component,
-  ElementRef,
-  Inject,
-  ViewChild,
-} from '@angular/core';
-import { DecimalPipe } from '@angular/common';
-import {
-  MAT_DIALOG_DATA,
-  MatDialogModule,
-  MatDialogRef,
-} from '@angular/material/dialog';
-import { MatButtonModule } from '@angular/material/button';
-import { MatIconModule } from '@angular/material/icon';
-
-export interface FrameInspectorData {
-  frame: any;
-  imgUrl: string;
-  mpobColors: Record<string, string>;
-}
-
-@Component({
-  selector: 'app-frame-inspector-dialog',
-  standalone: true,
-  imports: [DecimalPipe, MatDialogModule, MatButtonModule, MatIconModule],
-  template: `
-    <div class="inspector-shell">
-      <div class="inspector-title">
-        <mat-icon class="title-icon">image_search</mat-icon>
-        <span>Frame Inspector</span>
-        <button mat-icon-button class="close-btn" (click)="close()">
-          <mat-icon>close</mat-icon>
-        </button>
-      </div>
-
-      <div class="inspector-body">
-        <canvas #inspectorCanvas class="inspector-canvas"></canvas>
-
-        <div class="det-list">
-          @for (det of detections; track det.bunch_id ?? $index) {
-            <div class="det-row" [style.border-left-color]="color(det.class)">
-              <span class="det-class">{{ det.class }}</span>
-              <span class="det-conf">{{ det.confidence * 100 | number:'1.1-1' }}%</span>
-            </div>
-          }
-          @if (detections.length === 0) {
-            <p class="det-empty">No detections.</p>
-          }
-        </div>
-      </div>
-
-
-      <div class="inspector-footer">
-        <span class="timing-chip">
-          Inference: {{ data.frame.inference_ms | number:'1.0-0' }}&thinsp;ms
-          &nbsp;|&nbsp;
-          Processing: {{ data.frame.processing_ms | number:'1.0-0' }}&thinsp;ms
-        </span>
-        <button mat-stroked-button (click)="close()">Close</button>
-      </div>
-    </div>
-  `,
-  styles: [`
-    :host { display: block; }
-
-    .inspector-shell {
-      display: flex;
-      flex-direction: column;
-      background: #1e272e;
-      border-radius: 8px;
-      overflow: hidden;
-    }
-
-    .inspector-title {
-      display: flex;
-      align-items: center;
-      gap: 8px;
-      padding: 14px 20px 12px;
-      border-bottom: 1px solid rgba(255,255,255,0.10);
-      color: #fff;
-      .title-icon { color: #81c784; font-size: 1.2rem; width: 1.2rem; height: 1.2rem; }
-      span { flex: 1; font-size: 0.95rem; font-weight: 600; }
-      .close-btn { color: rgba(255,255,255,0.45); width: 32px; height: 32px; }
-    }
-
-    .inspector-body {
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      gap: 14px;
-      padding: 16px 20px;
-      overflow-y: auto;
-      max-height: 80vh;
-    }
-
-    .inspector-canvas {
-      display: block;
-      max-width: 100%;
-      max-height: 60vh;
-      height: auto;
-      border-radius: 8px;
-      background: #111;
-    }
-
-    .det-list {
-      display: flex;
-      flex-wrap: wrap;
-      gap: 6px;
-      width: 100%;
-    }
-
-    .det-row {
-      border-left: 3px solid;
-      border-radius: 0 4px 4px 0;
-      padding: 5px 10px;
-      background: rgba(255,255,255,0.05);
-      display: flex;
-      align-items: center;
-      gap: 8px;
-    }
-
-    .det-class { font-size: 0.78rem; color: rgba(255,255,255,0.82); font-weight: 500; }
-    .det-conf  { font-size: 0.72rem; color: rgba(255,255,255,0.45); white-space: nowrap; }
-    .det-empty { font-size: 0.8rem; color: rgba(255,255,255,0.35); font-style: italic; margin: 0; }
-
-    .inspector-footer {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      padding: 10px 20px 16px;
-      border-top: 1px solid rgba(255,255,255,0.10);
-      .timing-chip { font-size: 0.72rem; color: rgba(255,255,255,0.40); }
-      button { color: rgba(255,255,255,0.65); border-color: rgba(255,255,255,0.20); }
-    }
-  `],
-})
-export class FrameInspectorDialogComponent implements AfterViewInit {
-  @ViewChild('inspectorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
-
-  detections: any[];
-
-  constructor(
-    public dialogRef: MatDialogRef<FrameInspectorDialogComponent>,
-    @Inject(MAT_DIALOG_DATA) public data: FrameInspectorData,
-  ) {
-    const raw = data.frame?.detections;
-    this.detections = Array.isArray(raw)
-      ? raw
-      : (typeof raw === 'string' ? (() => { try { return JSON.parse(raw); } catch { return []; } })() : []);
-  }
-
-  ngAfterViewInit(): void {
-    this.paint();
-  }
-
-  close(): void {
-    this.dialogRef.close();
-  }
-
-  color(cls: string): string {
-    return this.data.mpobColors[cls] ?? '#757575';
-  }
-
-  private paint(): void {
-    const canvas = this.canvasRef?.nativeElement;
-    if (!canvas || !this.data.imgUrl) return;
-
-    const img = new Image();
-    img.onload = () => {
-      const maxW = Math.min(img.naturalWidth, 720);
-      const scale = maxW / img.naturalWidth;
-      canvas.width = img.naturalWidth * scale;
-      canvas.height = img.naturalHeight * scale;
-      canvas.style.width = '';
-      canvas.style.height = '';
-      const ctx = canvas.getContext('2d')!;
-      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
-
-      for (const det of this.detections) {
-        let x: number, y: number, w: number, h: number;
-        if (det.norm_box) {
-          const [nx1, ny1, nx2, ny2] = det.norm_box;
-          x = nx1 * canvas.width;
-          y = ny1 * canvas.height;
-          w = (nx2 - nx1) * canvas.width;
-          h = (ny2 - ny1) * canvas.height;
-        } else if (det.box) {
-          const [x1, y1, x2, y2] = det.box;
-          x = x1 * scale;
-          y = y1 * scale;
-          w = (x2 - x1) * scale;
-          h = (y2 - y1) * scale;
-        } else {
-          continue;
-        }
-
-        const color = this.color(det.class);
-        ctx.strokeStyle = color;
-        ctx.lineWidth = 2;
-        ctx.strokeRect(x, y, w, h);
-
-        const label = `${det.class} ${(det.confidence * 100).toFixed(1)}%`;
-        ctx.font = 'bold 12px sans-serif';
-        const tw = ctx.measureText(label).width;
-        ctx.fillStyle = color;
-        ctx.fillRect(x, y - 20, tw + 8, 20);
-        ctx.fillStyle = '#fff';
-        ctx.fillText(label, x + 4, y - 5);
-      }
-    };
-    img.src = this.data.imgUrl;
-  }
-}

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

@@ -1,83 +0,0 @@
-<div class="history-root">
-
-  <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" [disabled]="loading$ | async" (click)="onClearAll()">
-      <mat-icon>delete_sweep</mat-icon>
-      Clear All
-    </button>
-  </div>
-
-  @if (loading$ | async) {
-    <div class="loading-container">
-      <mat-spinner diameter="44"></mat-spinner>
-      <span class="loading-label">Loading records&hellip;</span>
-    </div>
-  }
-
-  @if (groups$ | async; as groups) {
-    @if (groups.length === 0 && !(loading$ | async)) {
-      <div class="empty-state">
-        <mat-icon class="empty-icon">inbox</mat-icon>
-        <p class="empty-label">No historical records found.</p>
-        <small class="empty-hint">Please verify your Edge Server connection if this is unexpected.</small>
-      </div>
-    }
-
-    @if (groups.length > 0) {
-      @for (group of groups; track group.batchId) {
-        <div class="batch-card" [class.expanded]="group.isExpanded">
-
-          <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">
-                @switch (group.mode) {
-                  @case ('local-onnx') { ONNX }
-                  @case ('local-tflite') { TFLite }
-                  @default { Server }
-                }
-              </span>
-            </div>
-
-            <button
-              mat-icon-button
-              color="warn"
-              class="delete-btn"
-              matTooltip="Delete batch"
-              [disabled]="loading$ | async"
-              (click)="onDeleteBatch(group, $event)"
-            >
-              <mat-icon>delete_outline</mat-icon>
-            </button>
-          </div>
-
-          @if (group.isExpanded) {
-            <div class="batch-detail">
-              <app-batch-report [batchId]="group.batchId"></app-batch-report>
-            </div>
-          }
-
-        </div>
-      }
-    }
-  }
-
-</div>

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

@@ -1,260 +0,0 @@
-.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;
-    }
-
-    &.n8n {
-      background: #fff8e1;
-      color: #e65100;
-      border: 1px solid #ffcc02;
-    }
-  }
-
-  .delete-btn {
-    margin-left: auto;
-    flex-shrink: 0;
-  }
-}
-
-// ── Detail drawer (hosts BatchReportComponent) ────────────────────────────────
-
-.batch-detail {
-  padding: 0 1rem;
-  border-top: 1px solid #c8e6c9;
-  background: #1e272e;
-}
-
-// ── Mobile responsive overrides ───────────────────────────────────────────────
-
-@media (max-width: 767px) {
-  .history-root {
-    padding: 1rem 0.75rem;
-    gap: 0.75rem;
-  }
-
-  .history-header {
-    flex-wrap: wrap;
-    gap: 0.5rem;
-
-    .clear-btn {
-      margin-left: 0;
-    }
-  }
-
-  .batch-header {
-    flex-wrap: wrap;
-    gap: 0.5rem;
-    padding: 0.65rem 0.75rem;
-
-    .batch-meta {
-      min-width: 0;
-      flex: 1 1 auto;
-    }
-
-    .batch-stats {
-      flex-basis: 100%;
-      order: 3;
-    }
-
-    .mode-badge {
-      order: 2;
-    }
-
-    .delete-btn {
-      order: 1;
-      margin-left: auto;
-    }
-  }
-
-  .batch-detail {
-    padding: 0 0.5rem;
-  }
-}

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

@@ -1,119 +0,0 @@
-import {
-  Component,
-  OnDestroy,
-  OnInit,
-} from '@angular/core';
-import { AsyncPipe, DatePipe, DecimalPipe, NgClass, SlicePipe } from '@angular/common';
-import { Select, Store } from '@ngxs/store';
-import { combineLatest, Observable, Subscription } 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,
-  LoadHistory,
-  ToggleBatchGroup,
-} from '../store/vision.actions';
-import { BatchReportComponent } from './batch-report/batch-report.component';
-
-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,
-    SlicePipe,
-    MatButtonModule,
-    MatIconModule,
-    MatProgressSpinnerModule,
-    MatTooltipModule,
-    BatchReportComponent,
-  ],
-  templateUrl: './history.component.html',
-  styleUrl: './history.component.scss',
-})
-export class HistoryComponent implements OnInit, OnDestroy {
-  @Select(VisionState.items) items$!: Observable<any[]>;
-  @Select(VisionState.expandedBatchIds) expandedBatchIds$!: Observable<string[]>;
-  @Select(VisionState.loading) loading$!: Observable<boolean>;
-
-  groups$!: Observable<BatchGroup[]>;
-  private groupsSub!: Subscription;
-
-  constructor(private store: Store) {}
-
-  ngOnInit(): void {
-    this.groups$ = combineLatest([this.items$, this.expandedBatchIds$]).pipe(
-      map(([items, expandedIds]) => this.buildGroups(items, expandedIds)),
-    );
-    this.groupsSub = this.groups$.subscribe();
-    this.store.dispatch(new LoadHistory());
-  }
-
-  ngOnDestroy(): void {
-    this.groupsSub?.unsubscribe();
-  }
-
-  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 groupMap = new Map<string, any[]>();
-    for (const item of (items ?? [])) {
-      const bid = item.batch_id;
-      if (!bid) continue;
-      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),
-      };
-    });
-  }
-}

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

@@ -1,18 +0,0 @@
-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 {}

+ 3 - 0
tsconfig.json

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