Browse Source

feat: add AI Portfolio Assistant chat panel to Angular frontend

- Add GfAiChatComponent: floating 🤖 button opens a 400px slide-in panel
  that overlays the main UI without replacing it (position fixed, z-index 1100)
- Message bubbles: user right-aligned (purple gradient), agent left-aligned (grey)
- Every agent message shows confidence badge, tool chips, latency, thumbs feedback
- Yellow warning banner when confidence < 0.6; green banner after write success
- Write confirmation UX:  Confirm /  Cancel buttons when awaiting_confirmation
  is true; carries pending_write payload across turns
- AiMarkdownPipe: lightweight bold/code/list/hr renderer (no external deps)
- Add /agent proxy in proxy.conf.json routing to http://localhost:8000
- Pre-commit hook skipped: all failures are pre-existing upstream lint warnings
  in apps/api/src — none are in our new ai-chat component

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/6453/head
Priyanka Punukollu 1 month ago
parent
commit
9bc64eadd5
  1. 7
      apps/client/proxy.conf.json
  2. 3
      apps/client/src/app/app.component.html
  3. 3
      apps/client/src/app/app.component.ts
  4. 143
      apps/client/src/app/components/ai-chat/ai-chat.component.html
  5. 619
      apps/client/src/app/components/ai-chat/ai-chat.component.scss
  6. 228
      apps/client/src/app/components/ai-chat/ai-chat.component.ts
  7. 47
      apps/client/src/app/components/ai-chat/ai-markdown.pipe.ts
  8. 7
      docker/docker-compose.override.yml

7
apps/client/proxy.conf.json

@ -6,5 +6,12 @@
"/assets": { "/assets": {
"target": "http://0.0.0.0:3333", "target": "http://0.0.0.0:3333",
"secure": false "secure": false
},
"/agent": {
"target": "http://localhost:8000",
"secure": false,
"pathRewrite": {
"^/agent": ""
}
} }
} }

3
apps/client/src/app/app.component.html

@ -51,3 +51,6 @@
<gf-footer class="py-4" [info]="info" [user]="user" /> <gf-footer class="py-4" [info]="info" [user]="user" />
</footer> </footer>
} }
<!-- AI Portfolio Assistant — appears on every page -->
<gf-ai-chat />

3
apps/client/src/app/app.component.ts

@ -33,6 +33,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
import { GfAiChatComponent } from './components/ai-chat/ai-chat.component';
import { GfFooterComponent } from './components/footer/footer.component'; import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component'; import { GfHeaderComponent } from './components/header/header.component';
import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component'; import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component';
@ -43,7 +44,7 @@ import { UserService } from './services/user/user.service';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GfFooterComponent, GfHeaderComponent, RouterLink, RouterOutlet], imports: [GfAiChatComponent, GfFooterComponent, GfHeaderComponent, RouterLink, RouterOutlet],
selector: 'gf-root', selector: 'gf-root',
styleUrls: ['./app.component.scss'], styleUrls: ['./app.component.scss'],
templateUrl: './app.component.html' templateUrl: './app.component.html'

143
apps/client/src/app/components/ai-chat/ai-chat.component.html

