Procházet zdrojové kódy

first stage changes to use fis angular components lib and dp-ui

Dr-Swopt před 1 dnem
rodič
revize
166e27d40f

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1523 - 1261
package-lock.json


+ 33 - 3
package.json

@@ -6,7 +6,8 @@
     "start": "ng serve",
     "build": "ng build",
     "watch": "ng build --watch --configuration development",
-    "test": "ng test"
+    "test": "ng test",
+    "postinstall": "node -e \"const fs=require('fs'),p=require('path');['dependencies/angularlib','dependencies/dp-ui'].forEach(d=>['@angular','@angular-devkit','@ngxs','primeng','@primeuix'].forEach(s=>{try{fs.rmSync(p.join(d,'node_modules',s),{recursive:true,force:true})}catch(e){}}))\""
   },
   "prettier": {
     "overrides": [
@@ -20,11 +21,13 @@
   },
   "private": true,
   "dependencies": {
+    "@angular/animations": "^20.0.0",
     "@angular/cdk": "^20.0.0",
     "@angular/common": "^20.0.0",
     "@angular/compiler": "^20.0.0",
     "@angular/core": "^20.0.0",
     "@angular/forms": "^20.0.0",
+    "@angular/material": "^20.0.0",
     "@angular/platform-browser": "^20.0.0",
     "@angular/router": "^20.0.0",
     "@tensorflow/tfjs": "^4.22.0",
@@ -35,7 +38,13 @@
     "tslib": "^2.3.0",
     "zone.js": "~0.15.0",
     "angularlib": "file:dependencies/angularlib",
-    "dp-ui": "file:dependencies/dp-ui"
+    "dp-ui": "file:dependencies/dp-ui",
+    "primeng": "^21.1.1",
+    "@ngxs/form-plugin": "^20.1.0",
+    "@angular/material-moment-adapter": "^20.0.0",
+    "moment": "^2.30.1",
+    "ngx-socket-io": "^4.8.5",
+    "@ngrx/signals": "^19.2.1"
   },
   "devDependencies": {
     "@angular/build": "^20.0.5",
@@ -49,5 +58,26 @@
     "karma-jasmine": "~5.1.0",
     "karma-jasmine-html-reporter": "~2.1.0",
     "typescript": "~5.8.2"
+  },
+  "overrides": {
+    "@angular/animations": "^20.0.0",
+    "@angular/cdk": "^20.0.0",
+    "@angular/common": "^20.0.0",
+    "@angular/compiler": "^20.0.0",
+    "@angular/core": "^20.0.0",
+    "@angular/forms": "^20.0.0",
+    "@angular/material": "^20.0.0",
+    "@angular/platform-browser": "^20.0.0",
+    "@angular/router": "^20.0.0",
+    "@angular/service-worker": "^20.0.0",
+    "rxjs": "~7.8.0",
+    "tslib": "^2.3.0",
+    "zone.js": "~0.15.0",
+    "primeng": "^21.1.1",
+    "@ngxs/form-plugin": "^20.1.0",
+    "@angular/material-moment-adapter": "^20.0.0",
+    "moment": "^2.30.1",
+    "ngx-socket-io": "^4.8.5",
+    "@ngrx/signals": "^19.2.1"
   }
-}
+}

+ 6 - 0
public/config/config.json

@@ -0,0 +1,6 @@
+{
+  "connection": {
+    "uacp_ws": "http://localhost:3000/vision",
+    "uacpEmulation": "off"
+  }
+}

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

