diff --git a/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.html b/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.html new file mode 100644 index 000000000..cb60cb396 --- /dev/null +++ b/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.html @@ -0,0 +1,126 @@ +@if (hasSuggestions) { +
+
+
+ + AI Suggestions + {{ pendingSuggestions.length }} +
+
+ @if (appliedCount > 0) { + {{ appliedCount }} applied + } + @if (rejectedCount > 0) { + {{ rejectedCount }} rejected + } + +
+
+ +
+
+

+ + AI has analyzed this document and suggests the following metadata. Review and apply or reject each suggestion. +

+
+ + +
+
+ +
+ @for (type of suggestionTypes; track type) { +
+
+ + {{ getTypeLabel(type) }} + {{ groupedSuggestions.get(type)?.length }} +
+ +
+ @for (suggestion of groupedSuggestions.get(type); track suggestion.id) { +
+
+
+
+
+ @if (suggestion.type === AISuggestionType.CustomField && suggestion.field_name) { + {{ suggestion.field_name }}: + } + {{ getLabel(suggestion) }} +
+
+ + + {{ getConfidenceLabel(suggestion.confidence) }} + + @if (suggestion.created_at) { + + + {{ suggestion.created_at | date:'short' }} + + } +
+
+ +
+ + +
+
+
+
+ } +
+
+ } +
+ + @if (pendingSuggestions.length === 0) { +
+ +

All suggestions have been processed

+
+ } +
+
+} diff --git a/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.scss b/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.scss new file mode 100644 index 000000000..edc1e41b5 --- /dev/null +++ b/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.scss @@ -0,0 +1,241 @@ +.ai-suggestions-panel { + border: 2px solid var(--bs-primary); + border-radius: 0.5rem; + overflow: hidden; + transition: all 0.3s ease; + + &:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; + } + + .card-header { + cursor: pointer; + user-select: none; + transition: background-color 0.2s ease; + padding: 0.75rem 1rem; + + &:hover { + background-color: var(--bs-primary) !important; + filter: brightness(1.1); + } + + .badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + } + } + + .card-body { + padding: 1rem; + } +} + +.suggestions-container { + max-height: 600px; + overflow-y: auto; + overflow-x: hidden; + + // Custom scrollbar styles + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; + + &:hover { + background: #555; + } + } +} + +.suggestion-group { + .suggestion-group-header { + padding-bottom: 0.5rem; + border-bottom: 1px solid #dee2e6; + + strong { + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .badge { + font-size: 0.7rem; + } + } + + .suggestion-items { + padding-left: 1.5rem; + } +} + +.suggestion-item { + border-left: 3px solid var(--bs-primary); + transition: all 0.3s ease; + position: relative; + + &:hover { + border-left-color: var(--bs-success); + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1); + transform: translateX(2px); + } + + &.suggestion-applying { + animation: applyAnimation 0.5s ease; + border-left-color: var(--bs-success); + background-color: rgba(25, 135, 84, 0.1); + } + + &.suggestion-rejecting { + animation: rejectAnimation 0.5s ease; + border-left-color: var(--bs-danger); + background-color: rgba(220, 53, 69, 0.1); + } + + .suggestion-value { + color: #333; + font-size: 0.95rem; + word-break: break-word; + } + + .confidence-badge { + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + display: inline-flex; + align-items: center; + + &.confidence-high { + background-color: #28a745; + color: white; + } + + &.confidence-medium { + background-color: #ffc107; + color: #333; + } + + &.confidence-low { + background-color: #dc3545; + color: white; + } + } + + .suggestion-actions { + .btn { + min-width: 36px; + padding: 0.25rem 0.5rem; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } + } +} + +// Animations +@keyframes applyAnimation { + 0% { + opacity: 1; + transform: translateX(0); + } + 50% { + opacity: 0.5; + transform: translateX(20px); + } + 100% { + opacity: 0; + transform: translateX(40px); + } +} + +@keyframes rejectAnimation { + 0% { + opacity: 1; + transform: translateX(0) rotate(0deg); + } + 50% { + opacity: 0.5; + transform: translateX(-20px) rotate(-5deg); + } + 100% { + opacity: 0; + transform: translateX(-40px) rotate(-10deg); + } +} + +// Responsive design +@media (max-width: 768px) { + .ai-suggestions-panel { + .card-header { + padding: 0.5rem 0.75rem; + flex-wrap: wrap; + + .badge { + font-size: 0.65rem; + padding: 0.2rem 0.4rem; + } + } + + .card-body { + padding: 0.75rem; + } + } + + .suggestions-container { + max-height: 400px; + } + + .suggestion-group { + .suggestion-items { + padding-left: 0.5rem; + } + } + + .suggestion-item { + .d-flex { + flex-direction: column; + gap: 0.5rem !important; + } + + .suggestion-actions { + width: 100%; + justify-content: flex-end; + } + } +} + +@media (max-width: 576px) { + .ai-suggestions-panel { + .card-header { + .d-flex { + flex-direction: column; + align-items: flex-start !important; + gap: 0.5rem; + } + } + } + + .suggestion-item { + .suggestion-value { + font-size: 0.875rem; + } + + .confidence-badge { + font-size: 0.7rem; + } + } +} diff --git a/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.spec.ts b/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.spec.ts new file mode 100644 index 000000000..d8ac95619 --- /dev/null +++ b/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.spec.ts @@ -0,0 +1,331 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { provideAnimations } from '@angular/platform-browser/animations' +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { of } from 'rxjs' +import { + AISuggestion, + AISuggestionStatus, + AISuggestionType, +} from 'src/app/data/ai-suggestion' +import { Correspondent } from 'src/app/data/correspondent' +import { DocumentType } from 'src/app/data/document-type' +import { StoragePath } from 'src/app/data/storage-path' +import { Tag } from 'src/app/data/tag' +import { CorrespondentService } from 'src/app/services/rest/correspondent.service' +import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' +import { DocumentTypeService } from 'src/app/services/rest/document-type.service' +import { StoragePathService } from 'src/app/services/rest/storage-path.service' +import { TagService } from 'src/app/services/rest/tag.service' +import { ToastService } from 'src/app/services/toast.service' +import { AiSuggestionsPanelComponent } from './ai-suggestions-panel.component' + +const mockTags: Tag[] = [ + { id: 1, name: 'Invoice', colour: '#ff0000', text_colour: '#ffffff' }, + { id: 2, name: 'Receipt', colour: '#00ff00', text_colour: '#000000' }, +] + +const mockCorrespondents: Correspondent[] = [ + { id: 1, name: 'Acme Corp' }, + { id: 2, name: 'TechStart LLC' }, +] + +const mockDocumentTypes: DocumentType[] = [ + { id: 1, name: 'Invoice' }, + { id: 2, name: 'Contract' }, +] + +const mockStoragePaths: StoragePath[] = [ + { id: 1, name: '/invoices', path: '/invoices' }, + { id: 2, name: '/contracts', path: '/contracts' }, +] + +const mockSuggestions: AISuggestion[] = [ + { + id: '1', + type: AISuggestionType.Tag, + value: 1, + confidence: 0.85, + status: AISuggestionStatus.Pending, + }, + { + id: '2', + type: AISuggestionType.Correspondent, + value: 1, + confidence: 0.75, + status: AISuggestionStatus.Pending, + }, + { + id: '3', + type: AISuggestionType.DocumentType, + value: 1, + confidence: 0.90, + status: AISuggestionStatus.Pending, + }, +] + +describe('AiSuggestionsPanelComponent', () => { + let component: AiSuggestionsPanelComponent + let fixture: ComponentFixture + let tagService: TagService + let correspondentService: CorrespondentService + let documentTypeService: DocumentTypeService + let storagePathService: StoragePathService + let customFieldsService: CustomFieldsService + let toastService: ToastService + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + AiSuggestionsPanelComponent, + NgbCollapseModule, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + provideAnimations(), + ], + }).compileComponents() + + tagService = TestBed.inject(TagService) + correspondentService = TestBed.inject(CorrespondentService) + documentTypeService = TestBed.inject(DocumentTypeService) + storagePathService = TestBed.inject(StoragePathService) + customFieldsService = TestBed.inject(CustomFieldsService) + toastService = TestBed.inject(ToastService) + + jest.spyOn(tagService, 'listAll').mockReturnValue( + of({ + all: mockTags.map((t) => t.id), + count: mockTags.length, + results: mockTags, + }) + ) + + jest.spyOn(correspondentService, 'listAll').mockReturnValue( + of({ + all: mockCorrespondents.map((c) => c.id), + count: mockCorrespondents.length, + results: mockCorrespondents, + }) + ) + + jest.spyOn(documentTypeService, 'listAll').mockReturnValue( + of({ + all: mockDocumentTypes.map((dt) => dt.id), + count: mockDocumentTypes.length, + results: mockDocumentTypes, + }) + ) + + jest.spyOn(storagePathService, 'listAll').mockReturnValue( + of({ + all: mockStoragePaths.map((sp) => sp.id), + count: mockStoragePaths.length, + results: mockStoragePaths, + }) + ) + + jest.spyOn(customFieldsService, 'listAll').mockReturnValue( + of({ + all: [], + count: 0, + results: [], + }) + ) + + fixture = TestBed.createComponent(AiSuggestionsPanelComponent) + component = fixture.componentInstance + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + it('should process suggestions on input change', () => { + component.suggestions = mockSuggestions + component.ngOnChanges({ + suggestions: { + currentValue: mockSuggestions, + previousValue: [], + firstChange: true, + isFirstChange: () => true, + }, + }) + + expect(component.pendingSuggestions.length).toBe(3) + expect(component.appliedCount).toBe(0) + expect(component.rejectedCount).toBe(0) + }) + + it('should group suggestions by type', () => { + component.suggestions = mockSuggestions + component.ngOnChanges({ + suggestions: { + currentValue: mockSuggestions, + previousValue: [], + firstChange: true, + isFirstChange: () => true, + }, + }) + + expect(component.groupedSuggestions.size).toBe(3) + expect(component.groupedSuggestions.get(AISuggestionType.Tag)?.length).toBe( + 1 + ) + expect( + component.groupedSuggestions.get(AISuggestionType.Correspondent)?.length + ).toBe(1) + expect( + component.groupedSuggestions.get(AISuggestionType.DocumentType)?.length + ).toBe(1) + }) + + it('should apply a suggestion', () => { + component.suggestions = mockSuggestions + component.ngOnChanges({ + suggestions: { + currentValue: mockSuggestions, + previousValue: [], + firstChange: true, + isFirstChange: () => true, + }, + }) + + const toastSpy = jest.spyOn(toastService, 'showInfo') + const applySpy = jest.spyOn(component.apply, 'emit') + + const suggestion = component.pendingSuggestions[0] + component.applySuggestion(suggestion) + + expect(suggestion.status).toBe(AISuggestionStatus.Applied) + expect(applySpy).toHaveBeenCalledWith(suggestion) + expect(toastSpy).toHaveBeenCalled() + }) + + it('should reject a suggestion', () => { + component.suggestions = mockSuggestions + component.ngOnChanges({ + suggestions: { + currentValue: mockSuggestions, + previousValue: [], + firstChange: true, + isFirstChange: () => true, + }, + }) + + const toastSpy = jest.spyOn(toastService, 'showInfo') + const rejectSpy = jest.spyOn(component.reject, 'emit') + + const suggestion = component.pendingSuggestions[0] + component.rejectSuggestion(suggestion) + + expect(suggestion.status).toBe(AISuggestionStatus.Rejected) + expect(rejectSpy).toHaveBeenCalledWith(suggestion) + expect(toastSpy).toHaveBeenCalled() + }) + + it('should apply all suggestions', () => { + component.suggestions = mockSuggestions + component.ngOnChanges({ + suggestions: { + currentValue: mockSuggestions, + previousValue: [], + firstChange: true, + isFirstChange: () => true, + }, + }) + + const toastSpy = jest.spyOn(toastService, 'showInfo') + const applySpy = jest.spyOn(component.apply, 'emit') + + component.applyAll() + + expect(applySpy).toHaveBeenCalledTimes(3) + expect(toastSpy).toHaveBeenCalled() + }) + + it('should reject all suggestions', () => { + component.suggestions = mockSuggestions + component.ngOnChanges({ + suggestions: { + currentValue: mockSuggestions, + previousValue: [], + firstChange: true, + isFirstChange: () => true, + }, + }) + + const toastSpy = jest.spyOn(toastService, 'showInfo') + const rejectSpy = jest.spyOn(component.reject, 'emit') + + component.rejectAll() + + expect(rejectSpy).toHaveBeenCalledTimes(3) + expect(toastSpy).toHaveBeenCalled() + }) + + it('should return correct confidence class', () => { + expect(component.getConfidenceClass(0.9)).toBe('confidence-high') + expect(component.getConfidenceClass(0.7)).toBe('confidence-medium') + expect(component.getConfidenceClass(0.5)).toBe('confidence-low') + }) + + it('should return correct confidence label', () => { + expect(component.getConfidenceLabel(0.85)).toContain('85%') + expect(component.getConfidenceLabel(0.65)).toContain('65%') + expect(component.getConfidenceLabel(0.45)).toContain('45%') + }) + + it('should toggle collapse', () => { + expect(component.isCollapsed).toBe(false) + component.toggleCollapse() + expect(component.isCollapsed).toBe(true) + component.toggleCollapse() + expect(component.isCollapsed).toBe(false) + }) + + it('should respect disabled state', () => { + component.suggestions = mockSuggestions + component.disabled = true + component.ngOnChanges({ + suggestions: { + currentValue: mockSuggestions, + previousValue: [], + firstChange: true, + isFirstChange: () => true, + }, + }) + + const applySpy = jest.spyOn(component.apply, 'emit') + const suggestion = component.pendingSuggestions[0] + component.applySuggestion(suggestion) + + expect(applySpy).not.toHaveBeenCalled() + }) + + it('should not render panel when there are no suggestions', () => { + component.suggestions = [] + fixture.detectChanges() + + expect(component.hasSuggestions).toBe(false) + }) + + it('should render panel when there are suggestions', () => { + component.suggestions = mockSuggestions + component.ngOnChanges({ + suggestions: { + currentValue: mockSuggestions, + previousValue: [], + firstChange: true, + isFirstChange: () => true, + }, + }) + fixture.detectChanges() + + expect(component.hasSuggestions).toBe(true) + }) +}) diff --git a/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.ts b/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.ts new file mode 100644 index 000000000..770aa24ac --- /dev/null +++ b/src-ui/src/app/components/ai-suggestions-panel/ai-suggestions-panel.component.ts @@ -0,0 +1,381 @@ +import { CommonModule } from '@angular/common' +import { + trigger, + state, + style, + transition, + animate, +} from '@angular/animations' +import { + Component, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, + inject, +} from '@angular/core' +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { + AISuggestion, + AISuggestionStatus, + AISuggestionType, +} from 'src/app/data/ai-suggestion' +import { Correspondent } from 'src/app/data/correspondent' +import { CustomField } from 'src/app/data/custom-field' +import { DocumentType } from 'src/app/data/document-type' +import { StoragePath } from 'src/app/data/storage-path' +import { Tag } from 'src/app/data/tag' +import { CorrespondentService } from 'src/app/services/rest/correspondent.service' +import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' +import { DocumentTypeService } from 'src/app/services/rest/document-type.service' +import { StoragePathService } from 'src/app/services/rest/storage-path.service' +import { TagService } from 'src/app/services/rest/tag.service' +import { ToastService } from 'src/app/services/toast.service' + +@Component({ + selector: 'pngx-ai-suggestions-panel', + templateUrl: './ai-suggestions-panel.component.html', + styleUrls: ['./ai-suggestions-panel.component.scss'], + imports: [ + CommonModule, + NgbCollapseModule, + NgxBootstrapIconsModule, + ], + animations: [ + trigger('slideIn', [ + transition(':enter', [ + style({ transform: 'translateY(-20px)', opacity: 0 }), + animate('300ms ease-out', style({ transform: 'translateY(0)', opacity: 1 })), + ]), + ]), + trigger('fadeInOut', [ + transition(':enter', [ + style({ opacity: 0, transform: 'scale(0.95)' }), + animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })), + ]), + transition(':leave', [ + animate('200ms ease-in', style({ opacity: 0, transform: 'scale(0.95)' })), + ]), + ]), + ], +}) +export class AiSuggestionsPanelComponent implements OnChanges { + private tagService = inject(TagService) + private correspondentService = inject(CorrespondentService) + private documentTypeService = inject(DocumentTypeService) + private storagePathService = inject(StoragePathService) + private customFieldsService = inject(CustomFieldsService) + private toastService = inject(ToastService) + + @Input() + suggestions: AISuggestion[] = [] + + @Input() + disabled: boolean = false + + @Output() + apply = new EventEmitter() + + @Output() + reject = new EventEmitter() + + public isCollapsed = false + public pendingSuggestions: AISuggestion[] = [] + public groupedSuggestions: Map = new Map() + public appliedCount = 0 + public rejectedCount = 0 + + private tags: Tag[] = [] + private correspondents: Correspondent[] = [] + private documentTypes: DocumentType[] = [] + private storagePaths: StoragePath[] = [] + private customFields: CustomField[] = [] + + public AISuggestionType = AISuggestionType + public AISuggestionStatus = AISuggestionStatus + + ngOnChanges(changes: SimpleChanges): void { + if (changes['suggestions']) { + this.processSuggestions() + this.loadMetadata() + } + } + + private processSuggestions(): void { + this.pendingSuggestions = this.suggestions.filter( + (s) => s.status === AISuggestionStatus.Pending + ) + this.appliedCount = this.suggestions.filter( + (s) => s.status === AISuggestionStatus.Applied + ).length + this.rejectedCount = this.suggestions.filter( + (s) => s.status === AISuggestionStatus.Rejected + ).length + + // Group suggestions by type + this.groupedSuggestions.clear() + this.pendingSuggestions.forEach((suggestion) => { + const group = this.groupedSuggestions.get(suggestion.type) || [] + group.push(suggestion) + this.groupedSuggestions.set(suggestion.type, group) + }) + } + + private loadMetadata(): void { + // Load tags if needed + const tagSuggestions = this.pendingSuggestions.filter( + (s) => s.type === AISuggestionType.Tag + ) + if (tagSuggestions.length > 0) { + this.tagService.listAll().subscribe((tags) => { + this.tags = tags.results + this.updateSuggestionLabels() + }) + } + + // Load correspondents if needed + const correspondentSuggestions = this.pendingSuggestions.filter( + (s) => s.type === AISuggestionType.Correspondent + ) + if (correspondentSuggestions.length > 0) { + this.correspondentService.listAll().subscribe((correspondents) => { + this.correspondents = correspondents.results + this.updateSuggestionLabels() + }) + } + + // Load document types if needed + const documentTypeSuggestions = this.pendingSuggestions.filter( + (s) => s.type === AISuggestionType.DocumentType + ) + if (documentTypeSuggestions.length > 0) { + this.documentTypeService.listAll().subscribe((documentTypes) => { + this.documentTypes = documentTypes.results + this.updateSuggestionLabels() + }) + } + + // Load storage paths if needed + const storagePathSuggestions = this.pendingSuggestions.filter( + (s) => s.type === AISuggestionType.StoragePath + ) + if (storagePathSuggestions.length > 0) { + this.storagePathService.listAll().subscribe((storagePaths) => { + this.storagePaths = storagePaths.results + this.updateSuggestionLabels() + }) + } + + // Load custom fields if needed + const customFieldSuggestions = this.pendingSuggestions.filter( + (s) => s.type === AISuggestionType.CustomField + ) + if (customFieldSuggestions.length > 0) { + this.customFieldsService.listAll().subscribe((customFields) => { + this.customFields = customFields.results + this.updateSuggestionLabels() + }) + } + } + + private updateSuggestionLabels(): void { + this.pendingSuggestions.forEach((suggestion) => { + if (!suggestion.label) { + suggestion.label = this.getLabel(suggestion) + } + }) + } + + public getLabel(suggestion: AISuggestion): string { + if (suggestion.label) { + return suggestion.label + } + + switch (suggestion.type) { + case AISuggestionType.Tag: + const tag = this.tags.find((t) => t.id === suggestion.value) + return tag ? tag.name : `Tag #${suggestion.value}` + + case AISuggestionType.Correspondent: + const correspondent = this.correspondents.find( + (c) => c.id === suggestion.value + ) + return correspondent + ? correspondent.name + : `Correspondent #${suggestion.value}` + + case AISuggestionType.DocumentType: + const docType = this.documentTypes.find( + (dt) => dt.id === suggestion.value + ) + return docType ? docType.name : `Document Type #${suggestion.value}` + + case AISuggestionType.StoragePath: + const storagePath = this.storagePaths.find( + (sp) => sp.id === suggestion.value + ) + return storagePath ? storagePath.name : `Storage Path #${suggestion.value}` + + case AISuggestionType.CustomField: + return suggestion.field_name || 'Custom Field' + + case AISuggestionType.Date: + return new Date(suggestion.value).toLocaleDateString() + + case AISuggestionType.Title: + return suggestion.value + + default: + return String(suggestion.value) + } + } + + public getTypeLabel(type: AISuggestionType): string { + switch (type) { + case AISuggestionType.Tag: + return $localize`Tags` + case AISuggestionType.Correspondent: + return $localize`Correspondent` + case AISuggestionType.DocumentType: + return $localize`Document Type` + case AISuggestionType.StoragePath: + return $localize`Storage Path` + case AISuggestionType.CustomField: + return $localize`Custom Field` + case AISuggestionType.Date: + return $localize`Date` + case AISuggestionType.Title: + return $localize`Title` + default: + return String(type) + } + } + + public getTypeIcon(type: AISuggestionType): string { + switch (type) { + case AISuggestionType.Tag: + return 'tag' + case AISuggestionType.Correspondent: + return 'person' + case AISuggestionType.DocumentType: + return 'file-earmark-text' + case AISuggestionType.StoragePath: + return 'folder' + case AISuggestionType.CustomField: + return 'input-cursor-text' + case AISuggestionType.Date: + return 'calendar' + case AISuggestionType.Title: + return 'pencil' + default: + return 'lightbulb' + } + } + + public getConfidenceClass(confidence: number): string { + if (confidence >= 0.8) { + return 'confidence-high' + } else if (confidence >= 0.6) { + return 'confidence-medium' + } else { + return 'confidence-low' + } + } + + public getConfidenceLabel(confidence: number): string { + const percentage = Math.round(confidence * 100) + if (confidence >= 0.8) { + return $localize`High (${percentage}%)` + } else if (confidence >= 0.6) { + return $localize`Medium (${percentage}%)` + } else { + return $localize`Low (${percentage}%)` + } + } + + public getConfidenceIcon(confidence: number): string { + if (confidence >= 0.8) { + return 'check-circle-fill' + } else if (confidence >= 0.6) { + return 'exclamation-circle' + } else { + return 'question-circle' + } + } + + public applySuggestion(suggestion: AISuggestion): void { + if (this.disabled) { + return + } + + suggestion.status = AISuggestionStatus.Applied + this.apply.emit(suggestion) + this.processSuggestions() + + this.toastService.showInfo( + $localize`Applied AI suggestion: ${this.getLabel(suggestion)}` + ) + } + + public rejectSuggestion(suggestion: AISuggestion): void { + if (this.disabled) { + return + } + + suggestion.status = AISuggestionStatus.Rejected + this.reject.emit(suggestion) + this.processSuggestions() + + this.toastService.showInfo( + $localize`Rejected AI suggestion: ${this.getLabel(suggestion)}` + ) + } + + public applyAll(): void { + if (this.disabled) { + return + } + + const count = this.pendingSuggestions.length + this.pendingSuggestions.forEach((suggestion) => { + suggestion.status = AISuggestionStatus.Applied + this.apply.emit(suggestion) + }) + this.processSuggestions() + + this.toastService.showInfo( + $localize`Applied ${count} AI suggestions` + ) + } + + public rejectAll(): void { + if (this.disabled) { + return + } + + const count = this.pendingSuggestions.length + this.pendingSuggestions.forEach((suggestion) => { + suggestion.status = AISuggestionStatus.Rejected + this.reject.emit(suggestion) + }) + this.processSuggestions() + + this.toastService.showInfo( + $localize`Rejected ${count} AI suggestions` + ) + } + + public toggleCollapse(): void { + this.isCollapsed = !this.isCollapsed + } + + public get hasSuggestions(): boolean { + return this.pendingSuggestions.length > 0 + } + + public get suggestionTypes(): AISuggestionType[] { + return Array.from(this.groupedSuggestions.keys()) + } +} diff --git a/src-ui/src/app/data/ai-suggestion.ts b/src-ui/src/app/data/ai-suggestion.ts new file mode 100644 index 000000000..f37cbf972 --- /dev/null +++ b/src-ui/src/app/data/ai-suggestion.ts @@ -0,0 +1,32 @@ +export enum AISuggestionType { + Tag = 'tag', + Correspondent = 'correspondent', + DocumentType = 'document_type', + StoragePath = 'storage_path', + CustomField = 'custom_field', + Date = 'date', + Title = 'title', +} + +export enum AISuggestionStatus { + Pending = 'pending', + Applied = 'applied', + Rejected = 'rejected', +} + +export interface AISuggestion { + id: string + type: AISuggestionType + value: any + confidence: number + status: AISuggestionStatus + label?: string + field_name?: string // For custom fields + created_at?: Date +} + +export interface AIDocumentSuggestions { + document_id: number + suggestions: AISuggestion[] + generated_at: Date +}