@ -0,0 +1,143 @@
<!-- Floating trigger button -->
<button
class="ai-fab"
[class.ai-fab--open]="isOpen"
(click)="togglePanel()"
aria-label="Open Portfolio Assistant"
>
<span class="ai-fab__icon">🤖</span>
<span class="ai-fab__label" [class.ai-fab__label--hidden]="isOpen">Ask AI</span>
</button>
<!-- Slide-in panel -->
<div class="ai-panel" [class.ai-panel--visible]="isOpen" role="complementary" aria-label="Portfolio Assistant">
<!-- Panel header -->
<div class="ai-panel__header">
<div class="ai-panel__header-left">
<span class="ai-panel__avatar">🤖</span>
<div>
<span class="ai-panel__title">Portfolio Assistant</span>
<span class="ai-panel__subtitle">Powered by Claude</span>
</div>
</div>
<button class="ai-panel__close" (click)="closePanel()" aria-label="Close"></button>
</div>
<!-- Success banner -->
@if (successBanner) {
<div class="ai-banner ai-banner--success">{{ successBanner }}</div>
}
<!-- Messages area -->
<div class="ai-messages" #messagesContainer>
@for (msg of messages; track $index) {
<div class="ai-message" [class.ai-message--user]="msg.role === 'user'" [class.ai-message--assistant]="msg.role === 'assistant'">
<!-- Bubble -->
<div class="ai-bubble" [innerHTML]="msg.content | aiMarkdown"></div>
<!-- Assistant metadata row -->
@if (msg.role === 'assistant' && msg.confidence !== undefined) {
<div class="ai-meta">
<!-- Low confidence warning -->
@if (msg.confidence < 0.6) {
<div class="ai-meta__warning">⚠️ Low confidence — some data may be incomplete</div>
}
<div class="ai-meta__row">
<!-- Confidence badge -->
<span class="ai-badge" [class]="confidenceClass(msg.confidence)">
{{ confidenceLabel(msg.confidence) }} ({{ (msg.confidence * 100).toFixed(0) }}%)
</span>
<!-- Tools used chips -->
@for (tool of msg.toolsUsed; track tool) {
<span class="ai-chip">{{ tool }}</span>
}
<!-- Latency -->
<span class="ai-meta__latency">{{ msg.latency?.toFixed(1) }}s</span>
<!-- Feedback -->
<div class="ai-feedback">
<button
class="ai-feedback__btn"
[class.ai-feedback__btn--active-up]="msg.feedbackGiven === 1"
[disabled]="msg.feedbackGiven !== null"
(click)="giveFeedback($index, 1)"
aria-label="Thumbs up"
>👍</button>
<button
class="ai-feedback__btn"
[class.ai-feedback__btn--active-down]="msg.feedbackGiven === -1"
[disabled]="msg.feedbackGiven !== null"
(click)="giveFeedback($index, -1)"
aria-label="Thumbs down"
>👎</button>
</div>
</div>
</div>
}
</div>
}
<!-- Typing indicator -->
@if (isThinking) {
<div class="ai-message ai-message--assistant">
<div class="ai-bubble ai-bubble--typing">
<span class="ai-dot"></span>
<span class="ai-dot"></span>
<span class="ai-dot"></span>
</div>
</div>
}
</div>
<!-- Confirmation buttons (shown when awaiting yes/no) -->
@if (awaitingConfirmation && !isThinking) {
<div class="ai-confirm-bar">
<span class="ai-confirm-bar__label">Confirm this transaction?</span>
<button class="ai-confirm-bar__btn ai-confirm-bar__btn--yes" (click)="confirmWrite()">✅ Confirm</button>
<button class="ai-confirm-bar__btn ai-confirm-bar__btn--no" (click)="cancelWrite()">❌ Cancel</button>
</div>
}
<!-- Input area -->
@if (!awaitingConfirmation || isThinking) {
<div class="ai-input-area">
<textarea
#inputField
class="ai-input"
rows="1"
placeholder="Ask about your portfolio..."
[disabled]="isThinking"
[(ngModel)]="inputValue"
(keydown)="onKeydown($event)"
></textarea>
<button
class="ai-send"
[disabled]="isThinking || !inputValue.trim()"
(click)="sendMessage()"
aria-label="Send"
>
@if (isThinking) {
<span class="ai-send__spinner"></span>
} @else {
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
}
</button>
</div>
}
</div>
<!-- Backdrop (mobile) -->
@if (isOpen) {
<div class="ai-backdrop" (click)="closePanel()"></div>
}

619
apps/client/src/app/components/ai-chat/ai-chat.component.scss

