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 @@
+
+
+
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 }}