|
@@ -1,8 +1,19 @@
|
|
|
import { Injectable } from '@angular/core';
|
|
import { Injectable } from '@angular/core';
|
|
|
import { Action, Selector, State, StateContext, NgxsOnInit } from '@ngxs/store';
|
|
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 {
|
|
import {
|
|
|
ClearAllHistory,
|
|
ClearAllHistory,
|
|
|
DeleteHistoryRecord,
|
|
DeleteHistoryRecord,
|
|
@@ -13,11 +24,11 @@ import {
|
|
|
} from './vision.actions';
|
|
} from './vision.actions';
|
|
|
|
|
|
|
|
export interface VisionStateModel {
|
|
export interface VisionStateModel {
|
|
|
- items: any[];
|
|
|
|
|
|
|
+ items: HistoryRecord[];
|
|
|
loading: boolean;
|
|
loading: boolean;
|
|
|
expandedBatchIds: string[];
|
|
expandedBatchIds: string[];
|
|
|
- currentInference: any | null;
|
|
|
|
|
- batchFrames: any[];
|
|
|
|
|
|
|
+ currentInference: InferenceFrame | null;
|
|
|
|
|
+ batchFrames: InferenceFrame[];
|
|
|
selectedFrameIndex: number | null;
|
|
selectedFrameIndex: number | null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -53,7 +64,7 @@ export class VisionState implements NgxsOnInit {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@Selector()
|
|
@Selector()
|
|
|
- static items(state: VisionStateModel): any[] {
|
|
|
|
|
|
|
+ static items(state: VisionStateModel): HistoryRecord[] {
|
|
|
return state.items;
|
|
return state.items;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -68,10 +79,15 @@ export class VisionState implements NgxsOnInit {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@Selector()
|
|
@Selector()
|
|
|
- static currentInference(state: VisionStateModel): any | null {
|
|
|
|
|
|
|
+ static currentInference(state: VisionStateModel): InferenceFrame | null {
|
|
|
return state.currentInference;
|
|
return state.currentInference;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @Selector()
|
|
|
|
|
+ static batchFrames(state: VisionStateModel): InferenceFrame[] {
|
|
|
|
|
+ return state.batchFrames;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@Action(SubmitBatchAnalysis)
|
|
@Action(SubmitBatchAnalysis)
|
|
|
submitBatchAnalysis(
|
|
submitBatchAnalysis(
|
|
|
ctx: StateContext<VisionStateModel>,
|
|
ctx: StateContext<VisionStateModel>,
|
|
@@ -80,32 +96,49 @@ export class VisionState implements NgxsOnInit {
|
|
|
ctx.patchState({ loading: true, currentInference: null });
|
|
ctx.patchState({ loading: true, currentInference: null });
|
|
|
const batchId = crypto.randomUUID();
|
|
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') {
|
|
if (payload.mode === 'remote') {
|
|
|
- return this.remoteInferenceService.analyze(file, undefined, batchId);
|
|
|
|
|
|
|
+ base = this.remoteInferenceService.analyze(file, undefined, batchId);
|
|
|
} else {
|
|
} 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) =>
|
|
switchMap((localFrame: InferenceFrame) =>
|
|
|
this.remoteInferenceService.saveExternalResult({
|
|
this.remoteInferenceService.saveExternalResult({
|
|
|
frame: localFrame.imageDataUrl,
|
|
frame: localFrame.imageDataUrl,
|
|
|
filename: file.name,
|
|
filename: file.name,
|
|
|
- batchId: batchId ?? 'unbatched_edge_studio',
|
|
|
|
|
|
|
+ batchId,
|
|
|
|
|
+ mode: payload.mode,
|
|
|
detections: localFrame.detections,
|
|
detections: localFrame.detections,
|
|
|
industrial_summary: localFrame.industrial_summary,
|
|
industrial_summary: localFrame.industrial_summary,
|
|
|
inference_ms: localFrame.inference_ms,
|
|
inference_ms: localFrame.inference_ms,
|
|
|
processing_ms: localFrame.processing_ms,
|
|
processing_ms: localFrame.processing_ms,
|
|
|
}).pipe(
|
|
}).pipe(
|
|
|
timeout(5000),
|
|
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 })),
|
|
tap(frame => ctx.patchState({ currentInference: frame })),
|
|
|
toArray(),
|
|
toArray(),
|
|
|
tap(frames => {
|
|
tap(frames => {
|
|
@@ -139,30 +172,28 @@ export class VisionState implements NgxsOnInit {
|
|
|
{ payload }: LoadGroupImages,
|
|
{ payload }: LoadGroupImages,
|
|
|
): Observable<void> {
|
|
): Observable<void> {
|
|
|
const { items } = ctx.getState();
|
|
const { items } = ctx.getState();
|
|
|
|
|
+ const targetBatchId = payload.batchId === '__ungrouped__' ? null : payload.batchId;
|
|
|
const pending = items.filter(
|
|
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);
|
|
if (!pending.length) return of(void 0);
|
|
|
|
|
|
|
|
return merge(
|
|
return merge(
|
|
|
- ...pending.map((item: any) =>
|
|
|
|
|
|
|
+ ...pending.map(item =>
|
|
|
this.remoteInferenceService.getImage(item.archive_id).pipe(
|
|
this.remoteInferenceService.getImage(item.archive_id).pipe(
|
|
|
- tap(res => {
|
|
|
|
|
|
|
+ tap((res: ImageRecord) => {
|
|
|
const current = ctx.getState().items;
|
|
const current = ctx.getState().items;
|
|
|
ctx.patchState({
|
|
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(() => {
|
|
catchError(() => {
|
|
|
const current = ctx.getState().items;
|
|
const current = ctx.getState().items;
|
|
|
ctx.patchState({
|
|
ctx.patchState({
|
|
|
- items: current.map((i: any) =>
|
|
|
|
|
|
|
+ items: current.map(i =>
|
|
|
i.archive_id === item.archive_id ? { ...i, imageDataUrl: 'error' } : 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> {
|
|
loadHistory(ctx: StateContext<VisionStateModel>): Observable<void> {
|
|
|
ctx.patchState({ loading: true });
|
|
ctx.patchState({ loading: true });
|
|
|
return this.remoteInferenceService.getHistory().pipe(
|
|
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 })),
|
|
tap(items => ctx.patchState({ items, loading: false })),
|
|
|
catchError(err => {
|
|
catchError(err => {
|
|
|
console.warn('⚠️ [Vault State] Edge network connection lost or timed out:', err.message || 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,
|
|
{ payload }: DeleteHistoryRecord,
|
|
|
): Observable<void> {
|
|
): Observable<void> {
|
|
|
const { items } = ctx.getState();
|
|
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(
|
|
return this.remoteInferenceService.deleteRecord(payload.id).pipe(
|
|
|
catchError(err => {
|
|
catchError(err => {
|
|
|
console.warn('[Vault State] Delete confirmation failed — record already removed from view:', err?.message || err);
|
|
console.warn('[Vault State] Delete confirmation failed — record already removed from view:', err?.message || err);
|