diff --git a/talent-management/public/data/menu.json b/talent-management/public/data/menu.json index 09c557b..3ef2220 100644 --- a/talent-management/public/data/menu.json +++ b/talent-management/public/data/menu.json @@ -89,6 +89,12 @@ } } ] + }, + { + "route": "ai-chat", + "name": "aiChat", + "type": "link", + "icon": "smart_toy" } ] } diff --git a/talent-management/public/i18n/en-US.json b/talent-management/public/i18n/en-US.json index 0bf0f26..c46b7d3 100644 --- a/talent-management/public/i18n/en-US.json +++ b/talent-management/public/i18n/en-US.json @@ -13,6 +13,7 @@ "salaryRanges": "Salary Ranges", "salaryRanges.salaryRangeList": "List", "salaryRanges.addSalaryRange": "Create", + "aiChat": "AI Assistant", "design": "Design", "design.colors": "Color System", "design.icons": "Material Icons", diff --git a/talent-management/src/app/app.routes.ts b/talent-management/src/app/app.routes.ts index 615a840..bcabc90 100644 --- a/talent-management/src/app/app.routes.ts +++ b/talent-management/src/app/app.routes.ts @@ -23,6 +23,7 @@ import { SalaryRangeDetailComponent } from './routes/salary-ranges/salary-range- import { SalaryRangeFormComponent } from './routes/salary-ranges/salary-range-form.component'; import { ProfileOverviewComponent } from './routes/profile/profile-overview.component'; import { ProfileSettingsComponent } from './routes/profile/profile-settings.component'; +import { AiChatComponent } from './routes/ai-chat/ai-chat.component'; export const routes: Routes = [ { @@ -57,6 +58,7 @@ export const routes: Routes = [ { path: '', redirectTo: 'overview', pathMatch: 'full' }, ], }, + { path: 'ai-chat', component: AiChatComponent }, { path: '403', component: Error403 }, { path: '404', component: Error404 }, { path: '500', component: Error500 }, diff --git a/talent-management/src/app/routes/ai-chat/ai-chat.component.html b/talent-management/src/app/routes/ai-chat/ai-chat.component.html new file mode 100644 index 0000000..5c3e12d --- /dev/null +++ b/talent-management/src/app/routes/ai-chat/ai-chat.component.html @@ -0,0 +1,161 @@ + + + +
+ + +
+ info +
+ AI features are disabled. +

+ To enable AI, set aiEnabled: true in + src/environments/environment.ts and + "AiEnabled": true in the API's appsettings.json. +

+
+
+
+
+
+ + +
+ + + + + + chat + General Chat + + +
+ + + AI Assistant + Ask anything — general knowledge, writing help, code questions +
+ +
+
+ + + +
+
+ {{ msg.role === 'user' ? 'person' : 'smart_toy' }} +
{{ msg.content }}
+
+
+ + +
+ chat_bubble_outline +

Start a conversation

+
+ + +
+ + Thinking… +
+ + +
+ error_outline + {{ chatError }} +
+
+ + + + + + Message + + + + +
+
+
+ + + + + analytics + HR Insights + + +
+ + + HR AI Assistant + Ask about your live workforce data — headcount, departments, recent hires +
+ +
+
+ + + +
+

Try asking:

