mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
- 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
8 changed files with 1056 additions and 1 deletions
@ -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> |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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, '<') |
||||
|
.replace(/>/g, '>') |
||||
|
// 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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
services: |
||||
|
postgres: |
||||
|
ports: |
||||
|
- "5432:5432" |
||||
|
redis: |
||||
|
ports: |
||||
|
- "6379:6379" |
||||
Loading…
Reference in new issue