From f52bcf503c4de669fde42f48e6dc6621dc8b5fbe Mon Sep 17 00:00:00 2001 From: onesketchyguy Date: Thu, 18 Jun 2026 16:45:13 -0700 Subject: [PATCH 1/3] Added ollama component --- .../services/analytics-data-source.service.ts | 5 +- .../client-family-member-dialog.component.ts | 3 +- .../client-action-notifier.service.spec.ts | 3 +- .../take-survey/take-survey.component.spec.ts | 3 +- .../add-family-member.component.ts | 3 +- .../edit-family-member.component.ts | 3 +- .../create-client.component.spec.ts | 27 +- .../popover/popover.service.ts | 3 +- src/app/core/error-handler/README.md | 3 +- .../edit-payment-type.component.ts | 3 +- .../floating-rate-period-dialog.component.ts | 3 +- src/app/settings/settings.service.ts | 26 + src/app/shared/services/ollama.service.ts | 128 ++++ .../external-services.component.html | 18 + .../external-services.component.ts | 4 +- .../edit-ollama/edit-ollama.component.html | 65 ++ .../edit-ollama/edit-ollama.component.scss | 7 + .../edit-ollama/edit-ollama.component.ts | 116 ++++ .../ollama/ollama.component.html | 40 ++ .../ollama/ollama.component.scss | 27 + .../ollama/ollama.component.ts | 77 +++ .../column-dialog/column-dialog.component.ts | 3 +- .../workflow-diagram.component.ts | 5 +- .../create-report.component.html | 58 ++ .../create-report.component.scss | 55 ++ .../create-report/create-report.component.ts | 48 +- src/app/system/system-routing.module.ts | 17 + src/assets/env.js | 7 + src/assets/translations/en-US.json | 620 +++++++++--------- src/environments/environment.prod.ts | 8 + src/environments/environment.ts | 8 + 31 files changed, 1047 insertions(+), 349 deletions(-) create mode 100644 src/app/shared/services/ollama.service.ts create mode 100644 src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.html create mode 100644 src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.scss create mode 100644 src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.ts create mode 100644 src/app/system/external-services/ollama/ollama.component.html create mode 100644 src/app/system/external-services/ollama/ollama.component.scss create mode 100644 src/app/system/external-services/ollama/ollama.component.ts diff --git a/src/app/analytics/services/analytics-data-source.service.ts b/src/app/analytics/services/analytics-data-source.service.ts index deab5e8cf5..93db26db87 100644 --- a/src/app/analytics/services/analytics-data-source.service.ts +++ b/src/app/analytics/services/analytics-data-source.service.ts @@ -250,10 +250,7 @@ export class AnalyticsDataSourceService { return request$; } - private extractAmountPair(response: any[], reportName: string): [ - number, - number - ] { + private extractAmountPair(response: any[], reportName: string): [number, number] { const firstRow = response?.[0] || {}; const numericEntries = Object.entries(firstRow) .map( diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts index 8435963584..6618cca483 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts @@ -128,8 +128,7 @@ export class ClientFamilyMemberDialogComponent implements OnInit { ], qualification: [''], age: [ - { value: '', disabled: true } - ], + { value: '', disabled: true }], isDependent: [''], relationshipId: [ '', diff --git a/src/app/clients/clients-view/client-actions/client-action-notifier.service.spec.ts b/src/app/clients/clients-view/client-actions/client-action-notifier.service.spec.ts index 8ba69d1e00..db75660e8f 100644 --- a/src/app/clients/clients-view/client-actions/client-action-notifier.service.spec.ts +++ b/src/app/clients/clients-view/client-actions/client-action-notifier.service.spec.ts @@ -22,8 +22,7 @@ describe('ClientActionNotifierService', () => { ClientActionNotifierService, { provide: MatSnackBar, useValue: snackBar }, { provide: TranslateService, useValue: translateService }, - { provide: Router, useValue: router } - ] + { provide: Router, useValue: router }] }); service = TestBed.inject(ClientActionNotifierService); diff --git a/src/app/clients/clients-view/client-actions/take-survey/take-survey.component.spec.ts b/src/app/clients/clients-view/client-actions/take-survey/take-survey.component.spec.ts index 0a0da540ec..fc648b06a1 100644 --- a/src/app/clients/clients-view/client-actions/take-survey/take-survey.component.spec.ts +++ b/src/app/clients/clients-view/client-actions/take-survey/take-survey.component.spec.ts @@ -26,8 +26,7 @@ describe('TakeSurveyComponent', () => { id: 10, name: 'Test Survey', questionDatas: [ - { id: 1, componentKey: 'A', answer: { id: 2, value: '5' } } - ] + { id: 1, componentKey: 'A', answer: { id: 2, value: '5' } }] } ]; diff --git a/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts b/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts index 146e54a6b4..6c16a49cc1 100644 --- a/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts +++ b/src/app/clients/clients-view/family-members-tab/add-family-member/add-family-member.component.ts @@ -117,8 +117,7 @@ export class AddFamilyMemberComponent implements OnInit { ], qualification: [''], age: [ - { value: '', disabled: true } - ], + { value: '', disabled: true }], isDependent: [''], relationshipId: [ '', diff --git a/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.ts b/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.ts index 7c86506df9..a79e469ccb 100644 --- a/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.ts +++ b/src/app/clients/clients-view/family-members-tab/edit-family-member/edit-family-member.component.ts @@ -118,8 +118,7 @@ export class EditFamilyMemberComponent implements OnInit { ], qualification: [familyMember.qualification], age: [ - { value: familyMember.age, disabled: true } - ], + { value: familyMember.age, disabled: true }], isDependent: [familyMember.isDependent], relationshipId: [ familyMember.relationshipId, diff --git a/src/app/clients/create-client/create-client.component.spec.ts b/src/app/clients/create-client/create-client.component.spec.ts index d2b4ab4a3d..6cc6a454d2 100644 --- a/src/app/clients/create-client/create-client.component.spec.ts +++ b/src/app/clients/create-client/create-client.component.spec.ts @@ -43,11 +43,9 @@ describe('CreateClientComponent - Integration Tests', () => { } ], officeOptions: [ - { id: 1, name: 'Head Office' } - ], + { id: 1, name: 'Head Office' }], staffOptions: [ - { id: 1, displayName: 'John Doe' } - ], + { id: 1, displayName: 'John Doe' }], savingsProductOptions: [] as any[], genderOptions: [] as any[], clientTypeOptions: [] as any[], @@ -62,8 +60,7 @@ describe('CreateClientComponent - Integration Tests', () => { const mockAddressConfig = { addressTypeIdOptions: [ - { id: 1, name: 'Home' } - ] + { id: 1, name: 'Home' }] }; beforeEach(async () => { @@ -209,8 +206,7 @@ describe('CreateClientComponent - Integration Tests', () => { Object.defineProperty(component.clientFamilyMembersStep, 'familyMembers', { get: jest.fn(() => ({ familyMembers: [ - { firstName: 'Jane', relationship: 'Spouse' } - ] + { firstName: 'Jane', relationship: 'Spouse' }] })), configurable: true }); @@ -240,8 +236,7 @@ describe('CreateClientComponent - Integration Tests', () => { Object.defineProperty(component.clientFamilyMembersStep, 'familyMembers', { get: jest.fn(() => ({ familyMembers: [ - { firstName: 'Jane', relationship: 'Spouse' } - ] + { firstName: 'Jane', relationship: 'Spouse' }] })), configurable: true }); @@ -251,8 +246,7 @@ describe('CreateClientComponent - Integration Tests', () => { Object.defineProperty(component.clientAddressStep, 'address', { get: jest.fn(() => ({ address: [ - { addressTypeId: 1, street: '123 Main St' } - ] + { addressTypeId: 1, street: '123 Main St' }] })), configurable: true }); @@ -379,8 +373,7 @@ describe('CreateClientComponent - Integration Tests', () => { Object.defineProperty(component.clientAddressStep, 'address', { get: jest.fn(() => ({ address: [ - { addressTypeId: 1, street: '123 Main St', city: 'New York' } - ] + { addressTypeId: 1, street: '123 Main St', city: 'New York' }] })), configurable: true }); @@ -525,8 +518,7 @@ describe('CreateClientComponent - Integration Tests', () => { Object.defineProperty(component.clientFamilyMembersStep, 'familyMembers', { get: jest.fn(() => ({ familyMembers: [ - { firstName: 'John', relationship: 'Spouse' } - ] + { firstName: 'John', relationship: 'Spouse' }] })), configurable: true }); @@ -537,8 +529,7 @@ describe('CreateClientComponent - Integration Tests', () => { Object.defineProperty(component.clientAddressStep, 'address', { get: jest.fn(() => ({ address: [ - { addressTypeId: 1, street: '456 Oak Ave', city: 'Boston' } - ] + { addressTypeId: 1, street: '456 Oak Ave', city: 'Boston' }] })), configurable: true }); diff --git a/src/app/configuration-wizard/popover/popover.service.ts b/src/app/configuration-wizard/popover/popover.service.ts index 28d7ea118d..4bf695942f 100644 --- a/src/app/configuration-wizard/popover/popover.service.ts +++ b/src/app/configuration-wizard/popover/popover.service.ts @@ -212,8 +212,7 @@ export class PopoverService { Injector.create({ parent: this.injector, providers: [ - { provide: PopoverRef, useValue: popoverRef } - ] + { provide: PopoverRef, useValue: popoverRef }] }) // new PortalInjector( // this.injector, diff --git a/src/app/core/error-handler/README.md b/src/app/core/error-handler/README.md index 5821e0c3c7..38cb2e4358 100644 --- a/src/app/core/error-handler/README.md +++ b/src/app/core/error-handler/README.md @@ -345,8 +345,7 @@ describe('ErrorHandlerService', () => { providers: [ ErrorHandlerService, { provide: MatSnackBar, useValue: snackBarSpy }, - { provide: Router, useValue: routerSpy } - ] + { provide: Router, useValue: routerSpy }] }); service = TestBed.inject(ErrorHandlerService); diff --git a/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts b/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts index bf6ef9355d..a2e5e82da8 100644 --- a/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts +++ b/src/app/organization/payment-types/edit-payment-type/edit-payment-type.component.ts @@ -83,8 +83,7 @@ export class EditPaymentTypeComponent implements OnInit { ], description: [this.paymentTypeData.description], isCashPayment: [ - { value: this.paymentTypeData.isCashPayment, disabled: this.isSystemDefined } - ], + { value: this.paymentTypeData.isCashPayment, disabled: this.isSystemDefined }], position: [ { value: this.paymentTypeData.position, disabled: this.isSystemDefined }, [ diff --git a/src/app/products/floating-rates/floating-rate-period-dialog/floating-rate-period-dialog.component.ts b/src/app/products/floating-rates/floating-rate-period-dialog/floating-rate-period-dialog.component.ts index bb710d6d9c..2593eef9ee 100644 --- a/src/app/products/floating-rates/floating-rate-period-dialog/floating-rate-period-dialog.component.ts +++ b/src/app/products/floating-rates/floating-rate-period-dialog/floating-rate-period-dialog.component.ts @@ -81,8 +81,7 @@ export class FloatingRatePeriodDialogComponent implements OnInit { ] ], isDifferentialToBaseLendingRate: [ - { value: this.data?.isDifferentialToBaseLendingRate ?? false, disabled: rowDisabled } - ] + { value: this.data?.isDifferentialToBaseLendingRate ?? false, disabled: rowDisabled }] }); } diff --git a/src/app/settings/settings.service.ts b/src/app/settings/settings.service.ts index ad41ceae6b..a721750d48 100644 --- a/src/app/settings/settings.service.ts +++ b/src/app/settings/settings.service.ts @@ -299,4 +299,30 @@ export class SettingsService { get themeDarkEnabled(): boolean { return JSON.parse(localStorage.getItem('mifosXThemeDarkEnabled')); } + + setOllamaEnabled(enabled: boolean) { + localStorage.setItem('mifosXOllamaEnabled', String(enabled)); + } + + setOllamaUrl(url: string) { + localStorage.setItem('mifosXOllamaUrl', url); + } + + setOllamaModel(model: string) { + localStorage.setItem('mifosXOllamaModel', model); + } + + get ollamaEnabled(): boolean { + const stored = localStorage.getItem('mifosXOllamaEnabled'); + if (stored !== null) return stored === 'true'; + return environment.ollamaEnabled; + } + + get ollamaUrl(): string { + return localStorage.getItem('mifosXOllamaUrl') || environment.ollamaUrl || ''; + } + + get ollamaModel(): string { + return localStorage.getItem('mifosXOllamaModel') || environment.ollamaModel || ''; + } } diff --git a/src/app/shared/services/ollama.service.ts b/src/app/shared/services/ollama.service.ts new file mode 100644 index 0000000000..4acece6080 --- /dev/null +++ b/src/app/shared/services/ollama.service.ts @@ -0,0 +1,128 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpBackend } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { environment } from 'environments/environment'; + +interface OllamaTagsResponse { + models: Array<{ name: string }>; +} + +interface OllamaGenerateResponse { + response: string; +} + +/** + * Service for communicating with a local Ollama instance. + * + * Uses HttpBackend directly to bypass Fineract auth interceptors. + * The feature is fully optional — all methods no-op when disabled or unconfigured. + */ +@Injectable({ + providedIn: 'root' +}) +export class OllamaService { + private httpBackend = inject(HttpBackend); + private externalHttp = new HttpClient(this.httpBackend); + + get enabled(): boolean { + const stored = localStorage.getItem('mifosXOllamaEnabled'); + if (stored !== null) { + return stored === 'true'; + } + return environment.ollamaEnabled; + } + + get url(): string { + return localStorage.getItem('mifosXOllamaUrl') || environment.ollamaUrl || ''; + } + + get model(): string { + return localStorage.getItem('mifosXOllamaModel') || environment.ollamaModel || ''; + } + + /** Returns list of available model names from the configured Ollama URL. */ + listModels(): Observable { + return this.listModelsFromUrl(this.url); + } + + /** Returns list of available model names from a given Ollama URL (used before saving settings). */ + listModelsFromUrl(url: string): Observable { + if (!url) return of([]); + return this.externalHttp.get(`${url}/api/tags`).pipe( + map((res) => (res?.models ?? []).map((m) => m.name)), + catchError(() => of([])) + ); + } + + /** + * Sends a prompt to Ollama and returns the generated text. + * Returns an empty string if the service is disabled or unconfigured. + */ + generate(prompt: string, system?: string): Observable { + if (!this.enabled || !this.url || !this.model) return of(''); + const body: Record = { model: this.model, prompt, stream: false }; + if (system) body['system'] = system; + return this.externalHttp.post(`${this.url}/api/generate`, body).pipe( + map((res) => res?.response ?? ''), + catchError(() => of('')) + ); + } + + /** + * Generates a Fineract MySQL SELECT query from a natural-language description. + * Includes the core Fineract schema as a system prompt so Ollama can reference + * real table and column names. + */ + generateSqlQuery(description: string): Observable { + const system = `You are a Mifos Fineract MySQL report query generator. +Generate a single MySQL SELECT query based on the user's description. +Use only these tables and columns: + +m_client: id, account_no, firstname, lastname, status_enum (100=pending 300=active 600=closed), office_id, staff_id, activation_date, birthdate, mobile_no, email_address +m_loan: id, account_no, client_id, group_id, product_id, loan_officer_id, loan_status_id (100=pending 200=approved 300=active 400=withdrawn 500=rejected 600=closed 700=overpaid), principal_amount, approved_principal, outstanding_derived, disbursedon_date, expected_maturedon_date, closedon_date, currency_code, office_id +m_loan_repayment_schedule: id, loan_id, duedate, installment, principal_amount, interest_amount, fee_charges_amount, penalty_charges_amount, principal_completed_derived, interest_completed_derived, completed_derived +m_loan_transaction: id, loan_id, transaction_type_enum (1=disbursement 2=repayment 5=waive_interest 9=written_off), transaction_date, amount, is_reversed +m_savings_account: id, account_no, client_id, group_id, product_id, status_enum (100=submitted 200=approved 300=active 600=closed), account_balance_derived, currency_code, office_id +m_savings_account_transaction: id, savings_account_id, transaction_type_enum (1=deposit 2=withdrawal 4=interest_posting), transaction_date, amount, running_balance_derived, is_reversed +m_office: id, name, hierarchy, parent_id, opening_date +m_staff: id, firstname, lastname, display_name, is_loan_officer, is_active, office_id +m_group: id, account_no, display_name, office_id, staff_id, status_enum, level_id, activation_date +m_product_loan: id, name, currency_code, min_principal_amount, max_principal_amount +m_savings_product: id, name, currency_code +m_currency: code, name, display_symbol, decimal_places + +Rules: +- Return ONLY the raw SQL SELECT statement. No markdown, no backticks, no explanation. +- Use meaningful column aliases (e.g. c.firstname AS first_name). +- Parameterise date ranges with ${'{'}dateFormat${'}'} and ${'{'}startDate${'}'} / ${'{'}endDate${'}'} placeholders if the user mentions a date range.`; + + return this.generate(description, system).pipe(map((sql) => this.stripMarkdownFences(sql))); + } + + private stripMarkdownFences(text: string): string { + return text + .replace(/^```(?:sql)?\s*/i, '') + .replace(/\s*```\s*$/, '') + .trim(); + } + + /** Returns true if the configured Ollama instance is reachable. */ + checkConnection(): Observable { + return this.checkConnectionAt(this.url); + } + + /** Returns true if an Ollama instance at the given URL is reachable. */ + checkConnectionAt(url: string): Observable { + if (!url) return of(false); + return this.listModelsFromUrl(url).pipe(map((models) => models.length >= 0)); + } +} diff --git a/src/app/system/external-services/external-services.component.html b/src/app/system/external-services/external-services.component.html index 985e40605d..33284ba286 100644 --- a/src/app/system/external-services/external-services.component.html +++ b/src/app/system/external-services/external-services.component.html @@ -88,6 +88,24 @@

} + + + + + +

{{ 'labels.heading.Ollama AI Report Assistant' | translate }}

+ @if (!arrowBooleans[4]) { + + } + @if (arrowBooleans[4]) { + + } + @if (arrowBooleans[4]) { +

+ {{ 'labels.text.Ollama AI Report Assistant Configuration' | translate }} +

+ } +
diff --git a/src/app/system/external-services/external-services.component.ts b/src/app/system/external-services/external-services.component.ts index 272a99601c..17e8edefb4 100644 --- a/src/app/system/external-services/external-services.component.ts +++ b/src/app/system/external-services/external-services.component.ts @@ -31,8 +31,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; changeDetection: ChangeDetectionStrategy.OnPush }) export class ExternalServicesComponent { - // Initialize an array of 4 boolean values, all set to false - arrowBooleans: boolean[] = new Array(4).fill(false); + // Initialize an array of 5 boolean values, all set to false + arrowBooleans: boolean[] = new Array(5).fill(false); constructor() {} diff --git a/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.html b/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.html new file mode 100644 index 0000000000..8d74589472 --- /dev/null +++ b/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.html @@ -0,0 +1,65 @@ + + +
+ +
+ +
+ + {{ 'labels.inputs.Enable AI Report Assistant' | translate }} + + + + {{ 'labels.inputs.Ollama URL' | translate }} + + @if (ollamaForm.controls['url'].hasError('required')) { + + {{ 'labels.inputs.Ollama URL' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + {{ 'labels.inputs.Ollama Model' | translate }} + @if (availableModels.length > 0) { + + @for (model of availableModels; track model) { + {{ model }} + } + + } @else { + + } + + +
+ +
+
+
+ + + + + +
+
+
diff --git a/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.scss b/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.scss new file mode 100644 index 0000000000..af9f4c0753 --- /dev/null +++ b/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.scss @@ -0,0 +1,7 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ diff --git a/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.ts b/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.ts new file mode 100644 index 0000000000..ea5a8c5ffb --- /dev/null +++ b/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.ts @@ -0,0 +1,116 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatButtonModule } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { SettingsService } from 'app/settings/settings.service'; +import { OllamaService } from 'app/shared/services/ollama.service'; +import { AlertService } from 'app/core/alert/alert.service'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'mifosx-edit-ollama', + templateUrl: './edit-ollama.component.html', + styleUrls: ['./edit-ollama.component.scss'], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatSlideToggleModule, + MatButtonModule, + //FaIconComponent, + RouterLink + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditOllamaComponent implements OnInit { + private formBuilder = inject(UntypedFormBuilder); + private settingsService = inject(SettingsService); + private ollamaService = inject(OllamaService); + private alertService = inject(AlertService); + private translateService = inject(TranslateService); + private route = inject(ActivatedRoute); + private router = inject(Router); + private cdr = inject(ChangeDetectorRef); + + ollamaForm: UntypedFormGroup; + availableModels: string[] = []; + + ngOnInit(): void { + this.ollamaForm = this.formBuilder.group({ + enabled: [this.settingsService.ollamaEnabled], + url: [ + this.settingsService.ollamaUrl, + [] + ], + model: [this.settingsService.ollamaModel] + }); + this.syncUrlValidators(this.ollamaForm.value.enabled); + this.ollamaForm.get('enabled').valueChanges.subscribe((enabled: boolean) => { + this.syncUrlValidators(enabled); + }); + if (this.settingsService.ollamaUrl) { + this.loadModels(this.settingsService.ollamaUrl); + } + } + + private syncUrlValidators(enabled: boolean): void { + const urlCtrl = this.ollamaForm.get('url'); + const modelCtrl = this.ollamaForm.get('model'); + if (enabled) { + urlCtrl.setValidators([Validators.required]); + urlCtrl.enable({ emitEvent: false }); + modelCtrl.enable({ emitEvent: false }); + } else { + urlCtrl.clearValidators(); + urlCtrl.disable({ emitEvent: false }); + modelCtrl.disable({ emitEvent: false }); + } + urlCtrl.updateValueAndValidity({ emitEvent: false }); + } + + loadModels(url?: string): void { + const target = url ?? this.ollamaForm.get('url').value; + if (!target) return; + this.ollamaService.listModelsFromUrl(target).subscribe((models) => { + this.availableModels = models; + this.cdr.markForCheck(); + }); + } + + testConnection(): void { + const url = this.ollamaForm.get('url').value; + if (!url) return; + this.ollamaService.checkConnectionAt(url).subscribe((ok) => { + if (ok) { + this.alertService.alert({ + type: 'Ollama', + message: this.translateService.instant('labels.text.Ollama connection successful') + }); + this.loadModels(url); + } else { + this.alertService.alert({ + type: 'Ollama Error', + message: this.translateService.instant('labels.text.Ollama connection failed') + }); + } + }); + } + + submit(): void { + if (this.ollamaForm.invalid) return; + this.settingsService.setOllamaEnabled(this.ollamaForm.value.enabled ?? false); + this.settingsService.setOllamaUrl(this.ollamaForm.getRawValue().url ?? ''); + this.settingsService.setOllamaModel(this.ollamaForm.getRawValue().model ?? ''); + this.router.navigate(['../'], { relativeTo: this.route }); + } +} diff --git a/src/app/system/external-services/ollama/ollama.component.html b/src/app/system/external-services/ollama/ollama.component.html new file mode 100644 index 0000000000..1bed5655dd --- /dev/null +++ b/src/app/system/external-services/ollama/ollama.component.html @@ -0,0 +1,40 @@ + + +
+ +
+ +@if (enabled) { +
+

+ info + {{ 'labels.text.Ollama is enabled. Visit the Reports page to use the AI SQL generator.' | translate }} +

+
+} + +
+ + + + + + + + + + + + + +
{{ 'labels.inputs.name' | translate }}{{ row.name | translateKey: 'inputs' }}{{ 'labels.inputs.Value' | translate }}{{ row.value }}
+
diff --git a/src/app/system/external-services/ollama/ollama.component.scss b/src/app/system/external-services/ollama/ollama.component.scss new file mode 100644 index 0000000000..45af0d1825 --- /dev/null +++ b/src/app/system/external-services/ollama/ollama.component.scss @@ -0,0 +1,27 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.usage-hint { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: rgb(63 81 181 / 8%); + border-radius: 4px; + margin: 0; + font-size: 0.9rem; + color: rgb(0 0 0 / 70%); +} + +.hint-icon { + font-size: 1.1rem; + width: 1.1rem; + height: 1.1rem; + color: #3f51b5; + flex-shrink: 0; +} diff --git a/src/app/system/external-services/ollama/ollama.component.ts b/src/app/system/external-services/ollama/ollama.component.ts new file mode 100644 index 0000000000..67878c0c70 --- /dev/null +++ b/src/app/system/external-services/ollama/ollama.component.ts @@ -0,0 +1,77 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { ChangeDetectionStrategy, Component, OnInit, ViewChild, inject } from '@angular/core'; +import { MatSort, MatSortHeader } from '@angular/material/sort'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatIconModule } from '@angular/material/icon'; +import { RouterLink } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { SettingsService } from 'app/settings/settings.service'; + +@Component({ + selector: 'mifosx-ollama', + templateUrl: './ollama.component.html', + styleUrls: ['./ollama.component.scss'], + standalone: true, + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + RouterLink, + MatIconModule, + MatTable, + MatSort, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatSortHeader, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class OllamaComponent implements OnInit { + private settingsService = inject(SettingsService); + + displayedColumns: string[] = [ + 'name', + 'value' + ]; + dataSource: MatTableDataSource<{ name: string; value: string }>; + enabled = false; + + @ViewChild(MatSort, { static: true }) sort: MatSort; + + ngOnInit(): void { + this.enabled = this.settingsService.ollamaEnabled; + const rows = [ + { name: 'enabled', value: this.settingsService.ollamaEnabled ? 'true' : 'false' }, + { name: 'url', value: this.settingsService.ollamaUrl || '—' }, + { name: 'model', value: this.settingsService.ollamaModel || '—' } + ]; + this.dataSource = new MatTableDataSource(rows); + this.dataSource.sort = this.sort; + } +} diff --git a/src/app/system/manage-data-tables/column-dialog/column-dialog.component.ts b/src/app/system/manage-data-tables/column-dialog/column-dialog.component.ts index 3fa2e2228a..c60140b9df 100644 --- a/src/app/system/manage-data-tables/column-dialog/column-dialog.component.ts +++ b/src/app/system/manage-data-tables/column-dialog/column-dialog.component.ts @@ -84,8 +84,7 @@ export class ColumnDialogComponent implements OnInit { ], mandatory: [{ value: this.data.isColumnNullable, disabled: this.data.type === 'existing' }], unique: [ - { value: this.data.isColumnUnique, disabled: this.data.isColumnNullable || this.data.type === 'existing' } - ], + { value: this.data.isColumnUnique, disabled: this.data.isColumnNullable || this.data.type === 'existing' }], indexed: [{ value: this.data.isColumnIndexed, disabled: this.data.type === 'existing' }], code: [ { diff --git a/src/app/system/manage-jobs/workflow-jobs/workflow-diagram/workflow-diagram.component.ts b/src/app/system/manage-jobs/workflow-jobs/workflow-diagram/workflow-diagram.component.ts index febd7f85a3..e899fe0444 100644 --- a/src/app/system/manage-jobs/workflow-jobs/workflow-diagram/workflow-diagram.component.ts +++ b/src/app/system/manage-jobs/workflow-jobs/workflow-diagram/workflow-diagram.component.ts @@ -31,10 +31,7 @@ export class JobStep { export class WorkflowDiagramComponent implements OnInit { @Input() jobStepsData: JobStep[] = []; - diagramSize: [ - number, - number - ] = [ + diagramSize: [number, number] = [ 1024, 300 ]; diff --git a/src/app/system/manage-reports/create-report/create-report.component.html b/src/app/system/manage-reports/create-report/create-report.component.html index 1af3ec8b83..68b9799eb9 100644 --- a/src/app/system/manage-reports/create-report/create-report.component.html +++ b/src/app/system/manage-reports/create-report/create-report.component.html @@ -72,6 +72,64 @@ + @if (ollamaEnabled) { +
+ + + @if (aiExpanded) { +
+ + {{ 'labels.inputs.Describe the report you want' | translate }} + + + +
+ + @if (aiSql) { + + } +
+ + @if (aiError) { +

+ {{ 'labels.text.Could not reach Ollama. Check your URL and model in Settings.' | translate }} +

+ } + + @if (aiSql) { +
{{ aiSql }}
+ } +
+ } +
+ } + {{ 'labels.inputs.SQL' | translate }}