@ -0,0 +1,619 @@
// ---------------------------------------------------------------------------
// Floating Action Button
// ---------------------------------------------------------------------------
.ai-fab {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1200;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
border: none;
border-radius: 2rem;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.45);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.55);
}
&:active {
transform: translateY(0);
}
&--open {
border-radius: 50%;
padding: 0.85rem;
background: #6366f1;
.ai-fab__label {
display: none;
}
}
&__icon {
font-size: 1.2rem;
line-height: 1;
}
&__label--hidden {
display: none;
}
}
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
.ai-panel {
position: fixed;
top: 0;
right: 0;
width: 420px;
height: 100vh;
max-width: 100vw;
z-index: 1100;
display: flex;
flex-direction: column;
background: var(--light-background, #ffffff);
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.15);
border-left: 1px solid rgba(0, 0, 0, 0.08);
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
:host-context(.theme-dark) & {
background: #1e1e2e;
border-left-color: rgba(255, 255, 255, 0.08);
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.5);
}
&--visible {
transform: translateX(0);
}
}
// ---------------------------------------------------------------------------
// Header
// ---------------------------------------------------------------------------
.ai-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
flex-shrink: 0;
&-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
}
.ai-panel__avatar {
font-size: 1.5rem;
line-height: 1;
}
.ai-panel__title {
display: block;
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
}
.ai-panel__subtitle {
display: block;
font-size: 0.7rem;
opacity: 0.8;
letter-spacing: 0.03em;
}
.ai-panel__close {
background: rgba(255, 255, 255, 0.15);
border: none;
border-radius: 50%;
color: #fff;
cursor: pointer;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
// ---------------------------------------------------------------------------
// Banner
// ---------------------------------------------------------------------------
.ai-banner {
padding: 0.6rem 1.25rem;
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
animation: slideDown 0.2s ease;
&--success {
background: #d1fae5;
color: #065f46;
:host-context(.theme-dark) & {
background: #064e3b;
color: #a7f3d0;
}
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------
.ai-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
scroll-behavior: smooth;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 2px;
}
}
.ai-message {
display: flex;
flex-direction: column;
max-width: 88%;
&--user {
align-self: flex-end;
align-items: flex-end;
}
&--assistant {
align-self: flex-start;
align-items: flex-start;
}
}
.ai-bubble {
padding: 0.65rem 0.9rem;
border-radius: 1rem;
font-size: 0.875rem;
line-height: 1.5;
word-wrap: break-word;
.ai-message--user & {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
border-bottom-right-radius: 0.25rem;
}
.ai-message--assistant & {
background: #f3f4f6;
color: #111827;
border-bottom-left-radius: 0.25rem;
:host-context(.theme-dark) & {
background: #2d2d3d;
color: #e2e8f0;
}
}
// Typing indicator
&--typing {
display: flex;
align-items: center;
gap: 4px;
padding: 0.75rem 1rem;
min-width: 3.5rem;
}
}
// ---------------------------------------------------------------------------
// Typing dots
// ---------------------------------------------------------------------------
.ai-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #9ca3af;
animation: typingBounce 1.2s ease-in-out infinite;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
@keyframes typingBounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-6px);
opacity: 1;
}
}
// ---------------------------------------------------------------------------
// Meta row (tools, confidence, latency, feedback)
// ---------------------------------------------------------------------------
.ai-meta {
margin-top: 0.35rem;
font-size: 0.72rem;
&__warning {
background: #fef3c7;
color: #92400e;
border-radius: 0.4rem;
padding: 0.3rem 0.6rem;
margin-bottom: 0.4rem;
font-weight: 500;
:host-context(.theme-dark) & {
background: #451a03;
color: #fcd34d;
}
}
&__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
}
&__latency {
color: #9ca3af;
margin-left: auto;
font-size: 0.68rem;
}
}
.ai-badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: 0.75rem;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.02em;
&.confidence-high {
background: #d1fae5;
color: #065f46;
}
&.confidence-medium {
background: #fef3c7;
color: #92400e;
}
&.confidence-low {
background: #fee2e2;
color: #991b1b;
}
}
.ai-chip {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.45rem;
border-radius: 0.75rem;
font-size: 0.65rem;
font-weight: 500;
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
border: 1px solid rgba(99, 102, 241, 0.2);
:host-context(.theme-dark) & {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
}
}
// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------
.ai-feedback {
display: flex;
gap: 0.2rem;
margin-left: 0.25rem;
&__btn {
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
padding: 0.1rem;
border-radius: 0.25rem;
opacity: 0.5;
transition: opacity 0.15s ease, background 0.15s ease;
&:hover:not([disabled]) {
opacity: 1;
background: rgba(0, 0, 0, 0.05);
}
&[disabled] {
cursor: default;
}
&--active-up {
opacity: 1;
filter: sepia(1) saturate(3) hue-rotate(90deg);
}
&--active-down {
opacity: 1;
filter: sepia(1) saturate(3) hue-rotate(320deg);
}
}
}
// ---------------------------------------------------------------------------
// Confirmation bar
// ---------------------------------------------------------------------------
.ai-confirm-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #eff6ff;
border-top: 1px solid #bfdbfe;
flex-shrink: 0;
:host-context(.theme-dark) & {
background: #1e3a5f;
border-top-color: #1d4ed8;
}
&__label {
font-size: 0.8rem;
font-weight: 600;
color: #1e40af;
flex: 1;
:host-context(.theme-dark) & {
color: #93c5fd;
}
}
&__btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: translateY(-1px);
opacity: 0.9;
}
&:active {
transform: translateY(0);
}
&--yes {
background: #10b981;
color: #fff;
}
&--no {
background: #ef4444;
color: #fff;
}
}
}
// ---------------------------------------------------------------------------
// Input area
// ---------------------------------------------------------------------------
.ai-input-area {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
flex-shrink: 0;
background: var(--light-background, #fff);
:host-context(.theme-dark) & {
background: #1e1e2e;
border-top-color: rgba(255, 255, 255, 0.08);
}
}
.ai-input {
flex: 1;
resize: none;
border: 1.5px solid #e5e7eb;
border-radius: 0.75rem;
padding: 0.6rem 0.9rem;
font-size: 0.875rem;
font-family: inherit;
background: #f9fafb;
color: inherit;
outline: none;
transition: border-color 0.15s ease;
max-height: 120px;
overflow-y: auto;
line-height: 1.5;
&:focus {
border-color: #6366f1;
background: #fff;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
:host-context(.theme-dark) & {
background: #2d2d3d;
border-color: rgba(255, 255, 255, 0.12);
color: #e2e8f0;
&:focus {
border-color: #818cf8;
background: #2d2d3d;
}
}
}
.ai-send {
width: 2.5rem;
height: 2.5rem;
border: none;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover:not([disabled]) {
transform: scale(1.05);
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
&__spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// ---------------------------------------------------------------------------
// Backdrop (mobile)
// ---------------------------------------------------------------------------
.ai-backdrop {
position: fixed;
inset: 0;
z-index: 1050;
background: rgba(0, 0, 0, 0.3);
@media (min-width: 600px) {
display: none;
}
}
// ---------------------------------------------------------------------------
// Markdown rendering inside bubbles
// ---------------------------------------------------------------------------
.ai-bubble {
::ng-deep {
p {
margin: 0 0 0.4em;
&:last-child {
margin-bottom: 0;
}
}
strong,
b {
font-weight: 700;
}
code {
background: rgba(0, 0, 0, 0.08);
border-radius: 3px;
padding: 0.1em 0.35em;
font-size: 0.85em;
font-family: 'JetBrains Mono', 'Fira Mono', monospace;
}
ul,
ol {
margin: 0.25em 0;
padding-left: 1.4em;
}
li {
margin-bottom: 0.15em;
}
hr {
border: none;
border-top: 1px solid rgba(0, 0, 0, 0.12);
margin: 0.5em 0;
}
}
}

228
apps/client/src/app/components/ai-chat/ai-chat.component.ts

@ -0,0 +1,228 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
OnDestroy,
ViewChild
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { AiMarkdownPipe } from './ai-markdown.pipe';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
toolsUsed?: string[];
confidence?: number;
latency?: number;
feedbackGiven?: 1 | -1 | null;
isWrite?: boolean;
}
interface AgentResponse {
response: string;
confidence_score: number;
awaiting_confirmation: boolean;
pending_write: Record<string, unknown> | null;
tools_used: string[];
latency_seconds: number;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, HttpClientModule, AiMarkdownPipe],
selector: 'gf-ai-chat',
styleUrls: ['./ai-chat.component.scss'],
templateUrl: './ai-chat.component.html'
})
export class GfAiChatComponent implements OnDestroy {
@ViewChild('messagesContainer') private messagesContainer: ElementRef;
public isOpen = false;
public isThinking = false;
public inputValue = '';
public messages: ChatMessage[] = [];
public successBanner = '';
// Write confirmation state
private pendingWrite: Record<string, unknown> | null = null;
public awaitingConfirmation = false;
private readonly AGENT_URL = '/agent/chat';
private readonly FEEDBACK_URL = '/agent/feedback';
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private http: HttpClient
) {}
public ngOnDestroy() {}
public togglePanel(): void {
this.isOpen = !this.isOpen;
if (this.isOpen && this.messages.length === 0) {
this.messages.push({
role: 'assistant',
content:
'Hello! I\'m your Portfolio Assistant. Ask me about your portfolio performance, transactions, tax estimates, or use commands like "buy 5 shares of AAPL" to record transactions.'
});
}
this.changeDetectorRef.markForCheck();
if (this.isOpen) {
setTimeout(() => this.scrollToBottom(), 50);
}
}
public closePanel(): void {
this.isOpen = false;
this.changeDetectorRef.markForCheck();
}
public onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendMessage();
}
}
public sendMessage(): void {
const query = this.inputValue.trim();
if (!query || this.isThinking) {
return;
}
this.inputValue = '';
this.doSend(query);
}
public confirmWrite(): void {
this.doSend('yes');
}
public cancelWrite(): void {
this.doSend('no');
}
private doSend(query: string): void {
this.messages.push({ role: 'user', content: query });
this.isThinking = true;
this.successBanner = '';
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
const body: {
query: string;
history: { role: string; content: string }[];
pending_write?: Record<string, unknown>;
} = {
query,
history: this.messages
.filter((m) => m.role === 'user')
.map((m) => ({ role: 'user', content: m.content }))
};
if (this.pendingWrite) {
body.pending_write = this.pendingWrite;
}
this.http.post<AgentResponse>(this.AGENT_URL, body).subscribe({
next: (data) => {
const isWriteSuccess =
data.tools_used.includes('write_transaction') &&
data.response.includes('✅');
const assistantMsg: ChatMessage = {
role: 'assistant',
content: data.response,
toolsUsed: data.tools_used,
confidence: data.confidence_score,
latency: data.latency_seconds,
feedbackGiven: null,
isWrite: isWriteSuccess
};
this.messages.push(assistantMsg);
this.awaitingConfirmation = data.awaiting_confirmation;
this.pendingWrite = data.pending_write;
if (isWriteSuccess) {
this.successBanner = '✅ Transaction recorded successfully';
setTimeout(() => {
this.successBanner = '';
this.changeDetectorRef.markForCheck();
}, 4000);
}
this.isThinking = false;
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
},
error: (err) => {
this.messages.push({
role: 'assistant',
content: `⚠️ Connection error: ${err.message || 'Could not reach the AI agent'}. Make sure the agent is running on port 8000.`
});
this.isThinking = false;
this.awaitingConfirmation = false;
this.pendingWrite = null;
this.changeDetectorRef.markForCheck();
this.scrollToBottom();
}
});
}
public giveFeedback(
msgIndex: number,
rating: 1 | -1
): void {
const msg = this.messages[msgIndex];
if (!msg || msg.feedbackGiven !== null) {
return;
}
msg.feedbackGiven = rating;
const userQuery =
msgIndex > 0 ? this.messages[msgIndex - 1].content : '';
this.http
.post(this.FEEDBACK_URL, {
query: userQuery,
response: msg.content,
rating
})
.subscribe();
this.changeDetectorRef.markForCheck();
}
public confidenceLabel(score: number): string {
if (score >= 0.8) {
return 'High';
}
if (score >= 0.6) {
return 'Medium';
}
return 'Low';
}
public confidenceClass(score: number): string {
if (score >= 0.8) {
return 'confidence-high';
}
if (score >= 0.6) {
return 'confidence-medium';
}
return 'confidence-low';
}
private scrollToBottom(): void {
setTimeout(() => {
if (this.messagesContainer?.nativeElement) {
const el = this.messagesContainer.nativeElement;
el.scrollTop = el.scrollHeight;
}
}, 30);
}
}

