diff --git a/apps/client/proxy.conf.json b/apps/client/proxy.conf.json
index a31371d9f..0ab5b4973 100644
--- a/apps/client/proxy.conf.json
+++ b/apps/client/proxy.conf.json
@@ -6,5 +6,12 @@
"/assets": {
"target": "http://0.0.0.0:3333",
"secure": false
+ },
+ "/agent": {
+ "target": "http://localhost:8000",
+ "secure": false,
+ "pathRewrite": {
+ "^/agent": ""
+ }
}
}
diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html
index 61d5023e2..f608cfd37 100644
--- a/apps/client/src/app/app.component.html
+++ b/apps/client/src/app/app.component.html
@@ -51,3 +51,6 @@
}
+
+
+
diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts
index a4af01124..4b11b843e 100644
--- a/apps/client/src/app/app.component.ts
+++ b/apps/client/src/app/app.component.ts
@@ -33,6 +33,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
+import { GfAiChatComponent } from './components/ai-chat/ai-chat.component';
import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component';
import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component';
@@ -43,7 +44,7 @@ import { UserService } from './services/user/user.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [GfFooterComponent, GfHeaderComponent, RouterLink, RouterOutlet],
+ imports: [GfAiChatComponent, GfFooterComponent, GfHeaderComponent, RouterLink, RouterOutlet],
selector: 'gf-root',
styleUrls: ['./app.component.scss'],
templateUrl: './app.component.html'
diff --git a/apps/client/src/app/components/ai-chat/ai-chat.component.html b/apps/client/src/app/components/ai-chat/ai-chat.component.html
new file mode 100644
index 000000000..c80f28ea9
--- /dev/null
+++ b/apps/client/src/app/components/ai-chat/ai-chat.component.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+ @if (successBanner) {
+
{{ successBanner }}
+ }
+
+
+
+ @for (msg of messages; track $index) {
+
+
+
+
+
+
+ @if (msg.role === 'assistant' && msg.confidence !== undefined) {
+
+ }
+
+
+ }
+
+
+ @if (isThinking) {
+
+ }
+
+
+
+ @if (awaitingConfirmation && !isThinking) {
+
+ Confirm this transaction?
+
+
+
+ }
+
+
+ @if (!awaitingConfirmation || isThinking) {
+
+ }
+
+
+
+
+@if (isOpen) {
+
+}
diff --git a/apps/client/src/app/components/ai-chat/ai-chat.component.scss b/apps/client/src/app/components/ai-chat/ai-chat.component.scss
new file mode 100644
index 000000000..8b8c76104
--- /dev/null
+++ b/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;
+ }
+ }
+}
diff --git a/apps/client/src/app/components/ai-chat/ai-chat.component.ts b/apps/client/src/app/components/ai-chat/ai-chat.component.ts
new file mode 100644
index 000000000..d4bfc3c20
--- /dev/null
+++ b/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 | 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 | 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;
+ } = {
+ 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(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);
+ }
+}
diff --git a/apps/client/src/app/components/ai-chat/ai-markdown.pipe.ts b/apps/client/src/app/components/ai-chat/ai-markdown.pipe.ts
new file mode 100644
index 000000000..b714c53ac
--- /dev/null
+++ b/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, '&')
+ .replace(//g, '>')
+ // Bold **text** or __text__
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/__(.+?)__/g, '$1')
+ // Inline code `code`
+ .replace(/`([^`]+)`/g, '$1')
+ // Horizontal rule ---
+ .replace(/^---+$/gm, '
')
+ // Bullet lines starting with "- " or "* "
+ .replace(/^[*\-] (.+)$/gm, '$1')
+ // Wrap consecutive in
+ .replace(/(- .*<\/li>(\n|$))+/g, (block) => ``)
+ // Newlines →
(except inside )
+ .replace(/\n/g, '
');
+
+ // Cleanup: remove
immediately before/after block elements
+ html = html
+ .replace(/
\s*(<\/?(?:ul|li|hr))/g, '$1')
+ .replace(/(<\/(?:ul|li)>)\s*
/g, '$1');
+
+ return this.sanitizer.bypassSecurityTrustHtml(html);
+ }
+}
diff --git a/docker/docker-compose.override.yml b/docker/docker-compose.override.yml
new file mode 100644
index 000000000..f53520c03
--- /dev/null
+++ b/docker/docker-compose.override.yml
@@ -0,0 +1,7 @@
+services:
+ postgres:
+ ports:
+ - "5432:5432"
+ redis:
+ ports:
+ - "6379:6379"