From dd5d93e84a0794004ebbec4017e38ca78b493beb Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:32:10 -0700 Subject: [PATCH] Frontend AI suggestion workflow stuff --- .../workflow-edit-dialog.component.html | 39 ++++++ .../workflow-edit-dialog.component.spec.ts | 126 +++++++++++++++++- .../workflow-edit-dialog.component.ts | 62 +++++++++ src-ui/src/app/data/workflow-action.ts | 17 +++ 4 files changed, 242 insertions(+), 2 deletions(-) diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html index 3827c41e6..af8e9a72d 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -462,6 +462,45 @@ } + @case (WorkflowActionType.ApplyAiSuggestions) { +
+
+

The document will be sent to the configured AI service for suggestions. Consider costs and privacy.

+ +
+
+
+
+ +
+
+ +
+
+ } } diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts index dfe713c6d..d9c365673 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts @@ -22,6 +22,7 @@ import { } from 'src/app/data/matching-model' import { Workflow } from 'src/app/data/workflow' import { + AISuggestionField, WorkflowAction, WorkflowActionType, } from 'src/app/data/workflow-action' @@ -49,6 +50,7 @@ import { TagsComponent } from '../../input/tags/tags.component' import { TextComponent } from '../../input/text/text.component' import { EditDialogMode } from '../edit-dialog.component' import { + AI_SUGGESTION_FIELD_OPTIONS, DOCUMENT_SOURCE_OPTIONS, SCHEDULE_DATE_FIELD_OPTIONS, TriggerFilterType, @@ -239,14 +241,15 @@ describe('WorkflowEditDialogComponent', () => { SCHEDULE_DATE_FIELD_OPTIONS ) - // Email disabled + // Email, remote OCR and AI all disabled jest.spyOn(settingsService, 'get').mockReturnValue(false) component.ngOnInit() expect(component.actionTypeOptions).toEqual( WORKFLOW_ACTION_OPTIONS.filter( (a) => a.id !== WorkflowActionType.Email && - a.id !== WorkflowActionType.RemoteOcr + a.id !== WorkflowActionType.RemoteOcr && + a.id !== WorkflowActionType.ApplyAiSuggestions ) ) }) @@ -344,6 +347,125 @@ describe('WorkflowEditDialogComponent', () => { ) }) + it('should offer apply AI suggestions unless every trigger is consumption', () => { + jest.spyOn(settingsService, 'get').mockReturnValue(true) + + // Consumption runs before the document has been parsed, so there would be + // no content to make suggestions from + component.object = { + name: 'Workflow 1', + order: 0, + enabled: true, + triggers: [{ type: WorkflowTriggerType.Consumption }], + actions: [], + } as Workflow + component.ngOnInit() + expect(component.actionTypeOptions.map((a) => a.id)).not.toContain( + WorkflowActionType.ApplyAiSuggestions + ) + + // A second, usable trigger is enough + component.object = { + name: 'Workflow 2', + order: 0, + enabled: true, + triggers: [ + { type: WorkflowTriggerType.Consumption }, + { type: WorkflowTriggerType.DocumentAdded }, + ], + actions: [], + } as Workflow + component.ngOnInit() + expect(component.actionTypeOptions.map((a) => a.id)).toContain( + WorkflowActionType.ApplyAiSuggestions + ) + }) + + it('should keep apply AI suggestions listed when an action already uses it', () => { + jest.spyOn(settingsService, 'get').mockReturnValue(true) + + // Otherwise changing the trigger would silently blank the selection + component.object = { + name: 'Workflow 1', + order: 0, + enabled: true, + triggers: [{ type: WorkflowTriggerType.Consumption }], + actions: [{ type: WorkflowActionType.ApplyAiSuggestions }], + } as Workflow + component.ngOnInit() + + expect(component.actionTypeOptions.map((a) => a.id)).toContain( + WorkflowActionType.ApplyAiSuggestions + ) + }) + + it('should not offer apply AI suggestions when AI is disabled', () => { + jest + .spyOn(settingsService, 'get') + .mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED) + + component.object = { + name: 'Workflow 1', + order: 0, + enabled: true, + triggers: [{ type: WorkflowTriggerType.DocumentAdded }], + actions: [], + } as Workflow + component.ngOnInit() + + expect(component.actionTypeOptions.map((a) => a.id)).not.toContain( + WorkflowActionType.ApplyAiSuggestions + ) + }) + + it('should create form fields for apply AI suggestions options', () => { + component.object = { + name: 'Workflow 1', + order: 0, + enabled: true, + triggers: [{ type: WorkflowTriggerType.DocumentAdded }], + actions: [ + { + type: WorkflowActionType.ApplyAiSuggestions, + ai_suggestion_fields: [ + AISuggestionField.Title, + AISuggestionField.Tags, + ], + ai_create_missing: true, + ai_overwrite_existing: true, + }, + ], + } as Workflow + component.ngOnInit() + + const action = component.actionFields.at(0) + expect(action.get('ai_suggestion_fields').value).toEqual([ + AISuggestionField.Title, + AISuggestionField.Tags, + ]) + expect(action.get('ai_create_missing').value).toBeTruthy() + expect(action.get('ai_overwrite_existing').value).toBeTruthy() + expect(component.aiSuggestionFieldOptions).toEqual( + AI_SUGGESTION_FIELD_OPTIONS + ) + }) + + it('should default apply AI suggestions options on a new action', () => { + component.object = { + name: 'Workflow 1', + order: 0, + enabled: true, + triggers: [{ type: WorkflowTriggerType.DocumentAdded }], + actions: [], + } as Workflow + component.addAction() + + const action = component.actionFields.at(component.actionFields.length - 1) + expect(action.get('ai_suggestion_fields').value).toEqual([]) + expect(action.get('ai_create_missing').value).toBeFalsy() + expect(action.get('ai_overwrite_existing').value).toBeFalsy() + }) + it('should support add and remove triggers and actions', () => { component.object = workflow component.addTrigger() diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index ed66e61dc..bb8cc58fa 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -30,6 +30,7 @@ import { StoragePath } from 'src/app/data/storage-path' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { Workflow } from 'src/app/data/workflow' import { + AISuggestionField, WorkflowAction, WorkflowActionType, } from 'src/app/data/workflow-action' @@ -152,6 +153,37 @@ export const WORKFLOW_ACTION_OPTIONS = [ id: WorkflowActionType.RemoteOcr, name: $localize`Remote OCR`, }, + { + id: WorkflowActionType.ApplyAiSuggestions, + name: $localize`Apply AI suggestions`, + }, +] + +export const AI_SUGGESTION_FIELD_OPTIONS = [ + { + id: AISuggestionField.Title, + name: $localize`Title`, + }, + { + id: AISuggestionField.Tags, + name: $localize`Tags`, + }, + { + id: AISuggestionField.Correspondent, + name: $localize`Correspondent`, + }, + { + id: AISuggestionField.DocumentType, + name: $localize`Document type`, + }, + { + id: AISuggestionField.StoragePath, + name: $localize`Storage path`, + }, + { + id: AISuggestionField.Created, + name: $localize`Created date`, + }, ] export enum TriggerFilterType { @@ -576,6 +608,24 @@ export class WorkflowEditDialogComponent allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr) } + // Only available after consumption. Unlike remote OCR this is hidden only + // once every trigger is consumption, so it stays offered on a workflow + // that has no triggers yet. + const aiSuggestionsUsable = + this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) && + (!formWorkflow?.triggers?.length || + formWorkflow.triggers.some( + (trigger) => trigger.type !== WorkflowTriggerType.Consumption + ) || + formWorkflow.actions?.some( + (action) => action.type === WorkflowActionType.ApplyAiSuggestions + )) + if (!aiSuggestionsUsable) { + allowed = allowed.filter( + (a) => a.id !== WorkflowActionType.ApplyAiSuggestions + ) + } + if ( this.allowedActionTypes?.length === allowed.length && this.allowedActionTypes.every((a, i) => a.id === allowed[i].id) @@ -1227,6 +1277,11 @@ export class WorkflowEditDialogComponent passwords: new FormControl( this.formatPasswords(action.passwords ?? []) ), + ai_suggestion_fields: new FormControl( + action.ai_suggestion_fields ?? [] + ), + ai_create_missing: new FormControl(!!action.ai_create_missing), + ai_overwrite_existing: new FormControl(!!action.ai_overwrite_existing), }), { emitEvent } ) @@ -1316,6 +1371,10 @@ export class WorkflowEditDialogComponent return this.actionTypeOptions.find((t) => t.id === type)?.name ?? '' } + get aiSuggestionFieldOptions() { + return AI_SUGGESTION_FIELD_OPTIONS + } + addAction() { if (!this.object) { this.object = Object.assign({}, this.objectForm.value) @@ -1369,6 +1428,9 @@ export class WorkflowEditDialogComponent include_document: false, }, passwords: [], + ai_suggestion_fields: [], + ai_create_missing: false, + ai_overwrite_existing: false, } this.object.actions.push(action) this.createActionField(action) diff --git a/src-ui/src/app/data/workflow-action.ts b/src-ui/src/app/data/workflow-action.ts index 09ef5418f..abcb590c1 100644 --- a/src-ui/src/app/data/workflow-action.ts +++ b/src-ui/src/app/data/workflow-action.ts @@ -8,6 +8,17 @@ export enum WorkflowActionType { PasswordRemoval = 5, MoveToTrash = 6, RemoteOcr = 7, + ApplyAiSuggestions = 8, +} + +// see src/documents/models.py AISuggestionField +export enum AISuggestionField { + Title = 'title', + Tags = 'tags', + Correspondent = 'correspondent', + DocumentType = 'document_type', + StoragePath = 'storage_path', + Created = 'created', } export interface WorkflowActionEmail extends ObjectWithId { @@ -102,4 +113,10 @@ export interface WorkflowAction extends ObjectWithId { webhook?: WorkflowActionWebhook passwords?: string[] + + ai_suggestion_fields?: AISuggestionField[] + + ai_create_missing?: boolean + + ai_overwrite_existing?: boolean }