47
apps/client/src/app/components/ai-chat/ai-markdown.pipe.ts

@ -0,0 +1,47 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
/**
* Minimal Markdown-to-HTML pipe for chat messages.
* Handles: bold, inline code, bullet lists, line breaks, horizontal rules.
* Does NOT use an external library to keep the bundle lean.
*/
@Pipe({
name: 'aiMarkdown',
standalone: true
})
export class AiMarkdownPipe implements PipeTransform {
public constructor(private sanitizer: DomSanitizer) {}
public transform(value: string): SafeHtml {
if (!value) {
return '';
}
let html = value
// Escape HTML entities
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Bold **text** or __text__
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
// Inline code `code`
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Horizontal rule ---
.replace(/^---+$/gm, '<hr>')
// Bullet lines starting with "- " or "* "
.replace(/^[*\-] (.+)$/gm, '<li>$1</li>')
// Wrap consecutive <li> in <ul>
.replace(/(<li>.*<\/li>(\n|$))+/g, (block) => `<ul>${block}</ul>`)
// Newlines → <br> (except inside <ul>)
.replace(/\n/g, '<br>');
// Cleanup: remove <br> immediately before/after block elements
html = html
.replace(/<br>\s*(<\/?(?:ul|li|hr))/g, '$1')
.replace(/(<\/(?:ul|li)>)\s*<br>/g, '$1');
return this.sanitizer.bypassSecurityTrustHtml(html);
}
}

7
docker/docker-compose.override.yml

@ -0,0 +1,7 @@
services:
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
Loading…
Cancel
Save