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/reports/reports.service.ts b/src/app/reports/reports.service.ts index 260d32dc89..cbedd430d9 100644 --- a/src/app/reports/reports.service.ts +++ b/src/app/reports/reports.service.ts @@ -40,7 +40,7 @@ export class ReportsService { * @returns {Observable} */ getReportParams(reportName: string): Observable { - const httpParams = new HttpParams().set('R_reportListing', `${reportName}`).set('parameterType', 'true'); + const httpParams = new HttpParams().set('R_reportListing', `'${reportName}'`).set('parameterType', 'true'); return this.http .get(`/runreports/FullParameterList`, { params: httpParams }) .pipe(map((response: any) => response.data.map((entry: any) => new ReportParameter(entry.row)))); diff --git a/src/app/settings/settings.service.ts b/src/app/settings/settings.service.ts index 9c5ddda2e6..7e9b65aebe 100644 --- a/src/app/settings/settings.service.ts +++ b/src/app/settings/settings.service.ts @@ -306,4 +306,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..245f22f730 --- /dev/null +++ b/src/app/shared/services/ollama.service.ts @@ -0,0 +1,140 @@ +/** + * 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 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. Fineract runs on MySQL, MariaDB, or PostgreSQL, + * so the prompt asks for portable ANSI SQL rather than a vendor-specific dialect. + */ + generateSqlQuery(description: string): Observable { + const system = `You are a Mifos Fineract SQL report query generator. +Generate a single ANSI SQL SELECT query based on the user's description. +Fineract can run on MySQL, MariaDB, or PostgreSQL, so avoid vendor-specific syntax and stick to standard ANSI SQL. +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.validateSelectSql(this.stripMarkdownFences(sql)))); + } + + private stripMarkdownFences(text: string): string { + return text + .replace(/^```(?:sql)?\s*/i, '') + .replace(/\s*```\s*$/, '') + .trim(); + } + + /** Returns the SQL unchanged if it is a single SELECT statement, otherwise an empty string. */ + private validateSelectSql(sql: string): string { + const normalized = sql.trim().replace(/;+\s*$/, ''); + const isSingleSelect = /^select\b/i.test(normalized) && !/;\s*\S/.test(normalized); + return isSingleSelect ? normalized : ''; + } + + /** 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.externalHttp.get(`${url}/api/tags`).pipe( + map(() => true), + catchError(() => of(false)) + ); + } +} diff --git a/src/app/system/external-services/external-services.component.html b/src/app/system/external-services/external-services.component.html index 985e40605d..5dcf87d8f2 100644 --- a/src/app/system/external-services/external-services.component.html +++ b/src/app/system/external-services/external-services.component.html @@ -88,6 +88,28 @@

} + + + + + +

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

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

+ {{ 'labels.text.AI API 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..3adfafb242 100644 --- a/src/app/system/external-services/external-services.component.ts +++ b/src/app/system/external-services/external-services.component.ts @@ -10,6 +10,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { MatNavList, MatListItem } from '@angular/material/list'; import { MatIcon } from '@angular/material/icon'; +import { MatIconButton } from '@angular/material/button'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatLine } from '@angular/material/grid-list'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -25,14 +26,15 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatNavList, MatListItem, MatIcon, + MatIconButton, FaIconComponent, MatLine ], 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..9ed028cdc0 --- /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.url' | translate }} + + @if (ollamaForm.controls['url'].hasError('required')) { + + {{ 'labels.inputs.url' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + {{ 'labels.inputs.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..8d6ec7135b --- /dev/null +++ b/src/app/system/external-services/ollama/edit-ollama/edit-ollama.component.ts @@ -0,0 +1,120 @@ +/** + * 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 { FormBuilder, FormControl, FormGroup, 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(FormBuilder); + 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: FormGroup<{ + enabled: FormControl; + url: FormControl; + model: FormControl; + }>; + 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: 'AI API', + message: this.translateService.instant('labels.text.AI API connection successful') + }); + this.loadModels(url); + } else { + this.alertService.alert({ + type: 'AI API Error', + message: this.translateService.instant('labels.text.AI API 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..df26d4d4ab --- /dev/null +++ b/src/app/system/external-services/ollama/ollama.component.html @@ -0,0 +1,40 @@ + + +
+ +
+ +@if (enabled) { +
+

+ info + {{ 'labels.text.AI API 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..867e0ff260 --- /dev/null +++ b/src/app/system/external-services/ollama/ollama.component.scss @@ -0,0 +1,23 @@ +/** + * 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: 8px; + padding: 8px 16px; + background: var(--ds-blue-50); + border-radius: var(--ds-radius-sm); + margin: 0; + color: var(--ds-blue-700); +} + +.hint-icon { + color: var(--ds-blue-700); + 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..3d150adb98 --- /dev/null +++ b/src/app/system/external-services/ollama/ollama.component.ts @@ -0,0 +1,84 @@ +/** + * 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'; +import { TranslateService } from '@ngx-translate/core'; + +@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); + private translateService = inject(TranslateService); + + 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.translateService.instant( + this.settingsService.ollamaEnabled ? 'labels.inputs.Enabled' : 'labels.inputs.Disabled' + ) + }, + { 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..2e9806bbae 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 the AI API. Check your URL and model in Settings.' | translate }} +

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