+
+ + + + +
+
+ + +
+
+ {{ msg.role === 'user' ? 'person' : 'analytics' }} +
+
{{ msg.content }}
+ {{ msg.executionTimeMs }}ms +
+
+
+ + +
+ + Fetching live data and reasoning… +
+ + +
+ error_outline + {{ hrError }} +
+
+ + + + + + Question + + + + +
+
+
+ +
+
diff --git a/talent-management/src/app/routes/ai-chat/ai-chat.component.scss b/talent-management/src/app/routes/ai-chat/ai-chat.component.scss new file mode 100644 index 0000000..e3f0467 --- /dev/null +++ b/talent-management/src/app/routes/ai-chat/ai-chat.component.scss @@ -0,0 +1,262 @@ +.ai-disabled-banner { + padding: 16px; + + .disabled-card { + max-width: 720px; + margin: 0 auto; + border-left: 4px solid #2196f3; + + mat-card-content { + padding: 20px; + } + + .disabled-content { + display: flex; + align-items: flex-start; + gap: 16px; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + color: #2196f3; + flex-shrink: 0; + margin-top: 2px; + } + + strong { + font-size: 16px; + } + + p { + margin: 8px 0 0; + color: rgba(0, 0, 0, 0.6); + font-size: 14px; + line-height: 1.5; + } + + code { + background: rgba(0, 0, 0, 0.06); + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 13px; + } + } + } +} + +.chat-container { + padding: 16px; + max-width: 900px; + margin: 0 auto; +} + +.tab-icon { + margin-right: 6px; + font-size: 18px; + width: 18px; + height: 18px; + vertical-align: middle; +} + +.tab-content { + padding-top: 16px; +} + +.chat-card { + mat-card-header { + display: flex; + align-items: flex-start; + padding: 16px 16px 0; + margin-bottom: 0; + + .mat-mdc-card-header-text { + flex: 1; + } + + .header-actions { + margin-left: auto; + } + } + + mat-card-content { + padding: 16px; + min-height: 320px; + max-height: 480px; + overflow-y: auto; + } +} + +// Messages +.message-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.message { + display: flex; + align-items: flex-start; + gap: 10px; + + .avatar-icon { + font-size: 22px; + width: 22px; + height: 22px; + flex-shrink: 0; + margin-top: 4px; + } + + .bubble { + padding: 10px 14px; + border-radius: 12px; + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + max-width: 100%; + } + + .bubble-wrapper { + display: flex; + flex-direction: column; + gap: 4px; + + .exec-time { + font-size: 11px; + color: rgba(0, 0, 0, 0.38); + padding-left: 4px; + } + } + + &.user-message { + flex-direction: row-reverse; + + .avatar-icon { + color: #3f51b5; + } + + .bubble { + background: #e8eaf6; + color: rgba(0, 0, 0, 0.87); + } + } + + &.assistant-message { + .avatar-icon { + color: #4caf50; + } + + .bubble { + background: #f5f5f5; + color: rgba(0, 0, 0, 0.87); + } + } +} + +// Empty state +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 240px; + color: rgba(0, 0, 0, 0.38); + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 12px; + } + + p { + margin: 0; + font-size: 14px; + } +} + +// Suggestions +.suggestions { + padding-bottom: 16px; + + .suggestions-label { + font-size: 13px; + color: rgba(0, 0, 0, 0.54); + margin: 0 0 10px; + } + + .suggestion-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + + button { + font-size: 13px; + height: 32px; + } + } +} + +// Loading +.loading-row { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 0; + color: rgba(0, 0, 0, 0.54); + font-size: 14px; +} + +// Error +.error-row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px; + border-radius: 6px; + background: #fff3e0; + color: #e65100; + font-size: 14px; + margin-top: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + flex-shrink: 0; + } +} + +// Input area +.input-area { + padding: 12px 16px; + display: flex; + gap: 12px; + align-items: flex-end; + + .message-input { + flex: 1; + margin-bottom: 0; + } +} + +@media (max-width: 600px) { + .chat-container { + padding: 8px; + } + + .input-area { + flex-direction: column; + align-items: stretch; + + button { + width: 100%; + } + } + + .suggestion-list { + flex-direction: column; + } +} diff --git a/talent-management/src/app/routes/ai-chat/ai-chat.component.ts b/talent-management/src/app/routes/ai-chat/ai-chat.component.ts new file mode 100644 index 0000000..5094a29 --- /dev/null +++ b/talent-management/src/app/routes/ai-chat/ai-chat.component.ts @@ -0,0 +1,145 @@ +import { Component, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatDividerModule } from '@angular/material/divider'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { PageHeader } from '@shared'; +import { AiService } from '../../services/api/ai.service'; +import { environment } from '../../../environments/environment'; + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + executionTimeMs?: number; +} + +@Component({ + selector: 'app-ai-chat', + standalone: true, + templateUrl: './ai-chat.component.html', + styleUrl: './ai-chat.component.scss', + imports: [ + CommonModule, + FormsModule, + MatCardModule, + MatIconModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatProgressSpinnerModule, + MatTabsModule, + MatDividerModule, + PageHeader, + ], +}) +export class AiChatComponent implements OnDestroy { + private aiService = inject(AiService); + private destroy$ = new Subject(); + + aiEnabled = environment.aiEnabled; + + // General chat state + chatMessages: ChatMessage[] = []; + chatInput = ''; + chatLoading = false; + chatError = ''; + + // HR insight state + hrMessages: ChatMessage[] = []; + hrInput = ''; + hrLoading = false; + hrError = ''; + + sendChat(): void { + const message = this.chatInput.trim(); + if (!message || this.chatLoading) return; + + this.chatMessages.push({ role: 'user', content: message }); + this.chatInput = ''; + this.chatLoading = true; + this.chatError = ''; + + this.aiService + .chat(message) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: response => { + this.chatMessages.push({ role: 'assistant', content: response.reply }); + this.chatLoading = false; + }, + error: err => { + this.chatError = err?.error?.detail ?? 'Failed to get a response. Is the API running with AiEnabled: true?'; + this.chatLoading = false; + }, + }); + } + + sendHrInsight(): void { + const question = this.hrInput.trim(); + if (!question || this.hrLoading) return; + + this.hrMessages.push({ role: 'user', content: question }); + this.hrInput = ''; + this.hrLoading = true; + this.hrError = ''; + + this.aiService + .hrInsight(question) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: result => { + if (result.succeeded && result.data) { + this.hrMessages.push({ + role: 'assistant', + content: result.data.answer, + executionTimeMs: result.data.executionTimeMs, + }); + } else { + this.hrError = result.message ?? 'Unexpected response from HR insights endpoint.'; + } + this.hrLoading = false; + }, + error: err => { + this.hrError = err?.error?.detail ?? 'Failed to get HR insights. Is the API running with AiEnabled: true?'; + this.hrLoading = false; + }, + }); + } + + onChatKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendChat(); + } + } + + onHrKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendHrInsight(); + } + } + + clearChat(): void { + this.chatMessages = []; + this.chatError = ''; + } + + clearHr(): void { + this.hrMessages = []; + this.hrError = ''; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/talent-management/src/app/services/api/ai.service.ts b/talent-management/src/app/services/api/ai.service.ts new file mode 100644 index 0000000..6313062 --- /dev/null +++ b/talent-management/src/app/services/api/ai.service.ts @@ -0,0 +1,54 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +export interface AiChatResponse { + reply: string; +} + +export interface HrInsightDto { + question: string; + answer: string; + executionTimeMs: number; +} + +export interface HrInsightResult { + succeeded: boolean; + data: HrInsightDto; + message?: string; +} + +/** + * AI API Service + * Calls /api/v1/ai/chat and /api/v1/ai/hr-insight endpoints. + * Requires AiEnabled feature flag to be true in the .NET API. + */ +@Injectable({ + providedIn: 'root', +}) +export class AiService { + private http = inject(HttpClient); + private apiUrl = environment.apiUrl; + + /** + * Send a message to the general-purpose AI assistant. + * Calls POST /api/v1/ai/chat + */ + chat(message: string, systemPrompt?: string): Observable { + return this.http.post(`${this.apiUrl}/ai/chat`, { + message, + systemPrompt, + }); + } + + /** + * Ask the HR AI assistant a data-aware question. + * Calls POST /api/v1/ai/hr-insight — the backend injects live workforce metrics. + */ + hrInsight(question: string): Observable { + return this.http.post(`${this.apiUrl}/ai/hr-insight`, { + question, + }); + } +} diff --git a/talent-management/src/app/services/api/index.ts b/talent-management/src/app/services/api/index.ts index 6387bfe..011796d 100644 --- a/talent-management/src/app/services/api/index.ts +++ b/talent-management/src/app/services/api/index.ts @@ -1,3 +1,4 @@ +export * from './ai.service'; export * from './base-api.service'; export * from './dashboard.service'; export * from './department.service'; diff --git a/talent-management/src/environments/environment.prod.ts b/talent-management/src/environments/environment.prod.ts index 6805681..e88046f 100644 --- a/talent-management/src/environments/environment.prod.ts +++ b/talent-management/src/environments/environment.prod.ts @@ -13,4 +13,5 @@ export const environment = { // Feature Flags allowAnonymousAccess: true, + aiEnabled: false, }; diff --git a/talent-management/src/environments/environment.ts b/talent-management/src/environments/environment.ts index 23d083b..7c97f13 100644 --- a/talent-management/src/environments/environment.ts +++ b/talent-management/src/environments/environment.ts @@ -17,6 +17,7 @@ export const environment = { // Feature Flags allowAnonymousAccess: true, + aiEnabled: false, }; /*