@@ -1,14 +1,20 @@
-import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
+import { ApplicationConfig, importProvidersFrom, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
 import { provideRouter } from '@angular/router';
 import { provideHttpClient } from '@angular/common/http';
+import { provideStore } from '@ngxs/store';
 
 import { routes } from './app.routes';
+import { Angularlib } from 'angularlib/angularlib.module';
+import { DpModule } from 'dp-ui/dp.module';
 
 export const appConfig: ApplicationConfig = {
   providers: [
     provideBrowserGlobalErrorListeners(),
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideRouter(routes),
-    provideHttpClient()
+    provideHttpClient(),
+    provideStore([]),
+    importProvidersFrom(Angularlib),
+    importProvidersFrom(DpModule),
   ]
 };

+ 112 - 105
src/app/components/analyzer/analyzer.component.ts

@@ -1,27 +1,11 @@
 /**
- * Lego 02 / Lego 11 / Lego 13 — Vision Tab (Scanner)
+ * ADR-024.3 — Vision Engine Core Reconstruction
  *
- * Three-engine "Snap & Analyze" architecture. No continuous inference loops.
- *
- * Engines:
- *   tflite  — Browser TFLite WASM (local, file upload)
- *   onnx    — Browser ONNX Runtime (local, file upload)
- *   socket  — NestJS /vision socket (raw Base64 snap via vision:analyze)
- *
- * Snap workflow (socket engine):
- *   1. User starts webcam → live preview plays (no inference)
- *   2. User clicks "Snap & Analyze"
- *   3. Component captures the current video frame as 640×640 Base64
- *   4. VisionSocketService emits vision:analyze to NestJS
- *   5. NestJS returns vision:result → bounding boxes drawn on frozen snapshot
- *
- * Snap workflow (browser engines):
- *   1. User uploads an image file
- *   2. User clicks "Run Inference"
- *   3. InferenceService runs TFLite or ONNX locally
- *   4. Detections drawn on the canvas
- *
- * Preservation rule: TFLite/ONNX tensor math in LocalInferenceService is untouched.
+ * Extends BaseComponent for lifecycle-managed memory safety.
+ * All network frames route through RemoteInferenceService → DpService.stream()
+ * rather than raw socket instances.
+ * The `visionSocket` property is a local signal shim that preserves the
+ * existing template bindings without requiring HTML changes.
  */
 
 import {
@@ -36,9 +20,16 @@ import {
 } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
+import { interval } from 'rxjs';
+import { Store } from '@ngxs/store';
+
+import { BaseComponent, untilDestroy } from 'angularlib/base.component';
+import { ComponentService } from 'angularlib/component.service';
+import { NgxSocketService } from 'dp-ui/socket/ngxSocket.service';
+
 import { LocalHistoryService } from '../../services/local-history.service';
+import { RemoteInferenceService } from '../../services/remote-inference.service';
 import { InferenceService, LocalEngine } from '../../core/services/inference.service';
-import { VisionSocketService } from '../../services/vision-socket.service';
 import {
   BatchResult,
   FullSessionReport,
@@ -58,21 +49,16 @@ const GRADE_COLORS: Record<string, string> = {
   templateUrl: './analyzer.component.html',
   styleUrls: ['./analyzer.component.scss'],
 })
-export class AnalyzerComponent implements OnInit, OnDestroy {
+export class AnalyzerComponent extends BaseComponent implements OnInit, OnDestroy {
 
   // ── Canvas refs ────────────────────────────────────────────────────────────
-  /** Result canvas for browser-engine mode (drawn after inference) */
   @ViewChild('resultCanvas') resultCanvasRef!: ElementRef<HTMLCanvasElement>;
-  /** Snapshot canvas for socket-engine mode (frozen frame + bounding boxes) */
   @ViewChild('snapCanvas') snapCanvasRef!: ElementRef<HTMLCanvasElement>;
-  /** Evidence canvas for batch audit drill-down (separate from snapCanvas) */
   @ViewChild('evidenceCanvas') evidenceCanvasRef!: ElementRef<HTMLCanvasElement>;
-  /** Live webcam video feed */
   @ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;
 
   // ── Engine selection ───────────────────────────────────────────────────────
   engine = signal<SnapEngine>('onnx');
-  /** 'local' for browser engines, 'backend' for socket */
   engineMode = computed<'local' | 'backend'>(() =>
     this.engine() === 'socket' ? 'backend' : 'local'
   );
@@ -84,7 +70,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   loading = false;
   isDragging = false;
 
-  // ── Batch ingestion state (backend/socket mode only) ───────────────────────
+  // ── Batch ingestion state ──────────────────────────────────────────────────
   batchQueue = signal<File[]>([]);
   currentBatchIndex = signal<number>(0);
   private _batchRunning = signal<boolean>(false);
@@ -94,50 +80,65 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   completedReport = signal<FullSessionReport | null>(null);
   selectedAuditEntry = signal<BatchResult | null>(null);
 
-  /** Wall-clock timestamp of when sendBase64 was called for the current image */
   private _pingTime = 0;
-  /** Wall-clock timestamp of when startBatchProcessing was called */
   private _batchStartTime = 0;
-  /** Total files in the current batch run — stored separately so it survives queue drain */
   private _totalBatchCount = 0;
   get totalBatchCount(): number { return this._totalBatchCount; }
-  /** Blob URL for the file currently being processed — stored so the pong effect can attach it to the record */
   private _currentBlobUrl: string | null = null;
-  /** UUID shared across all frames in a batch session — sent to the gateway so DB rows can be grouped in the Vault */
   private _batchId: string = '';
 
   // ── Socket-engine state ────────────────────────────────────────────────────
-  /** 'webcam' = live camera snap | 'gallery' = file from device storage */
   socketInputMode = signal<'webcam' | 'gallery'>('webcam');
-  /** Base64 of the last snapped/gallery frame — displayed as frozen background */
   snappedFrame: string | null = null;
-  /** File selected in gallery mode */
   socketGalleryFile: File | null = null;
   private webcamStream: MediaStream | null = null;
 
+  // ── visionSocket shim ──────────────────────────────────────────────────────
+  // Preserves all template bindings (visionSocket.connected(), .nestStatus(), etc.)
+  // while routing all frames through DpService.stream() via RemoteInferenceService.
+  private _nestStatus = signal<'ONLINE' | 'OFFLINE'>('OFFLINE');
+  private _analyzing = signal<boolean>(false);
+  private _lastResult = signal<any>(null);
+  private _lastError = signal<string | null>(null);
+
+  readonly visionSocket = {
+    nestStatus: this._nestStatus,
+    connected: computed(() => this._nestStatus() === 'ONLINE'),
+    analyzing: this._analyzing,
+    lastResult: this._lastResult,
+    lastError: this._lastError,
+    clearResult: () => {
+      this._lastResult.set(null);
+      this._lastError.set(null);
+    },
+  };
+
   constructor(
+    store: Store,
+    cs: ComponentService,
     public inferenceService: InferenceService,
     private localHistory: LocalHistoryService,
-    public visionSocket: VisionSocketService,
+    private remoteInference: RemoteInferenceService,
+    private ngxSocket: NgxSocketService,
   ) {
-    // When NestJS goes offline, force the engine back to a local mode
+    super(store, cs);
+
+    // Force back to local engine when backend connection is lost
     effect(() => {
-      if (visionSocket.nestStatus() === 'OFFLINE' && this.engine() === 'socket') {
+      if (this.visionSocket.nestStatus() === 'OFFLINE' && this.engine() === 'socket') {
         this.engine.set('onnx');
         this.stopWebcam();
       }
     });
 
-    // Safety: switching to local while a batch is queued must clear the queue
-    // immediately — local WASM cannot handle multi-file sequential load
+    // Local WASM cannot handle multi-file sequential load — abort any active batch
     effect(() => {
       if (this.engineMode() === 'local' && this.isBatchActive()) {
         this.abortBatch();
       }
     });
 
-    // "Pong" — fires on every lastResult change. Guards on non-null result AND
-    // active batch so it never interferes with single-image gallery/snap flows.
+    // "Pong" — fires when a successful analysis result arrives during a batch run
     effect(() => {
       const res = this.visionSocket.lastResult();
       if (!res || !this.isBatchActive()) return;
@@ -150,7 +151,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
         image_id: queue[idx]?.name ?? `file_${idx}`,
         timestamp: new Date().toISOString(),
         status: 'ok',
-        detections: res.detections.map(d => ({
+        detections: res.detections.map((d: any) => ({
           bunch_id: d.bunch_id,
           ripeness_class: d.class,
           confidence_pct: Math.round(d.confidence * 10000) / 100,
@@ -187,7 +188,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
       }
     });
 
-    // "Error pong" — if a socket error fires mid-batch, log it and advance
+    // "Error pong" — socket error mid-batch: log it and advance to next file
     effect(() => {
       const err = this.visionSocket.lastError();
       if (!err || !this.isBatchActive()) return;
@@ -226,18 +227,24 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
       }
     });
 
-    // Draw evidence canvas whenever the user selects an audit entry
+    // Redraw evidence canvas when the user selects an audit entry
     effect(() => {
       const entry = this.selectedAuditEntry();
       if (!entry) return;
-      // defer one tick so #evidenceCanvas is rendered in the DOM
       setTimeout(() => this.drawEvidence(entry), 0);
     });
   }
 
-  ngOnInit(): void {}
+  ngOnInit(): void {
+    // Poll NgxSocketService.status every second to keep visionSocket.nestStatus in sync
+    interval(1000).pipe(untilDestroy(this)).subscribe(() => {
+      const live = this.ngxSocket.status === 'online' ? 'ONLINE' : 'OFFLINE';
+      if (this._nestStatus() !== live) this._nestStatus.set(live);
+    });
+  }
 
-  ngOnDestroy(): void {
+  override ngOnDestroy(): void {
+    super.ngOnDestroy(); // emits destroyed Subject → all untilDestroy subscriptions complete
     this.stopWebcam();
   }
 
@@ -292,12 +299,14 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     const file = this.batchQueue()[this.currentBatchIndex()];
     if (!file) return;
 
-    // Create a blob URL for the evidence canvas (revoked in finalizeBatch/abortBatch)
     const blobUrl = URL.createObjectURL(file);
     this._currentBlobUrl = blobUrl;
 
     const img = new Image();
-    img.onerror = () => { URL.revokeObjectURL(blobUrl); this.advanceBatchOnError(file.name, 'Image failed to decode'); };
+    img.onerror = () => {
+      URL.revokeObjectURL(blobUrl);
+      this.advanceBatchOnError(file.name, 'Image failed to decode');
+    };
     img.onload = () => {
       const offscreen = document.createElement('canvas');
       offscreen.width = 640;
@@ -305,13 +314,11 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
       offscreen.getContext('2d')!.drawImage(img, 0, 0, 640, 640);
       const base64 = offscreen.toDataURL('image/jpeg');
       this._pingTime = performance.now();
-      this.visionSocket.sendBase64(base64, this._batchId);
+      this._sendBase64(base64, this._batchId);
     };
     img.src = blobUrl;
   }
 
-
-  /** Log a client-side read/decode error and advance to the next file */
   private advanceBatchOnError(fileName: string, reason: string): void {
     const record: BatchResult = {
       image_id: fileName,
@@ -370,16 +377,13 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
   }
 
   abortBatch(): void {
-    // Revoke any pending blob URL for the in-flight image
     if (this._currentBlobUrl) { URL.revokeObjectURL(this._currentBlobUrl); this._currentBlobUrl = null; }
-    // Revoke all stored evidence URLs
     this.sessionManifest.forEach(r => { if (r.localBlobUrl) URL.revokeObjectURL(r.localBlobUrl); });
     this.batchQueue.set([]);
     this.currentBatchIndex.set(0);
     this._batchRunning.set(false);
   }
 
-  /** Draw the source image + bounding boxes onto the dedicated evidence canvas */
   private drawEvidence(entry: BatchResult): void {
     const canvas = this.evidenceCanvasRef?.nativeElement;
     if (!canvas || !entry.localBlobUrl) return;
@@ -445,28 +449,27 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     reader.readAsDataURL(file);
   }
 
-  /** Run local TFLite or ONNX inference on the uploaded file */
   async runLocalInference(): Promise<void> {
     if (!this.selectedFile || !this.previewUrl) return;
     this.loading = true;
     const start = performance.now();
 
-    // Map engine toggle to InferenceService's LocalEngine type
     const localEngine: LocalEngine = this.engine() === 'tflite' ? 'tflite' : 'onnx';
     this.inferenceService.localEngine.set(localEngine);
     this.inferenceService.mode.set('local');
 
     try {
       const img = await this.loadImageDimensions(this.selectedFile);
-      this.inferenceService.analyze(this.previewUrl, img.width, img.height).subscribe({
-        next: (detections) => {
-          this.results = {
-            industrial_summary: this.inferenceService.summary(),
-            inference_ms: performance.now() - start,
-            detections,
-            original_dimensions: img,
-          };
-          if (true) {  // always save local runs to history
+      this.inferenceService.analyze(this.previewUrl, img.width, img.height)
+        .pipe(untilDestroy(this))
+        .subscribe({
+          next: (detections) => {
+            this.results = {
+              industrial_summary: this.inferenceService.summary(),
+              inference_ms: performance.now() - start,
+              detections,
+              original_dimensions: img,
+            };
             this.localHistory.saveRecord(
               this.results,
               this.selectedFile!.name,
@@ -474,22 +477,20 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
               this.previewUrl!,
               img,
             );
-          }
-          this.loading = false;
-          setTimeout(() => this.drawBrowserDetections(), 100);
-        },
-        error: (err) => {
-          console.error('Local inference failed:', err);
-          this.loading = false;
-        },
-      });
+            this.loading = false;
+            setTimeout(() => this.drawBrowserDetections(), 100);
+          },
+          error: (err) => {
+            console.error('Local inference failed:', err);
+            this.loading = false;
+          },
+        });
     } catch (err) {
       console.error('Local inference pipeline error:', err);
       this.loading = false;
     }
   }
 
-  /** Draw detections onto the browser-mode result canvas */
   private drawBrowserDetections(): void {
     const detections = this.inferenceService.detections();
     if (!detections || !this.resultCanvasRef || !this.previewUrl) return;
@@ -576,7 +577,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
       this.socketGalleryFile = files[0];
       this.batchQueue.set([]);
     }
-    // Reset input so the same files can be re-selected if needed
     input.value = '';
   }
 
@@ -610,10 +610,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
       const base64 = e.target?.result as string;
       if (!base64) return;
 
-      // Rescale the gallery image to 640×640 before sending and storing as
-      // snappedFrame. The backend always runs inference in 640×640 space, so
-      // the canvas background must match that same square crop to keep bounding
-      // boxes aligned with the displayed image.
       const img = new Image();
       img.onload = () => {
         const offscreen = document.createElement('canvas');
@@ -622,7 +618,7 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
         offscreen.getContext('2d')!.drawImage(img, 0, 0, 640, 640);
         const scaled640 = offscreen.toDataURL('image/jpeg');
         this.snappedFrame = scaled640;
-        this.visionSocket.sendBase64(scaled640);
+        this._sendBase64(scaled640);
         this.waitForSocketResult();
       };
       img.src = base64;
@@ -630,16 +626,10 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     reader.readAsDataURL(this.socketGalleryFile);
   }
 
-  /**
-   * Capture one frame from the live webcam, freeze it as a snapshot,
-   * and send it to NestJS via vision:analyze.
-   * The live video feed continues playing behind the scenes.
-   */
   snapAndAnalyze(): void {
     const videoEl = this.videoElRef?.nativeElement;
     if (!videoEl || videoEl.readyState < 2) return;
 
-    // Capture current frame to an offscreen canvas → File → batchQueue of 1
     const offscreen = document.createElement('canvas');
     offscreen.width = 640;
     offscreen.height = 640;
@@ -654,11 +644,37 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     }, 'image/jpeg');
   }
 
+  // ── Private frame dispatch ─────────────────────────────────────────────────
+
   /**
-   * Polls the visionSocket.lastResult signal until a new result arrives,
-   * then draws the bounding boxes on the frozen snapshot canvas.
-   * Uses a single-fire check via requestAnimationFrame polling — no setInterval.
+   * Routes a Base64 frame through DpService.stream() via RemoteInferenceService.
+   * Updates visionSocket shim signals so existing template bindings and effects
+   * continue to work without modification.
    */
+  private _sendBase64(base64: string, batchId?: string): void {
+    this._lastResult.set(null);
+    this._lastError.set(null);
+    this._analyzing.set(true);
+
+    this.remoteInference.analyze(base64, batchId)
+      .pipe(untilDestroy(this))
+      .subscribe({
+        next: (res) => {
+          const data = res as any;
+          if (data?.error) {
+            this._lastError.set(data.error);
+          } else {
+            this._lastResult.set(data);
+          }
+          this._analyzing.set(false);
+        },
+        error: (err) => {
+          this._lastError.set(err?.message ?? String(err));
+          this._analyzing.set(false);
+        },
+      });
+  }
+
   private waitForSocketResult(): void {
     const check = () => {
       const result = this.visionSocket.lastResult();
@@ -673,7 +689,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     requestAnimationFrame(check);
   }
 
-  /** Draw detections on the frozen snapshot canvas (socket engine result) */
   private drawSnapDetections(detections: any[]): void {
     const canvas = this.snapCanvasRef?.nativeElement;
     if (!canvas || !this.snappedFrame) return;
@@ -681,21 +696,14 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     const img = new Image();
     img.src = this.snappedFrame;
     img.onload = () => {
-      // The canvas display width matches the container.
-      // The canvas logical size is always set to 640×640 because the backend
-      // always runs inference in 640×640 space — coords are always 640-relative
-      // regardless of whether the source was a webcam snap (already 640×640) or
-      // a gallery image (arbitrary size sent as-is; backend rescales internally).
       const containerWidth = canvas.parentElement!.clientWidth || 640;
       canvas.width = containerWidth;
-      canvas.height = containerWidth; // square: 640px inference space
+      canvas.height = containerWidth;
 
       const ctx = canvas.getContext('2d');
       if (!ctx) return;
-      // Draw the source image stretched to fill the square canvas
       ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
 
-      // Map 640-space coords → canvas pixels via percentage
       const scaleX = canvas.width / 640;
       const scaleY = canvas.height / 640;
 
@@ -746,7 +754,6 @@ export class AnalyzerComponent implements OnInit, OnDestroy {
     return Object.keys(this.inferenceService.summary() ?? {});
   }
 
-  /** Aggregate industrial_summary across all successful batch results */
   getBatchDistribution(): { class: string; count: number; pct: number; color: string }[] {
     const report = this.completedReport();
     if (!report) return [];

+ 11 - 39
src/app/core/services/remote-inference.service.ts

@@ -1,58 +1,30 @@
 import { Injectable } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
-import { Observable, catchError, throwError, of } from 'rxjs';
+import { Observable } from 'rxjs';
 import { environment } from '../../../environments/environment';
 import { AnalysisResponse } from '../interfaces/palm-analysis.interface';
 
-@Injectable({
-  providedIn: 'root'
-})
+@Injectable({ providedIn: 'root' })
 export class RemoteInferenceService {
-  private readonly apiUrl = `${environment.apiUrl}/palm-oil/analyze`;
+  private readonly base = environment.apiUrl;
 
   constructor(private http: HttpClient) {}
 
-  analyze(imageBlob: Blob): Observable<AnalysisResponse> {
+  analyze(blob: Blob): Observable<AnalysisResponse> {
     const formData = new FormData();
-    // 'image' must match the @FileInterceptor('image') in NestJS
-    formData.append('image', imageBlob, 'capture.jpg');
-
-    return this.http.post<AnalysisResponse>(this.apiUrl, formData).pipe(
-      catchError((error) => {
-        console.error('Remote Inference Error:', error);
-        return throwError(() => new Error('Remote API unreachable. Please check your connection or switch to Edge Mode.'));
-      })
-    );
+    formData.append('image', blob, 'capture.jpg');
+    return this.http.post<AnalysisResponse>(`${this.base}/palm-oil/analyze`, formData);
   }
 
   getHistory(): Observable<any[]> {
-    return this.http.get<any[]>(`${environment.apiUrl}/palm-oil/history`).pipe(
-      catchError((error) => {
-        console.error('Remote History Error:', error);
-        return of([]);
-      })
-    );
+    return this.http.get<any[]>(`${this.base}/palm-oil/history`);
   }
 
-  deleteRecord(archiveId: string): Observable<{ deleted: boolean }> {
-    return this.http.delete<{ deleted: boolean }>(
-      `${environment.apiUrl}/palm-oil/history/${archiveId}`
-    ).pipe(
-      catchError((error) => {
-        console.error('Remote delete error:', error);
-        return of({ deleted: false });
-      })
-    );
+  deleteRecord(archiveId: string): Observable<any> {
+    return this.http.delete<any>(`${this.base}/palm-oil/history/${archiveId}`);
   }
 
-  clearAll(): Observable<{ deleted: number }> {
-    return this.http.delete<{ deleted: number }>(
-      `${environment.apiUrl}/palm-oil/history`
-    ).pipe(
-      catchError((error) => {
-        console.error('Remote clear error:', error);
-        return of({ deleted: 0 });
-      })
-    );
+  clearAll(): Observable<any> {
+    return this.http.delete<any>(`${this.base}/palm-oil/history`);
   }
 }

+ 7 - 29
src/app/services/api.service.ts

@@ -3,39 +3,17 @@ import { HttpClient } from '@angular/common/http';
 import { Observable } from 'rxjs';
 import { environment } from '../../environments/environment';
 
-@Injectable({
-  providedIn: 'root'
-})
+@Injectable({ providedIn: 'root' })
 export class ApiService {
-  private apiUrl = environment.apiUrl;
+  private readonly base = environment.apiUrl;
 
-  constructor(private http: HttpClient) { }
+  constructor(private http: HttpClient) {}
 
-  getConfidence(): Observable<any> {
-    return this.http.get(`${this.apiUrl}/get_confidence`);
+  getConfidence(): Observable<{ current_confidence: number }> {
+    return this.http.get<{ current_confidence: number }>(`${this.base}/palm-oil/confidence`);
   }
 
-  setConfidence(threshold: number): Observable<any> {
-    return this.http.post(`${this.apiUrl}/set_confidence`, { threshold });
-  }
-
-  analyze(file: File, modelType: string = 'onnx'): Observable<any> {
-    const formData = new FormData();
-    formData.append('file', file);
-    formData.append('model_type', modelType);
-    return this.http.post(`${this.apiUrl}/analyze`, formData);
-  }
-
-  getHistory(): Observable<any> {
-    return this.http.get(`${this.apiUrl}/get_history`);
-  }
-
-  getModelInfo(modelType: string = 'onnx'): Observable<any> {
-    return this.http.get(`${this.apiUrl}/get_model_info`, { params: { model_type: modelType } });
-  }
-
-  getAnalytics(): Observable<any> {
-    // This could be used for a dashboard later
-    return this.http.get(`${this.apiUrl}/get_history`);
+  setConfidence(confidence: number): Observable<any> {
+    return this.http.post<any>(`${this.base}/palm-oil/confidence`, { confidence });
   }
 }

+ 43 - 140
src/app/services/chat-socket.service.ts

@@ -1,164 +1,67 @@
-/**
- * Lego 03 / Lego 06 — RAG Chat via NestJS Socket Proxy
- *
- * Sends chat messages to NestJS over the existing /vision Socket.io connection.
- * NestJS forwards the message server-to-server to n8n (no CORS constraint),
- * then emits chat:result back here.
- *
- * Messages and loading state live here (not in the component) so they survive
- * tab navigation — the component is destroyed/recreated on each visit, but this
- * service is a singleton that persists for the app lifetime.
- *
- * Events:
- *   emit  → chat:send   { message: string }
- *   on    ← chat:result  <n8n response object>
- *   on    ← chat:error   { message: string }
- */
-
-import { Injectable, signal, OnDestroy } from '@angular/core';
-import { io, Socket } from 'socket.io-client';
-import { environment } from '../../environments/environment';
+import { Injectable, signal } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import { DpService } from 'dp-ui/dp.service';
+import { FisAppMessage } from 'dp-ui/fisappmessage/apprequestmessagetype';
 
 export interface ChatMessage {
-  role: 'user' | 'bot' | 'error';
+  role: 'user' | 'bot';
   text: string;
   timestamp: Date;
   durationMs?: number;
 }
 
-export interface ChatResult {
-  output?: string;
-  answer?: string;
-  response?: string;
-  text?: string;
-  [key: string]: any;
-}
-
-const STORAGE_KEY = 'palm_ai_chat_history';
-
 @Injectable({ providedIn: 'root' })
-export class ChatSocketService implements OnDestroy {
-
-  readonly sending = signal<boolean>(false);
+export class ChatSocketService {
+  readonly messages = signal<ChatMessage[]>([]);
   readonly loading = signal<boolean>(false);
-  readonly lastError = signal<string | null>(null);
-  readonly messages = signal<ChatMessage[]>(this.loadMessages());
-
-  private socket: Socket;
+  readonly sending = signal<boolean>(false);
 
-  constructor() {
-    this.socket = io(`${environment.nestWsUrl}/vision`, {
-      transports: ['websocket'],
-      reconnection: true,
-      reconnectionDelay: 1000,
-      secure: true,
-      rejectUnauthorized: false,
-    });
+  constructor(private dpService: DpService) {}
+
+  private buildMessage(serviceId: string, operation: string, data: unknown): FisAppMessage {
+    return {
+      header: {
+        messageType: 'Command' as any,
+        messageID: crypto.randomUUID(),
+        messageName: operation,
+        dateCreated: new Date().toISOString(),
+        isAggregate: false,
+        serviceId,
+        messageProducerInformation: { producerName: 'palm-oil-ai' } as any,
+        security: { ucpId: 'palm-oil-ai' },
+      },
+      data,
+    };
   }
 
   async sendMessage(text: string): Promise<void> {
-    if (!text || this.loading() || this.sending()) return;
-
-    this.pushMessage({ role: 'user', text, timestamp: new Date() });
-    this.loading.set(true);
-
-    const start = Date.now();
+    const userMsg: ChatMessage = { role: 'user', text, timestamp: new Date() };
+    this.messages.update(msgs => [...msgs, userMsg]);
+    this.sending.set(true);
 
+    const start = performance.now();
     try {
-      const response = await this.send(text);
-      const durationMs = Date.now() - start;
-
-      const botText =
-        response?.output ??
-        response?.answer ??
-        response?.response ??
-        response?.text ??
-        (typeof response === 'string' ? response : JSON.stringify(response));
-
-      this.pushMessage({ role: 'bot', text: botText, timestamp: new Date(), durationMs });
+      const response = await firstValueFrom(
+        this.dpService.stream(this.buildMessage('Chat', 'send', { message: text }))
+      );
+      const durationMs = Math.round(performance.now() - start);
+      const content =
+        response?.output ?? response?.answer ?? response?.response ?? response?.text ?? JSON.stringify(response);
+      const botMsg: ChatMessage = { role: 'bot', text: content, timestamp: new Date(), durationMs };
+      this.messages.update(msgs => [...msgs, botMsg]);
     } catch (err: any) {
-      this.pushMessage({
-        role: 'error',
-        text: `Proxy error: ${err.message ?? 'Unknown error'}. Check NestJS → n8n connection.`,
+      const errorMsg: ChatMessage = {
+        role: 'bot',
+        text: `Error: ${err?.message ?? 'Failed to send message'}`,
         timestamp: new Date(),
-      });
+      };
+      this.messages.update(msgs => [...msgs, errorMsg]);
     } finally {
-      this.loading.set(false);
+      this.sending.set(false);
     }
   }
 
   clearChat(): void {
-    const reset: ChatMessage[] = [{
-      role: 'bot',
-      text: 'Chat cleared. RAG pipeline ready.',
-      timestamp: new Date(),
-    }];
-    this.messages.set(reset);
-    this.saveMessages(reset);
-    this.socket.emit('chat:clear');
-  }
-
-  pushMessage(msg: ChatMessage): void {
-    this.messages.update(msgs => {
-      const updated = [...msgs, msg];
-      this.saveMessages(updated);
-      return updated;
-    });
-  }
-
-  private send(message: string): Promise<ChatResult> {
-    return new Promise((resolve, reject) => {
-      this.sending.set(true);
-      this.lastError.set(null);
-
-      const onResult = (data: ChatResult) => {
-        cleanup();
-        this.sending.set(false);
-        resolve(data);
-      };
-
-      const onError = (err: { message: string }) => {
-        cleanup();
-        this.sending.set(false);
-        const msg = err?.message ?? 'n8n proxy error';
-        this.lastError.set(msg);
-        reject(new Error(msg));
-      };
-
-      const cleanup = () => {
-        this.socket.off('chat:result', onResult);
-        this.socket.off('chat:error', onError);
-      };
-
-      this.socket.once('chat:result', onResult);
-      this.socket.once('chat:error', onError);
-
-      this.socket.emit('chat:send', { message });
-    });
-  }
-
-  private loadMessages(): ChatMessage[] {
-    try {
-      const raw = localStorage.getItem(STORAGE_KEY);
-      if (raw) {
-        const parsed: ChatMessage[] = JSON.parse(raw);
-        return parsed.map(m => ({ ...m, timestamp: new Date(m.timestamp) }));
-      }
-    } catch {}
-    return [{
-      role: 'bot',
-      text: 'RAG pipeline ready. Ask me about palm oil operational data.',
-      timestamp: new Date(),
-    }];
-  }
-
-  private saveMessages(msgs: ChatMessage[]): void {
-    try {
-      localStorage.setItem(STORAGE_KEY, JSON.stringify(msgs));
-    } catch {}
-  }
-
-  ngOnDestroy(): void {
-    this.socket.disconnect();
+    this.messages.set([]);
   }
 }

+ 56 - 0
src/app/services/remote-inference.service.ts

@@ -0,0 +1,56 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { DpService } from 'dp-ui/dp.service';
+import { FisAppMessage } from 'dp-ui/fisappmessage/apprequestmessagetype';
+
+@Injectable({ providedIn: 'root' })
+export class RemoteInferenceService {
+
+  constructor(private dpService: DpService) {}
+
+  private buildMessage(serviceId: string, operation: string, data: unknown): FisAppMessage {
+    return {
+      header: {
+        messageType: 'Command' as any,
+        messageID: crypto.randomUUID(),
+        messageName: operation,
+        dateCreated: new Date().toISOString(),
+        isAggregate: false,
+        serviceId,
+        messageProducerInformation: { producerName: 'palm-oil-ai' } as any,
+        security: { ucpId: 'palm-oil-ai' },
+      },
+      data,
+    };
+  }
+
+  analyze(base64: string, batchId?: string): Observable<any> {
+    return this.dpService.stream(
+      this.buildMessage('PalmVision', 'analyze', { frame: base64, batchId })
+    );
+  }
+
+  getHistory(): Observable<any> {
+    return this.dpService.stream(
+      this.buildMessage('History', 'getAll', {})
+    );
+  }
+
+  deleteRecord(archiveId: string): Observable<any> {
+    return this.dpService.stream(
+      this.buildMessage('History', 'delete', { archiveId })
+    );
+  }
+
+  clearAll(): Observable<any> {
+    return this.dpService.stream(
+      this.buildMessage('History', 'clearAll', {})
+    );
+  }
+
+  getImage(archiveId: string): Observable<any> {
+    return this.dpService.stream(
+      this.buildMessage('PalmHistory', 'GetImage', { archiveId })
+    );
+  }
+}

+ 13 - 20
src/app/services/theme.service.ts

@@ -1,34 +1,27 @@
 import { Injectable, Renderer2, RendererFactory2 } from '@angular/core';
 import { BehaviorSubject } from 'rxjs';
 
-@Injectable({
-  providedIn: 'root'
-})
+@Injectable({ providedIn: 'root' })
 export class ThemeService {
   private renderer: Renderer2;
-  private currentThemeSub = new BehaviorSubject<string>('dark');
-  currentTheme$ = this.currentThemeSub.asObservable();
+  private _isDark = false;
+  readonly currentTheme$ = new BehaviorSubject<'dark' | 'light'>('light');
 
   constructor(rendererFactory: RendererFactory2) {
     this.renderer = rendererFactory.createRenderer(null, null);
-    const savedTheme = localStorage.getItem('palm-ai-theme') || 'dark';
-    this.setTheme(savedTheme);
+    const saved = localStorage.getItem('palm-ai-theme');
+    if (saved === 'dark') this.applyDark(true);
   }
 
-  toggleTheme(): void {
-    const nextTheme = this.currentThemeSub.value === 'dark' ? 'light' : 'dark';
-    this.setTheme(nextTheme);
-  }
+  isDark(): boolean { return this._isDark; }
 
-  setTheme(theme: string): void {
-    const oldTheme = theme === 'dark' ? 'light' : 'dark';
-    this.renderer.removeClass(document.body, `theme-${oldTheme}`);
-    this.renderer.addClass(document.body, `theme-${theme}`);
-    localStorage.setItem('palm-ai-theme', theme);
-    this.currentThemeSub.next(theme);
-  }
+  toggleTheme(): void { this.applyDark(!this._isDark); }
 
-  isDark(): boolean {
-    return this.currentThemeSub.value === 'dark';
+  private applyDark(dark: boolean): void {
+    this._isDark = dark;
+    this.renderer.removeClass(document.body, dark ? 'theme-light' : 'theme-dark');
+    this.renderer.addClass(document.body, dark ? 'theme-dark' : 'theme-light');
+    localStorage.setItem('palm-ai-theme', dark ? 'dark' : 'light');
+    this.currentTheme$.next(dark ? 'dark' : 'light');
   }
 }

+ 19 - 127
src/app/services/vision-socket.service.ts

@@ -1,136 +1,28 @@
-/**
- * Lego 02 / Lego 11 / Lego 13 — Vision Socket Service
- *
- * Opens a persistent Socket.io connection to NestJS /vision namespace.
- * Captures a single webcam frame on demand and emits it as a raw,
- * uncompressed Base64 string on vision:analyze.
- *
- * HARD RULES (Lego 11):
- *   - Frames MUST be sent as raw Base64 strings (canvas.toDataURL('image/jpeg'))
- *   - DO NOT use binary ArrayBuffer encoding
- *   - DO NOT use WebRTC
- *   - DO NOT reduce quality or compress the payload
- *   - One frame per user-initiated "Snap" — no continuous streaming interval
- *
- * The full Base64 payload (~33% larger than binary) rides the socket bus
- * on each snap intentionally — this is the I/O overhead being measured.
- */
-
-import { Injectable, signal, computed, OnDestroy } from '@angular/core';
-import { BehaviorSubject } from 'rxjs';
-import { io, Socket } from 'socket.io-client';
-import { environment } from '../../environments/environment';
-import { AnalysisResponse } from '../core/interfaces/palm-analysis.interface';
+import { Injectable, signal, computed } from '@angular/core';
+import { Observable, filter, map, Subject } from 'rxjs';
+import { interval } from 'rxjs';
+import { NgxSocketService } from 'dp-ui/socket/ngxSocket.service';
 import { SystemMetrics } from '../core/interfaces/system-metrics.interface';
 
 @Injectable({ providedIn: 'root' })
-export class VisionSocketService implements OnDestroy {
-
-  readonly connected = signal<boolean>(false);
-  readonly analyzing = signal<boolean>(false);
-  readonly lastResult = signal<AnalysisResponse | null>(null);
-  readonly lastError = signal<string | null>(null);
-
-  readonly nestStatus = computed<'ONLINE' | 'OFFLINE'>(() =>
-    this.connected() ? 'ONLINE' : 'OFFLINE'
-  );
-
-  private readonly metricsSubject = new BehaviorSubject<SystemMetrics | null>(null);
-  readonly metrics$ = this.metricsSubject.asObservable();
-
-  private socket: Socket;
-
-  // Offscreen canvas used to capture a single frame from the video element
-  private captureCanvas: HTMLCanvasElement;
-  private captureCtx: CanvasRenderingContext2D;
-
-  constructor() {
-    this.captureCanvas = document.createElement('canvas');
-    this.captureCanvas.width = 640;
-    this.captureCanvas.height = 640;
-    this.captureCtx = this.captureCanvas.getContext('2d')!;
-
-    this.socket = io(`${environment.nestWsUrl}/vision`, {
-      transports: ['websocket'],
-      reconnection: true,
-      reconnectionDelay: 1000,
-      secure: true,
-      rejectUnauthorized: false,
-    });
-
-    this.socket.on('connect', () => this.connected.set(true));
-    this.socket.on('disconnect', () => this.connected.set(false));
-
-    this.socket.on('vision:result', (result: AnalysisResponse) => {
-      this.lastResult.set(result);
-      this.lastError.set(null);
-      this.analyzing.set(false);
-    });
-
-    this.socket.on('vision:error', (err: { message: string }) => {
-      this.lastError.set(err.message);
-      this.analyzing.set(false);
-    });
+export class VisionSocketService {
+  private readonly _nestStatus = signal<'ONLINE' | 'OFFLINE'>('OFFLINE');
 
-    this.socket.on('monitor:update', (data: SystemMetrics) => {
-      this.metricsSubject.next(data);
-    });
-  }
-
-  /**
-   * Capture one frame from the given video element and emit it to NestJS
-   * via vision:analyze. Returns the raw Base64 string that was sent,
-   * so the caller can display it as a static snapshot.
-   *
-   * Lego 11: raw JPEG Base64, no quality reduction, no binary encoding.
-   */
-  snapAndSend(videoEl: HTMLVideoElement, batchId?: string): string | null {
-    if (!videoEl || videoEl.readyState < 2) return null;
-
-    this.captureCtx.drawImage(videoEl, 0, 0, 640, 640);
-
-    // Lego 11 hard rule: raw, uncompressed Base64 — no quality param
-    const rawBase64Frame: string = this.captureCanvas.toDataURL('image/jpeg');
-
-    this.analyzing.set(true);
-    this.lastResult.set(null);
-    this.lastError.set(null);
-
-    this.socket.emit('vision:analyze', {
-      frame: rawBase64Frame,
-      sourceLabel: 'webcam-snap',
-      batchId,
-    });
-
-    return rawBase64Frame;
-  }
-
-  /**
-   * Send a pre-existing Base64 image string (from gallery/file picker) to NestJS
-   * via vision:analyze. Same socket path as snapAndSend — one event, one result.
-   *
-   * Lego 11: caller must supply a full data-URL (data:image/...;base64,...).
-   */
-  sendBase64(base64DataUrl: string, batchId?: string): void {
-    this.analyzing.set(true);
-    this.lastResult.set(null);
-    this.lastError.set(null);
+  readonly nestStatus = this._nestStatus.asReadonly();
+  readonly connected = computed(() => this._nestStatus() === 'ONLINE');
+  readonly metrics$: Observable<SystemMetrics>;
 
-    this.socket.emit('vision:analyze', {
-      frame: base64DataUrl,
-      sourceLabel: 'gallery-image',
-      batchId,
+  constructor(private ngxSocket: NgxSocketService) {
+    interval(1000).subscribe(() => {
+      const live = ngxSocket.status === 'online' ? 'ONLINE' : 'OFFLINE';
+      if (this._nestStatus() !== live) this._nestStatus.set(live);
     });
-  }
-
-  clearResult(): void {
-    this.lastResult.set(null);
-    this.lastError.set(null);
-  }
 
-  ngOnDestroy() {
-    // Zombie socket — intentionally kept alive during app lifetime (Lego 06).
-    // Only disconnects on full Angular DI teardown.
-    this.socket.disconnect();
+    // responseSubject is private in the submodule — access via cast to avoid modifying protected code
+    const allResponses$ = (ngxSocket as any).responseSubject as Subject<any>;
+    this.metrics$ = allResponses$.pipe(
+      filter((msg: any) => msg?.serviceId === 'Surveillance' && msg?.operation === 'metricsUpdate'),
+      map((msg: any) => msg.payload as SystemMetrics),
+    );
   }
 }

+ 1 - 0
src/assets/language.packs/en_US.json

@@ -0,0 +1 @@
+{}

+ 1 - 0
src/assets/language.packs/ms_MY.json

@@ -0,0 +1 @@
+{}

+ 1 - 0
src/assets/language.packs/zh_Hans.json

@@ -0,0 +1 @@
+{}

+ 2 - 2
src/environments/environment.ts

@@ -1,7 +1,7 @@
 export const environment = {
   production: false,
-  apiUrl: 'https://localhost:3000',
-  nestWsUrl: 'https://localhost:3000',   // Socket.io host (monitor + vision + embedding)
+  apiUrl: 'http://localhost:3000',
+  nestWsUrl: 'http://localhost:3000',
 }
 // export const environment = {
 //   production: false,

+ 4 - 2
tsconfig.app.json

@@ -7,9 +7,11 @@
     "types": []
   },
   "include": [
-    "src/**/*.ts"
+    "src/**/*.ts",
+    "dependencies/dp-ui/dependencies/fisappmessagejsdist/**/*.ts"
   ],
   "exclude": [
-    "src/**/*.spec.ts"
+    "src/**/*.spec.ts",
+    "dependencies/**/*.spec.ts"
   ]
 }

+ 21 - 21
tsconfig.json

@@ -3,24 +3,32 @@
 {
   "compileOnSave": false,
   "compilerOptions": {
-    "strict": true,
-    "noImplicitOverride": true,
-    "noPropertyAccessFromIndexSignature": true,
-    "noImplicitReturns": true,
-    "noFallthroughCasesInSwitch": true,
+    "strict": false,
     "skipLibCheck": true,
-    "isolatedModules": true,
+    "isolatedModules": false,
+    "useDefineForClassFields": false,
     "experimentalDecorators": true,
     "importHelpers": true,
+    "resolveJsonModule": true,
     "target": "ES2022",
-    "module": "preserve"
+    "module": "preserve",
+    "baseUrl": ".",
+    "noImplicitAny": false,
+    "strictPropertyInitialization": false,
+    "paths": {
+      "dp-ui": ["dependencies/dp-ui/dp.module"],
+      "dp-ui/*": ["dependencies/dp-ui/*"],
+      "angularlib": ["dependencies/angularlib/angularlib.module"],
+      "angularlib/*": ["dependencies/angularlib/*"],
+      "assets/language.packs/*": ["src/assets/language.packs/*"]
+    }
   },
   "angularCompilerOptions": {
     "enableI18nLegacyMessageIdFormat": false,
-    "strictInjectionParameters": true,
-    "strictInputAccessModifiers": true,
-    "typeCheckHostBindings": true,
-    "strictTemplates": true
+    "strictInjectionParameters": false,
+    "strictInputAccessModifiers": false,
+    "typeCheckHostBindings": false,
+    "strictTemplates": false
   },
   "files": [],
   "references": [
@@ -30,13 +38,5 @@
     {
       "path": "./tsconfig.spec.json"
     }
-  ],
-  "paths": {
-    "dp-ui/*": [
-      "src/dependencies/dp-ui/*"
-    ],
-    "angularlib/*": [
-      "src/dependencies/angularlib/*"
-    ],
-  }
-}
+  ]
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů