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
}