diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.html index 4abd0ec1d..5c23eb5fd 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.html +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.html @@ -23,7 +23,7 @@

{{message.text}} - {{message.text}} +
diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts index cfc39964b..e3791a2f9 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts @@ -3,31 +3,35 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatIconTestingModule } from '@angular/material/icon/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Angulartics2Module } from 'angulartics2'; -import { MarkdownService } from 'ngx-markdown'; -import { of, throwError } from 'rxjs'; +import { of } from 'rxjs'; +import { AiService } from 'src/app/services/ai.service'; import { ConnectionsService } from 'src/app/services/connections.service'; import { TableStateService } from 'src/app/services/table-state.service'; import { TablesService } from 'src/app/services/tables.service'; import { DbTableAiPanelComponent } from './db-table-ai-panel.component'; +async function* mockStream(...chunks: string[]): AsyncGenerator { + for (const chunk of chunks) { + yield chunk; + } +} + describe('DbTableAiPanelComponent', () => { let component: DbTableAiPanelComponent; let fixture: ComponentFixture; - let tablesService: TablesService; let tableStateService: TableStateService; - const mockMarkdownService = { - parse: vi.fn().mockReturnValue('parsed markdown'), - }; - const mockConnectionsService = { currentConnectionID: '12345678', }; const mockTablesService = { currentTableName: 'users', - createAIthread: vi.fn(), - requestAImessage: vi.fn(), + }; + + const mockAiService: Partial = { + createThread: vi.fn(), + sendMessage: vi.fn(), }; const mockTableStateService = { @@ -41,16 +45,15 @@ describe('DbTableAiPanelComponent', () => { imports: [Angulartics2Module.forRoot(), DbTableAiPanelComponent, BrowserAnimationsModule, MatIconTestingModule], providers: [ provideHttpClient(), - { provide: MarkdownService, useValue: mockMarkdownService }, { provide: ConnectionsService, useValue: mockConnectionsService }, { provide: TablesService, useValue: mockTablesService }, + { provide: AiService, useValue: mockAiService }, { provide: TableStateService, useValue: mockTableStateService }, ], }).compileComponents(); fixture = TestBed.createComponent(DbTableAiPanelComponent); component = fixture.componentInstance; - tablesService = TestBed.inject(TablesService); tableStateService = TestBed.inject(TableStateService); fixture.detectChanges(); }); @@ -77,7 +80,10 @@ describe('DbTableAiPanelComponent', () => { }); it('should create thread on Enter key when no thread exists', () => { - mockTablesService.createAIthread.mockReturnValue(of({ threadId: 'thread-123', responseMessage: 'AI response' })); + (mockAiService.createThread as ReturnType).mockResolvedValue({ + threadId: 'thread-123', + stream: mockStream('AI response'), + }); component.message = 'Test message'; component.threadID = null; @@ -86,11 +92,16 @@ describe('DbTableAiPanelComponent', () => { Object.defineProperty(event, 'preventDefault', { value: vi.fn() }); component.onKeydown(event); - expect(mockTablesService.createAIthread).toHaveBeenCalledWith('12345678', 'users', 'Test message'); + expect(mockAiService.createThread).toHaveBeenCalledWith( + '12345678', + 'users', + 'Test message', + expect.any(AbortSignal), + ); }); it('should send message on Enter key when thread exists', () => { - mockTablesService.requestAImessage.mockReturnValue(of('AI response')); + (mockAiService.sendMessage as ReturnType).mockResolvedValue(mockStream('AI response')); component.message = 'Follow up message'; component.threadID = 'existing-thread'; @@ -99,19 +110,23 @@ describe('DbTableAiPanelComponent', () => { Object.defineProperty(event, 'preventDefault', { value: vi.fn() }); component.onKeydown(event); - expect(mockTablesService.requestAImessage).toHaveBeenCalledWith( + expect(mockAiService.sendMessage).toHaveBeenCalledWith( '12345678', 'users', 'existing-thread', 'Follow up message', + expect.any(AbortSignal), ); }); - it('should add user message to chain when creating thread', () => { - mockTablesService.createAIthread.mockReturnValue(of({ threadId: 'thread-123', responseMessage: 'AI response' })); + it('should add user message to chain when creating thread', async () => { + (mockAiService.createThread as ReturnType).mockResolvedValue({ + threadId: 'thread-123', + stream: mockStream('AI response'), + }); component.message = 'User question'; - component.createThread(); + await component.createThread(); expect(component.messagesChain[0]).toEqual({ type: 'user', @@ -119,24 +134,27 @@ describe('DbTableAiPanelComponent', () => { }); }); - it('should add AI response to chain after thread creation', () => { - mockTablesService.createAIthread.mockReturnValue(of({ threadId: 'thread-123', responseMessage: 'AI response' })); + it('should add AI response to chain after thread creation', async () => { + (mockAiService.createThread as ReturnType).mockResolvedValue({ + threadId: 'thread-123', + stream: mockStream('AI response'), + }); component.message = 'User question'; - component.createThread(); + await component.createThread(); expect(component.threadID).toBe('thread-123'); expect(component.messagesChain[1]).toEqual({ type: 'ai', - text: 'parsed markdown', + text: 'AI response', }); }); - it('should handle error when creating thread', () => { - mockTablesService.createAIthread.mockReturnValue(throwError(() => 'Error message')); + it('should handle error when creating thread', async () => { + (mockAiService.createThread as ReturnType).mockRejectedValue('Error message'); component.message = 'User question'; - component.createThread(); + await component.createThread(); expect(component.messagesChain[1]).toEqual({ type: 'ai-error', @@ -144,10 +162,13 @@ describe('DbTableAiPanelComponent', () => { }); }); - it('should use suggested message when provided to createThread', () => { - mockTablesService.createAIthread.mockReturnValue(of({ threadId: 'thread-123', responseMessage: 'AI response' })); + it('should use suggested message when provided to createThread', async () => { + (mockAiService.createThread as ReturnType).mockResolvedValue({ + threadId: 'thread-123', + stream: mockStream('AI response'), + }); - component.createThread('Suggested question'); + await component.createThread('Suggested question'); expect(component.messagesChain[0].text).toBe('Suggested question'); }); @@ -157,12 +178,12 @@ describe('DbTableAiPanelComponent', () => { expect(mockTableStateService.handleViewAIpanel).toHaveBeenCalled(); }); - it('should clear message after sending', () => { - mockTablesService.requestAImessage.mockReturnValue(of('AI response')); + it('should clear message after sending', async () => { + (mockAiService.sendMessage as ReturnType).mockResolvedValue(mockStream('AI response')); component.message = 'Test message'; component.threadID = 'thread-123'; - component.sendMessage(); + await component.sendMessage(); expect(component.message).toBe(''); expect(component.charactrsNumber).toBe(0); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.ts index 0fc3e80bb..2e1f9c59f 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.ts @@ -1,14 +1,24 @@ import { CommonModule } from '@angular/common'; -import { Component, ElementRef, HostListener, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { + AfterViewInit, + Component, + ElementRef, + HostListener, + Input, + inject, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { Angulartics2, Angulartics2Module } from 'angulartics2'; -import { MarkdownModule, MarkdownService } from 'ngx-markdown'; +import { MarkdownModule } from 'ngx-markdown'; import posthog from 'posthog-js'; -import { Subscription } from 'rxjs'; +import { AiService } from 'src/app/services/ai.service'; import { ConnectionsService } from 'src/app/services/connections.service'; import { TableStateService } from 'src/app/services/table-state.service'; import { TablesService } from 'src/app/services/tables.service'; @@ -28,7 +38,7 @@ import { TablesService } from 'src/app/services/tables.service'; Angulartics2Module, ], }) -export class DbTableAiPanelComponent implements OnInit, OnDestroy { +export class DbTableAiPanelComponent implements OnInit, AfterViewInit, OnDestroy { @Input() public displayName: string; @Input() public tableColumns: string[] = []; @Input() public sidebarExpanded: boolean = true; @@ -57,7 +67,8 @@ export class DbTableAiPanelComponent implements OnInit, OnDestroy { public textareaRows: number = 4; public currentLoadingStep: string = ''; - private _currentRequest: Subscription = null; + private _aiService = inject(AiService); + private _abortController: AbortController | null = null; private _loadingStepsInterval: any = null; private _loadingSteps: string[] = [ 'Connecting to database', @@ -73,7 +84,6 @@ export class DbTableAiPanelComponent implements OnInit, OnDestroy { private _connections: ConnectionsService, private _tables: TablesService, private _tableState: TableStateService, - private markdownService: MarkdownService, private angulartics2: Angulartics2, ) {} @@ -118,9 +128,9 @@ export class DbTableAiPanelComponent implements OnInit, OnDestroy { } cancelRequest(): void { - if (this._currentRequest) { - this._currentRequest.unsubscribe(); - this._currentRequest = null; + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; this.submitting = false; this.stopLoadingSteps(); @@ -175,7 +185,7 @@ export class DbTableAiPanelComponent implements OnInit, OnDestroy { } } - createThread(suggestedMessage?: string) { + async createThread(suggestedMessage?: string) { if (suggestedMessage) { this.message = suggestedMessage; } @@ -188,44 +198,46 @@ export class DbTableAiPanelComponent implements OnInit, OnDestroy { const messageCopy = this.message; this.message = ''; - this._currentRequest = this._tables.createAIthread(this.connectionID, this.tableName, messageCopy).subscribe( - (response) => { - this.threadID = response.threadId; + this._abortController = new AbortController(); - this.messagesChain.push({ - type: 'ai', - text: this.markdownService.parse(response.responseMessage) as string, - }); - this.submitting = false; + try { + const response = await this._aiService.createThread( + this.connectionID, + this.tableName, + messageCopy, + this._abortController.signal, + ); + + if (response) { + this.threadID = response.threadId; + const aiMessage = { type: 'ai', text: '' }; + this.messagesChain.push(aiMessage); this.stopLoadingSteps(); - this._currentRequest = null; + + await this._consumeStream(response.stream, aiMessage); this.angulartics2.eventTrack.next({ action: 'AI panel: thread created successfully', }); posthog.capture('AI panel: thread created successfully'); - }, - (error_message) => { - this.messagesChain.push({ - type: 'ai-error', - text: error_message, - }); - this.angulartics2.eventTrack.next({ - action: 'AI panel: thread creation returned an error', - }); - posthog.capture('AI panel: thread creation returned an error'); - this.stopLoadingSteps(); - this._currentRequest = null; - }, - () => { - this.submitting = false; - this.stopLoadingSteps(); - this._currentRequest = null; - }, - ); + } + } catch (error_message) { + this.messagesChain.push({ + type: 'ai-error', + text: String(error_message), + }); + this.angulartics2.eventTrack.next({ + action: 'AI panel: thread creation returned an error', + }); + posthog.capture('AI panel: thread creation returned an error'); + } finally { + this.submitting = false; + this.stopLoadingSteps(); + this._abortController = null; + } } - sendMessage(suggestedMessage?: string): void { + async sendMessage(suggestedMessage?: string) { if (suggestedMessage) { this.message = suggestedMessage; } @@ -238,42 +250,50 @@ export class DbTableAiPanelComponent implements OnInit, OnDestroy { const messageCopy = this.message; this.message = ''; this.charactrsNumber = 0; - this._currentRequest = this._tables - .requestAImessage(this.connectionID, this.tableName, this.threadID, messageCopy) - .subscribe( - (response_message) => { - this.messagesChain.push({ - type: 'ai', - text: this.markdownService.parse(response_message) as string, - }); - this.submitting = false; - this.stopLoadingSteps(); - this._currentRequest = null; - - this.angulartics2.eventTrack.next({ - action: 'AI panel: message sent successfully', - }); - posthog.capture('AI panel: message sent successfully'); - }, - (error_message) => { - this.messagesChain.push({ - type: 'ai-error', - text: error_message, - }); - this.submitting = false; - this.stopLoadingSteps(); - this._currentRequest = null; - this.angulartics2.eventTrack.next({ - action: 'AI panel: message sent and returned an error', - }); - posthog.capture('AI panel: message sent and returned an error'); - }, - () => { - this.submitting = false; - this.stopLoadingSteps(); - this._currentRequest = null; - }, + + this._abortController = new AbortController(); + + try { + const stream = await this._aiService.sendMessage( + this.connectionID, + this.tableName, + this.threadID, + messageCopy, + this._abortController.signal, ); + + if (stream) { + const aiMessage = { type: 'ai', text: '' }; + this.messagesChain.push(aiMessage); + this.stopLoadingSteps(); + + await this._consumeStream(stream, aiMessage); + + this.angulartics2.eventTrack.next({ + action: 'AI panel: message sent successfully', + }); + posthog.capture('AI panel: message sent successfully'); + } + } catch (error_message) { + this.messagesChain.push({ + type: 'ai-error', + text: String(error_message), + }); + this.angulartics2.eventTrack.next({ + action: 'AI panel: message sent and returned an error', + }); + posthog.capture('AI panel: message sent and returned an error'); + } finally { + this.submitting = false; + this.stopLoadingSteps(); + this._abortController = null; + } + } + + private async _consumeStream(stream: AsyncGenerator, message: { type: string; text: string }) { + for await (const chunk of stream) { + message.text += chunk; + } } scrollToBottom(): void { diff --git a/frontend/src/app/services/ai.service.ts b/frontend/src/app/services/ai.service.ts new file mode 100644 index 000000000..02a21d3f0 --- /dev/null +++ b/frontend/src/app/services/ai.service.ts @@ -0,0 +1,50 @@ +import { Injectable, inject } from '@angular/core'; +import { ApiService } from './api.service'; + +export interface AiStreamResponse { + threadId: string; + stream: AsyncGenerator; +} + +@Injectable({ + providedIn: 'root', +}) +export class AiService { + private _api = inject(ApiService); + + async createThread( + connectionId: string, + tableName: string, + message: string, + signal?: AbortSignal, + ): Promise { + const result = await this._api.postStream( + `/ai/v4/request/${connectionId}`, + { user_message: message }, + { params: { tableName }, signal }, + ); + + if (!result) return null; + + return { + threadId: result.headers.get('X-OpenAI-Thread-ID'), + stream: result.stream, + }; + } + + async sendMessage( + connectionId: string, + tableName: string, + threadId: string, + message: string, + signal?: AbortSignal, + ): Promise | null> { + const result = await this._api.postStream( + `/ai/v4/request/${connectionId}`, + { user_message: message }, + { params: { tableName, threadId }, signal }, + ); + + return result?.stream ?? null; + } +} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts new file mode 100644 index 000000000..1a9dc5e2c --- /dev/null +++ b/frontend/src/app/services/api.service.ts @@ -0,0 +1,228 @@ +import { HttpResourceOptions, HttpResourceRef, HttpResourceRequest, httpResource } from '@angular/common/http'; +import { effect, Injectable, inject } from '@angular/core'; +import { environment } from '../../environments/environment'; +import { AlertActionType, AlertType } from '../models/alert'; +import { NotificationsService } from './notifications.service'; + +export interface ApiRequestOptions { + params?: Record; + headers?: Record; + successMessage?: string; + errorMessage?: string; + responseType?: 'json' | 'text'; + signal?: AbortSignal; +} + +export interface ApiResponse { + data: T; + headers: Headers; + status: number; +} + +@Injectable({ + providedIn: 'root', +}) +export class ApiService { + private _notifications = inject(NotificationsService); + + resource( + request: () => string | HttpResourceRequest | undefined, + options?: HttpResourceOptions & { errorMessage?: string }, + ): HttpResourceRef { + const ref = httpResource(request as () => HttpResourceRequest | undefined, options); + + effect(() => { + const err = ref.error(); + if (err) { + this._handleError(err, { errorMessage: options?.errorMessage }); + } + }); + + return ref; + } + + async get(url: string, options?: ApiRequestOptions): Promise { + const result = await this._fetchResponse('GET', url, undefined, options); + return result?.data ?? null; + } + + async post(url: string, body?: unknown, options?: ApiRequestOptions): Promise { + const result = await this._fetchResponse('POST', url, body, options); + return result?.data ?? null; + } + + async postResponse(url: string, body?: unknown, options?: ApiRequestOptions): Promise | null> { + return this._fetchResponse('POST', url, body, options); + } + + async put(url: string, body?: unknown, options?: ApiRequestOptions): Promise { + const result = await this._fetchResponse('PUT', url, body, options); + return result?.data ?? null; + } + + async delete(url: string, options?: ApiRequestOptions): Promise { + const result = await this._fetchResponse('DELETE', url, undefined, options); + return result?.data ?? null; + } + + async postStream( + url: string, + body?: unknown, + options?: ApiRequestOptions, + ): Promise<{ headers: Headers; stream: AsyncGenerator } | null> { + try { + const fullUrl = this._buildUrl(url, options?.params); + const headers = this._buildHeaders(options?.headers); + + const response = await fetch(fullUrl, { + method: 'POST', + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'include', + signal: options?.signal, + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem('token_expiration'); + location.href = '/login'; + return null; + } + + const errorBody = await response.json().catch(() => ({})); + throw { error: errorBody, status: response.status }; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + async function* textStream(): AsyncGenerator { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield decoder.decode(value, { stream: true }); + } + } finally { + reader.releaseLock(); + } + } + + return { headers: response.headers, stream: textStream() }; + } catch (err: unknown) { + if ((err as Error).name === 'AbortError') return null; + this._handleError(err, options); + return null; + } + } + + private async _fetchResponse( + method: string, + url: string, + body?: unknown, + options?: ApiRequestOptions, + ): Promise | null> { + try { + const fullUrl = this._buildUrl(url, options?.params); + const headers = this._buildHeaders(options?.headers); + + const response = await fetch(fullUrl, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'include', + signal: options?.signal, + }); + + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem('token_expiration'); + location.href = '/login'; + return null; + } + + const errorBody = await response.json().catch(() => ({})); + throw { error: errorBody, status: response.status }; + } + + const data = + (options?.responseType ?? 'json') === 'text' ? ((await response.text()) as T) : ((await response.json()) as T); + + if (options?.successMessage) { + this._notifications.showSuccessSnackbar(options.successMessage); + } + + return { data, headers: response.headers, status: response.status }; + } catch (err: unknown) { + if ((err as Error).name === 'AbortError') return null; + this._handleError(err, options); + return null; + } + } + + private _buildUrl(url: string, params?: Record): string { + const normalized = this._normalizeUrl(url); + + if (!params || Object.keys(params).length === 0) return normalized; + + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + searchParams.set(key, String(value)); + } + } + + return `${normalized}?${searchParams.toString()}`; + } + + private _normalizeUrl(url: string): string { + if (url.startsWith('http://') || url.startsWith('https://')) return url; + if (url.startsWith('/saas')) return `${environment.saasURL || ''}${url}`; + return `${environment.apiRoot || '/api'}${url}`; + } + + private _buildHeaders(custom?: Record): Record { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + const gclidMatch = document.cookie.match(/(?:^|;\s*)autoadmin_gclid=([^;]*)/); + if (gclidMatch?.[1]) { + headers['GCLID'] = gclidMatch[1]; + } + + const pathSegments = location.pathname.split('/'); + const connectionId = pathSegments.length >= 3 ? pathSegments[2] : null; + if (connectionId) { + const masterKey = localStorage.getItem(`${connectionId}__masterKey`); + if (masterKey) { + headers['masterpwd'] = masterKey; + } + } + + if (custom) { + Object.assign(headers, custom); + } + + return headers; + } + + private _handleError(err: unknown, options?: { errorMessage?: string }): void { + console.log(err); + const error = err as { error?: { message?: string; originalMessage?: string }; message?: string }; + const message = options?.errorMessage || error?.error?.message || error?.message || 'Unknown error'; + const details = error?.error?.originalMessage; + + if (details) { + this._notifications.showAlert(AlertType.Error, { abstract: message, details }, [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: () => this._notifications.dismissAlert(), + }, + ]); + } else { + this._notifications.showErrorSnackbar(message); + } + } +}