mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
Add agent chat page with real-time streaming, markdown rendering, custom extensions (charts, allocation bars, sparklines, metrics), conversation history sidebar, and approval card UI. Lazy-loaded route at /agent with header navigation link.pull/6458/head
22 changed files with 3599 additions and 4 deletions
File diff suppressed because it is too large
@ -0,0 +1,317 @@ |
|||||
|
<div class="agent-container"> |
||||
|
<!-- Sidebar --> |
||||
|
<aside class="agent-sidebar"> |
||||
|
<div class="sidebar-header"> |
||||
|
<button class="new-chat-btn" (click)="onNewChat()"> |
||||
|
<ion-icon name="add-outline" size="small" /> |
||||
|
<span i18n>New Chat</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="conversation-list"> |
||||
|
@for (conversation of conversations; track conversation.id) { |
||||
|
<div class="conversation-item-wrapper"> |
||||
|
@if (renamingId === conversation.id) { |
||||
|
<input |
||||
|
autofocus |
||||
|
class="rename-input" |
||||
|
[value]="renameValue" |
||||
|
(blur)="confirmRename(conversation)" |
||||
|
(click)="$event.stopPropagation()" |
||||
|
(input)="renameValue = $any($event.target).value" |
||||
|
(keydown)="onRenameKeydown($event, conversation)" |
||||
|
/> |
||||
|
} @else { |
||||
|
<button |
||||
|
class="conversation-item" |
||||
|
[class.active]="activeConversation?.id === conversation.id" |
||||
|
(click)="onSelectConversation(conversation)" |
||||
|
> |
||||
|
@if (conversation.pinned) { |
||||
|
<ion-icon class="pin-badge" name="pin-outline" /> |
||||
|
} |
||||
|
<span class="conversation-title">{{ conversation.title }}</span> |
||||
|
</button> |
||||
|
<button |
||||
|
class="context-menu-trigger" |
||||
|
(click)="toggleMenu($event, conversation.id)" |
||||
|
> |
||||
|
<ion-icon name="ellipsis-vertical-outline" /> |
||||
|
</button> |
||||
|
@if (openMenuId === conversation.id) { |
||||
|
<div class="context-menu" (click)="$event.stopPropagation()"> |
||||
|
<button |
||||
|
class="context-menu-item" |
||||
|
(click)="startRename($event, conversation)" |
||||
|
> |
||||
|
<ion-icon name="create-outline" size="small" /> |
||||
|
<span i18n>Rename</span> |
||||
|
</button> |
||||
|
<button |
||||
|
class="context-menu-item" |
||||
|
(click)="togglePin($event, conversation)" |
||||
|
> |
||||
|
<ion-icon name="pin-outline" size="small" /> |
||||
|
<span>{{ conversation.pinned ? 'Unpin' : 'Pin' }}</span> |
||||
|
</button> |
||||
|
<button |
||||
|
class="context-menu-item delete" |
||||
|
(click)="deleteConversation($event, conversation)" |
||||
|
> |
||||
|
<ion-icon name="trash-outline" size="small" /> |
||||
|
<span i18n>Delete</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
} |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
@if (conversations.length === 0) { |
||||
|
<div class="no-conversations" i18n>No conversations yet</div> |
||||
|
} |
||||
|
</div> |
||||
|
</aside> |
||||
|
|
||||
|
<!-- Main Chat Area --> |
||||
|
<main class="agent-main"> |
||||
|
@if (!activeConversation || activeConversation.messages.length === 0) { |
||||
|
<!-- Empty State --> |
||||
|
<div class="chat-canvas"> |
||||
|
<div class="empty-state"> |
||||
|
<div class="empty-icon"> |
||||
|
<ion-icon name="chatbubble-ellipses-outline" /> |
||||
|
</div> |
||||
|
<h2 class="empty-title" i18n>Hello, Portfolio Owner</h2> |
||||
|
<p class="empty-subtitle" i18n> |
||||
|
I can help analyze your assets, check dividends, or rebalance. |
||||
|
</p> |
||||
|
<div class="prompts-grid"> |
||||
|
@for (card of promptCards; track card.title) { |
||||
|
<button class="prompt-card" (click)="onPromptCardClick(card)"> |
||||
|
<div class="prompt-icon"> |
||||
|
<ion-icon size="small" [name]="card.iconName" /> |
||||
|
</div> |
||||
|
<div class="prompt-text">{{ card.title }}</div> |
||||
|
<div class="prompt-sub">{{ card.subtitle }}</div> |
||||
|
</button> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
} @else { |
||||
|
<!-- Messages --> |
||||
|
<div #messagesContainer class="chat-canvas"> |
||||
|
<div class="chat-stream"> |
||||
|
@for (message of activeConversation.messages; track $index) { |
||||
|
@if ( |
||||
|
message.role === 'user' || |
||||
|
message.content || |
||||
|
message.html || |
||||
|
message.activeTools?.length || |
||||
|
message.pendingApproval |
||||
|
) { |
||||
|
<div |
||||
|
class="message-group" |
||||
|
[class.assistant]="message.role === 'assistant'" |
||||
|
[class.user]="message.role === 'user'" |
||||
|
> |
||||
|
<div class="bubble-wrapper"> |
||||
|
@if (message.activeTools?.length) { |
||||
|
<div |
||||
|
class="tool-indicator" |
||||
|
[class.completed]="!message.isStreaming" |
||||
|
> |
||||
|
@for (tool of message.activeTools; track tool) { |
||||
|
<span class="tool-label" |
||||
|
><code>{{ tool }}</code></span |
||||
|
> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
<div class="bubble" [class.streaming]="message.isStreaming"> |
||||
|
@if (message.role === 'assistant' && message.html) { |
||||
|
<div |
||||
|
class="markdown-body" |
||||
|
[innerHTML]="message.html" |
||||
|
(click)="onMarkdownClick($event)" |
||||
|
></div> |
||||
|
} @else { |
||||
|
{{ message.content }} |
||||
|
} |
||||
|
</div> |
||||
|
@if (message.pendingApproval) { |
||||
|
<div class="approval-card"> |
||||
|
<div class="approval-header">Confirm action</div> |
||||
|
<div class="approval-summary"> |
||||
|
<code class="approval-tool">{{ |
||||
|
formatToolName(message.pendingApproval.toolName) |
||||
|
}}</code> |
||||
|
<pre class="approval-detail">{{ |
||||
|
formatApprovalInput(message.pendingApproval) |
||||
|
}}</pre> |
||||
|
</div> |
||||
|
<div class="approval-actions"> |
||||
|
<button |
||||
|
class="approve-btn" |
||||
|
(click)="handleApproval(message, true)" |
||||
|
> |
||||
|
Approve |
||||
|
</button> |
||||
|
<button |
||||
|
class="deny-btn" |
||||
|
(click)="handleApproval(message, false)" |
||||
|
> |
||||
|
Deny |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
@if ( |
||||
|
message.role === 'assistant' && |
||||
|
message.requestId && |
||||
|
message.content && |
||||
|
!message.isStreaming |
||||
|
) { |
||||
|
<div class="action-bar"> |
||||
|
<button class="action-btn" (click)="copyMessage(message)"> |
||||
|
<ion-icon name="copy-outline" size="small" /> |
||||
|
<span class="action-tooltip">Copy</span> |
||||
|
</button> |
||||
|
<button |
||||
|
class="action-btn" |
||||
|
(click)="shareMessage(message)" |
||||
|
> |
||||
|
<ion-icon name="link-outline" size="small" /> |
||||
|
<span class="action-tooltip">Share link</span> |
||||
|
</button> |
||||
|
<button |
||||
|
class="action-btn" |
||||
|
[class.active-up]="message.feedbackRating === 1" |
||||
|
[disabled]="!!message.feedbackRating" |
||||
|
(click)="sendFeedback(message, 1)" |
||||
|
> |
||||
|
<ion-icon name="thumbs-up-outline" size="small" /> |
||||
|
<span class="action-tooltip">Love this</span> |
||||
|
</button> |
||||
|
<button |
||||
|
class="action-btn" |
||||
|
[class.active-down]="message.feedbackRating === -1" |
||||
|
[disabled]="!!message.feedbackRating" |
||||
|
(click)="sendFeedback(message, -1)" |
||||
|
> |
||||
|
<ion-icon name="thumbs-down-outline" size="small" /> |
||||
|
<span class="action-tooltip">Not helpful</span> |
||||
|
</button> |
||||
|
<button class="action-btn" disabled> |
||||
|
<ion-icon |
||||
|
name="ellipsis-horizontal-outline" |
||||
|
size="small" |
||||
|
/> |
||||
|
<span class="action-tooltip">More</span> |
||||
|
</button> |
||||
|
@if (message.latencyMs) { |
||||
|
<span |
||||
|
class="latency-badge" |
||||
|
(mouseenter)="prefetchVerification(message)" |
||||
|
> |
||||
|
{{ formatLatency(message.latencyMs) }} |
||||
|
<span |
||||
|
class="latency-tooltip" |
||||
|
[innerHTML]="buildVerificationTooltip(message)" |
||||
|
></span> |
||||
|
</span> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
} |
||||
|
@if ( |
||||
|
isLoading && |
||||
|
activeConversation.messages[activeConversation.messages.length - 1] |
||||
|
?.content === '' && |
||||
|
!activeConversation.messages[activeConversation.messages.length - 1] |
||||
|
?.activeTools?.length |
||||
|
) { |
||||
|
<div class="message-group assistant"> |
||||
|
<div class="bubble-wrapper"> |
||||
|
<div class="bubble"> |
||||
|
<div class="typing-indicator"> |
||||
|
<span></span><span></span><span></span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Input Area --> |
||||
|
<div class="input-container"> |
||||
|
<div class="input-wrapper"> |
||||
|
<textarea |
||||
|
#messageInput |
||||
|
class="chat-input" |
||||
|
i18n-placeholder |
||||
|
placeholder="Ask about your portfolio..." |
||||
|
rows="1" |
||||
|
[disabled]="isLoading" |
||||
|
[value]="inputValue" |
||||
|
(input)="onInputChange($event)" |
||||
|
(keydown)="onInputKeydown($event)" |
||||
|
></textarea> |
||||
|
<div class="input-actions"> |
||||
|
<div class="model-selector-container"> |
||||
|
<button |
||||
|
class="model-pill" |
||||
|
(click)=" |
||||
|
modelDropdownOpen = !modelDropdownOpen; $event.stopPropagation() |
||||
|
" |
||||
|
> |
||||
|
{{ getModelLabel(selectedModel) }} |
||||
|
<ion-icon |
||||
|
size="small" |
||||
|
[name]=" |
||||
|
modelDropdownOpen |
||||
|
? 'chevron-up-outline' |
||||
|
: 'chevron-down-outline' |
||||
|
" |
||||
|
/> |
||||
|
</button> |
||||
|
@if (modelDropdownOpen) { |
||||
|
<div class="model-dropdown" (click)="$event.stopPropagation()"> |
||||
|
@for (m of models; track m.id) { |
||||
|
<button |
||||
|
class="model-option" |
||||
|
[class.selected]="selectedModel === m.id" |
||||
|
(click)="selectModel(m.id)" |
||||
|
> |
||||
|
<div class="model-option-text"> |
||||
|
<span class="model-name">{{ m.label }}</span> |
||||
|
<span class="model-tier">{{ m.tier }}</span> |
||||
|
</div> |
||||
|
@if (selectedModel === m.id) { |
||||
|
<ion-icon |
||||
|
class="model-check" |
||||
|
name="checkmark-outline" |
||||
|
size="small" |
||||
|
/> |
||||
|
} |
||||
|
</button> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
<button |
||||
|
class="send-btn" |
||||
|
[disabled]="!inputValue.trim() || isLoading" |
||||
|
(click)="sendMessage()" |
||||
|
> |
||||
|
<ion-icon name="send-outline" size="small" /> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</main> |
||||
|
</div> |
||||
@ -0,0 +1,15 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
import { internalRoutes } from '@ghostfolio/common/routes/routes'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { GfAgentPageComponent } from './agent-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: GfAgentPageComponent, |
||||
|
path: '', |
||||
|
title: internalRoutes.agent.title |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,898 @@ |
|||||
|
:host { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
height: calc(100svh - var(--mat-toolbar-standard-height)); |
||||
|
color: rgb(var(--dark-primary-text)); |
||||
|
|
||||
|
// Theme-aware custom properties (light defaults) |
||||
|
--agent-text: rgba(var(--dark-primary-text)); |
||||
|
--agent-text-secondary: #6b7280; |
||||
|
--agent-text-tertiary: #9ca3af; |
||||
|
--agent-bg-surface: white; |
||||
|
--agent-bg-input: #f3f4f6; |
||||
|
--agent-bg-sidebar: rgba(var(--palette-foreground-base, 0, 0, 0), 0.02); |
||||
|
--agent-bg-hover: rgba(var(--palette-foreground-base, 0, 0, 0), 0.04); |
||||
|
--agent-bg-hover-strong: rgba(var(--palette-foreground-base, 0, 0, 0), 0.08); |
||||
|
--agent-border: #e5e7eb; |
||||
|
--agent-bubble-user: #f3f4f6; |
||||
|
--agent-ctx-bg: white; |
||||
|
--agent-tooltip-bg: #f3f4f6; |
||||
|
--agent-shadow-card: |
||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); |
||||
|
--agent-shadow-float: |
||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025); |
||||
|
--agent-send-bg: #4db6ac; |
||||
|
--agent-send-hover: #3aa198; |
||||
|
--agent-send-disabled-bg: #9ca3af; |
||||
|
--agent-send-disabled-color: white; |
||||
|
--agent-prompt-card-bg: white; |
||||
|
--agent-prompt-card-border: transparent; |
||||
|
--agent-prompt-card-shadow: var(--agent-shadow-card); |
||||
|
--agent-prompt-card-hover-shadow: var(--agent-shadow-float); |
||||
|
--agent-input-focus-shadow: 0 0 0 3px #e0f2f1; |
||||
|
--agent-rename-shadow: 0 0 0 2px #e0f2f1; |
||||
|
--agent-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; |
||||
|
} |
||||
|
|
||||
|
:host-context(.theme-dark) { |
||||
|
color: rgb(var(--light-primary-text)); |
||||
|
|
||||
|
--agent-text: rgba(var(--light-primary-text)); |
||||
|
--agent-text-secondary: rgba(255, 255, 255, 0.5); |
||||
|
--agent-text-tertiary: rgba(255, 255, 255, 0.4); |
||||
|
--agent-bg-surface: rgb(var(--dark-background)); |
||||
|
--agent-bg-input: rgba(255, 255, 255, 0.06); |
||||
|
--agent-bg-sidebar: rgba(255, 255, 255, 0.03); |
||||
|
--agent-bg-hover: rgba(255, 255, 255, 0.06); |
||||
|
--agent-bg-hover-strong: rgba(255, 255, 255, 0.12); |
||||
|
--agent-border: rgba(255, 255, 255, 0.12); |
||||
|
--agent-bubble-user: rgba(255, 255, 255, 0.08); |
||||
|
--agent-ctx-bg: #2a2a2a; |
||||
|
--agent-tooltip-bg: #333; |
||||
|
--agent-shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.2); |
||||
|
--agent-shadow-float: 0 4px 12px rgba(0, 0, 0, 0.3); |
||||
|
--agent-send-bg: rgba(var(--palette-primary-500), 1); |
||||
|
--agent-send-hover: rgba(var(--palette-primary-500), 0.85); |
||||
|
--agent-send-disabled-bg: rgba(255, 255, 255, 0.12); |
||||
|
--agent-send-disabled-color: rgba(255, 255, 255, 0.3); |
||||
|
--agent-prompt-card-bg: rgba(255, 255, 255, 0.05); |
||||
|
--agent-prompt-card-border: rgba(255, 255, 255, 0.08); |
||||
|
--agent-prompt-card-shadow: none; |
||||
|
--agent-prompt-card-hover-shadow: var(--agent-shadow-float); |
||||
|
--agent-input-focus-shadow: 0 0 0 3px rgba(77, 182, 172, 0.15); |
||||
|
--agent-rename-shadow: 0 0 0 2px rgba(77, 182, 172, 0.15); |
||||
|
--agent-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; |
||||
|
} |
||||
|
|
||||
|
// Tokens |
||||
|
$primary: #4db6ac; |
||||
|
$primary-dark: #3aa198; |
||||
|
$primary-light: #e0f2f1; |
||||
|
$radius-lg: 16px; |
||||
|
$radius-md: 12px; |
||||
|
$radius-sm: 8px; |
||||
|
|
||||
|
.agent-container { |
||||
|
display: flex; |
||||
|
height: 100%; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.agent-sidebar { |
||||
|
width: 14rem; |
||||
|
min-width: 14rem; |
||||
|
background: var(--agent-bg-sidebar); |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
padding: 1rem 0; |
||||
|
} |
||||
|
|
||||
|
.sidebar-header { |
||||
|
padding: 0 0.5rem 0.5rem; |
||||
|
display: flex; |
||||
|
gap: 4px; |
||||
|
} |
||||
|
|
||||
|
.new-chat-btn { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: flex-start; |
||||
|
gap: 0.5rem; |
||||
|
height: 2.25rem; |
||||
|
width: 100%; |
||||
|
padding: 0 0.75rem; |
||||
|
border: none; |
||||
|
border-radius: $radius-sm; |
||||
|
background: var(--agent-bg-hover); |
||||
|
color: var(--agent-text); |
||||
|
font-size: 14px; |
||||
|
font-weight: 500; |
||||
|
font-family: inherit; |
||||
|
cursor: pointer; |
||||
|
transition: background 0.15s; |
||||
|
|
||||
|
&:hover { |
||||
|
background: var(--agent-bg-hover-strong); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.conversation-list { |
||||
|
flex: 1; |
||||
|
overflow-y: auto; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
|
||||
|
.conversation-item-wrapper { |
||||
|
position: relative; |
||||
|
border-radius: 4px; |
||||
|
transition: background 0.15s; |
||||
|
|
||||
|
&:hover { |
||||
|
background: var(--agent-bg-hover); |
||||
|
|
||||
|
.context-menu-trigger { |
||||
|
opacity: 1; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.conversation-item { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: flex-start; |
||||
|
height: 2rem; |
||||
|
width: 100%; |
||||
|
padding: 0 28px 0 1rem; |
||||
|
border: none; |
||||
|
background: transparent; |
||||
|
cursor: pointer; |
||||
|
text-align: left; |
||||
|
color: var(--agent-text); |
||||
|
font-size: 14px; |
||||
|
font-family: inherit; |
||||
|
|
||||
|
&.active { |
||||
|
color: var( |
||||
|
--mat-tab-active-label-text-color, |
||||
|
rgba(var(--palette-primary-500), 1) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
ion-icon { |
||||
|
flex-shrink: 0; |
||||
|
margin-right: 0.5rem; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.conversation-title { |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.pin-badge { |
||||
|
font-size: 12px; |
||||
|
color: var(--agent-text-tertiary); |
||||
|
flex-shrink: 0; |
||||
|
margin-right: 4px; |
||||
|
} |
||||
|
|
||||
|
.context-menu-trigger { |
||||
|
position: absolute; |
||||
|
right: 4px; |
||||
|
top: 50%; |
||||
|
transform: translateY(-50%); |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
width: 24px; |
||||
|
height: 24px; |
||||
|
border: none; |
||||
|
border-radius: 4px; |
||||
|
background: transparent; |
||||
|
color: var(--agent-text-tertiary); |
||||
|
cursor: pointer; |
||||
|
opacity: 0; |
||||
|
transition: opacity 0.12s; |
||||
|
font-size: 16px; |
||||
|
} |
||||
|
|
||||
|
.context-menu { |
||||
|
position: absolute; |
||||
|
top: 100%; |
||||
|
right: 4px; |
||||
|
min-width: 140px; |
||||
|
background: var(--agent-ctx-bg); |
||||
|
border: 1px solid var(--agent-border); |
||||
|
border-radius: $radius-sm; |
||||
|
box-shadow: var(--agent-shadow-float); |
||||
|
z-index: 100; |
||||
|
padding: 4px 0; |
||||
|
} |
||||
|
|
||||
|
.context-menu-item { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
width: 100%; |
||||
|
padding: 6px 12px; |
||||
|
border: none; |
||||
|
background: transparent; |
||||
|
color: var(--agent-text); |
||||
|
font-size: 13px; |
||||
|
font-family: inherit; |
||||
|
cursor: pointer; |
||||
|
transition: background 0.12s; |
||||
|
|
||||
|
&:hover { |
||||
|
background: var(--agent-bg-hover); |
||||
|
} |
||||
|
|
||||
|
&.delete { |
||||
|
color: #f44336; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.rename-input { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
height: 2rem; |
||||
|
width: 100%; |
||||
|
padding: 0 0.75rem; |
||||
|
border: 1px solid $primary; |
||||
|
border-radius: 4px; |
||||
|
background: var(--agent-bg-surface); |
||||
|
color: var(--agent-text); |
||||
|
font-size: 14px; |
||||
|
font-family: inherit; |
||||
|
outline: none; |
||||
|
box-shadow: var(--agent-rename-shadow); |
||||
|
} |
||||
|
|
||||
|
.no-conversations { |
||||
|
padding: 1rem; |
||||
|
color: var(--agent-text-tertiary); |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
.agent-main { |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
min-width: 0; |
||||
|
background: var(--agent-bg-surface); |
||||
|
} |
||||
|
|
||||
|
.chat-canvas { |
||||
|
flex: 1; |
||||
|
overflow-y: auto; |
||||
|
padding: 2rem 0; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
|
||||
|
.chat-stream { |
||||
|
max-width: 800px; |
||||
|
margin: 0 auto; |
||||
|
width: 100%; |
||||
|
padding: 0 1rem; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 24px; |
||||
|
padding-bottom: 24px; |
||||
|
} |
||||
|
|
||||
|
.empty-state { |
||||
|
margin: auto; |
||||
|
text-align: center; |
||||
|
max-width: 600px; |
||||
|
width: 100%; |
||||
|
padding: 0 1rem; |
||||
|
animation: fadeIn 0.4s ease-out; |
||||
|
} |
||||
|
|
||||
|
.empty-icon { |
||||
|
width: 64px; |
||||
|
height: 64px; |
||||
|
background: linear-gradient(135deg, $primary-light, #fff); |
||||
|
border-radius: 9999px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
margin: 0 auto 24px; |
||||
|
color: $primary; |
||||
|
box-shadow: var(--agent-shadow-card); |
||||
|
font-size: 28px; |
||||
|
} |
||||
|
|
||||
|
.empty-title { |
||||
|
font-size: 24px; |
||||
|
font-weight: 700; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
.empty-subtitle { |
||||
|
font-size: 15px; |
||||
|
color: var(--agent-text-secondary); |
||||
|
margin-bottom: 32px; |
||||
|
} |
||||
|
|
||||
|
.prompts-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(2, 1fr); |
||||
|
gap: 12px; |
||||
|
text-align: left; |
||||
|
} |
||||
|
|
||||
|
.prompt-card { |
||||
|
background: var(--agent-prompt-card-bg); |
||||
|
padding: 16px; |
||||
|
border-radius: $radius-md; |
||||
|
border: 1px solid var(--agent-prompt-card-border); |
||||
|
box-shadow: var(--agent-prompt-card-shadow); |
||||
|
cursor: pointer; |
||||
|
transition: all 0.2s; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 8px; |
||||
|
font-family: inherit; |
||||
|
|
||||
|
&:hover { |
||||
|
border-color: $primary; |
||||
|
transform: translateY(-2px); |
||||
|
box-shadow: var(--agent-prompt-card-hover-shadow); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.prompt-icon { |
||||
|
color: $primary; |
||||
|
margin-bottom: 4px; |
||||
|
} |
||||
|
|
||||
|
.prompt-text { |
||||
|
font-size: 14px; |
||||
|
font-weight: 500; |
||||
|
color: var(--agent-text); |
||||
|
} |
||||
|
|
||||
|
.prompt-sub { |
||||
|
font-size: 12px; |
||||
|
color: var(--agent-text-secondary); |
||||
|
} |
||||
|
|
||||
|
// Messages |
||||
|
.message-group { |
||||
|
display: flex; |
||||
|
gap: 12px; |
||||
|
|
||||
|
&.assistant { |
||||
|
align-self: flex-start; |
||||
|
max-width: 100%; |
||||
|
} |
||||
|
|
||||
|
&.user { |
||||
|
align-self: flex-end; |
||||
|
flex-direction: row-reverse; |
||||
|
max-width: 80%; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.avatar { |
||||
|
width: 28px; |
||||
|
height: 28px; |
||||
|
border-radius: 9999px; |
||||
|
flex-shrink: 0; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
background: $primary; |
||||
|
color: white; |
||||
|
font-size: 14px; |
||||
|
margin-top: 2px; |
||||
|
} |
||||
|
|
||||
|
.bubble-wrapper { |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.tool-indicator { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 4px; |
||||
|
padding: 4px 0; |
||||
|
} |
||||
|
|
||||
|
.tool-label { |
||||
|
font-size: 13px; |
||||
|
color: var(--agent-text-secondary); |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
|
||||
|
code { |
||||
|
background: rgba($primary, 0.15); |
||||
|
color: $primary; |
||||
|
padding: 2px 6px; |
||||
|
border-radius: 4px; |
||||
|
font-size: 12px; |
||||
|
font-family: var(--agent-mono); |
||||
|
} |
||||
|
|
||||
|
&::before { |
||||
|
content: ''; |
||||
|
display: inline-block; |
||||
|
width: 6px; |
||||
|
height: 6px; |
||||
|
border-radius: 50%; |
||||
|
background: $primary; |
||||
|
margin-right: 8px; |
||||
|
animation: pulse-dot 1.4s infinite; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.tool-indicator.completed .tool-label { |
||||
|
opacity: 0.5; |
||||
|
|
||||
|
&::before { |
||||
|
animation: none; |
||||
|
opacity: 0.5; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes pulse-dot { |
||||
|
0%, |
||||
|
100% { |
||||
|
opacity: 0.4; |
||||
|
} |
||||
|
50% { |
||||
|
opacity: 1; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Approval card |
||||
|
.approval-card { |
||||
|
margin-top: 12px; |
||||
|
border: 1px solid var(--agent-border); |
||||
|
border-radius: $radius-md; |
||||
|
padding: 16px; |
||||
|
background: var(--agent-bg-surface); |
||||
|
box-shadow: var(--agent-shadow-card); |
||||
|
} |
||||
|
|
||||
|
.approval-header { |
||||
|
font-size: 13px; |
||||
|
font-weight: 600; |
||||
|
color: var(--agent-text-secondary); |
||||
|
margin-bottom: 10px; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.03em; |
||||
|
} |
||||
|
|
||||
|
.approval-summary { |
||||
|
margin-bottom: 14px; |
||||
|
} |
||||
|
|
||||
|
.approval-tool { |
||||
|
display: inline-block; |
||||
|
background: rgba($primary, 0.15); |
||||
|
color: $primary; |
||||
|
padding: 2px 8px; |
||||
|
border-radius: 4px; |
||||
|
font-size: 12px; |
||||
|
font-family: var(--agent-mono); |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
.approval-detail { |
||||
|
margin: 0; |
||||
|
padding: 10px 12px; |
||||
|
background: var(--agent-bg-input); |
||||
|
border-radius: $radius-sm; |
||||
|
font-size: 13px; |
||||
|
font-family: var(--agent-mono); |
||||
|
color: var(--agent-text); |
||||
|
white-space: pre-wrap; |
||||
|
word-break: break-word; |
||||
|
line-height: 1.5; |
||||
|
} |
||||
|
|
||||
|
.approval-actions { |
||||
|
display: flex; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
|
||||
|
.approve-btn, |
||||
|
.deny-btn { |
||||
|
padding: 6px 16px; |
||||
|
border-radius: $radius-sm; |
||||
|
border: none; |
||||
|
font-size: 13px; |
||||
|
font-weight: 600; |
||||
|
font-family: inherit; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.15s; |
||||
|
} |
||||
|
|
||||
|
.approve-btn { |
||||
|
background: $primary; |
||||
|
color: white; |
||||
|
|
||||
|
&:hover { |
||||
|
background: $primary-dark; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.deny-btn { |
||||
|
background: transparent; |
||||
|
border: 1px solid var(--agent-border); |
||||
|
color: var(--agent-text-secondary); |
||||
|
|
||||
|
&:hover { |
||||
|
background: var(--agent-bg-hover); |
||||
|
color: #f44336; |
||||
|
border-color: #f44336; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.bubble { |
||||
|
font-size: 15px; |
||||
|
line-height: 1.5; |
||||
|
} |
||||
|
|
||||
|
.message-group.user .bubble { |
||||
|
padding: 10px 16px; |
||||
|
background: var(--agent-bubble-user); |
||||
|
color: var(--agent-text); |
||||
|
border-radius: $radius-lg; |
||||
|
border-bottom-right-radius: 4px; |
||||
|
} |
||||
|
|
||||
|
.action-bar { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 4px; |
||||
|
margin-top: 6px; |
||||
|
} |
||||
|
|
||||
|
.action-btn { |
||||
|
position: relative; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
width: 28px; |
||||
|
height: 28px; |
||||
|
border: 1px solid transparent; |
||||
|
border-radius: 6px; |
||||
|
background: transparent; |
||||
|
cursor: pointer; |
||||
|
color: var(--agent-text-tertiary); |
||||
|
transition: all 0.15s; |
||||
|
|
||||
|
&:hover:not(:disabled) { |
||||
|
background: var(--agent-bg-input); |
||||
|
color: var(--agent-text); |
||||
|
|
||||
|
.action-tooltip { |
||||
|
opacity: 1; |
||||
|
pointer-events: auto; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
&.active-up { |
||||
|
color: #4caf50; |
||||
|
border-color: #4caf50; |
||||
|
} |
||||
|
|
||||
|
&.active-down { |
||||
|
color: #f44336; |
||||
|
border-color: #f44336; |
||||
|
} |
||||
|
|
||||
|
&:disabled { |
||||
|
opacity: 0.5; |
||||
|
cursor: default; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.action-tooltip { |
||||
|
position: absolute; |
||||
|
top: calc(100% + 6px); |
||||
|
left: 50%; |
||||
|
transform: translateX(-50%); |
||||
|
white-space: nowrap; |
||||
|
font-size: 12px; |
||||
|
font-weight: 500; |
||||
|
line-height: 1; |
||||
|
padding: 5px 8px; |
||||
|
border-radius: 6px; |
||||
|
background: var(--agent-tooltip-bg); |
||||
|
color: var(--agent-text); |
||||
|
border: 1px solid var(--agent-border); |
||||
|
opacity: 0; |
||||
|
pointer-events: none; |
||||
|
transition: opacity 0.12s; |
||||
|
z-index: 10; |
||||
|
} |
||||
|
|
||||
|
.latency-badge { |
||||
|
position: relative; |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
height: 24px; |
||||
|
padding: 0 4px; |
||||
|
margin-left: 4px; |
||||
|
border: none; |
||||
|
background: transparent; |
||||
|
color: var(--agent-text-tertiary); |
||||
|
font-size: 11px; |
||||
|
font-family: var(--agent-mono); |
||||
|
cursor: default; |
||||
|
transition: color 0.15s; |
||||
|
|
||||
|
&:hover { |
||||
|
color: var(--agent-text-secondary); |
||||
|
|
||||
|
.latency-tooltip { |
||||
|
opacity: 1; |
||||
|
pointer-events: auto; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.latency-tooltip { |
||||
|
position: absolute; |
||||
|
left: calc(100% + 8px); |
||||
|
top: 50%; |
||||
|
transform: translateY(-50%); |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
min-width: 200px; |
||||
|
padding: 8px 12px; |
||||
|
border-radius: $radius-sm; |
||||
|
background: var(--agent-tooltip-bg); |
||||
|
border: 1px solid var(--agent-border); |
||||
|
font-size: 12px; |
||||
|
opacity: 0; |
||||
|
pointer-events: none; |
||||
|
transition: opacity 0.12s; |
||||
|
z-index: 10; |
||||
|
} |
||||
|
|
||||
|
.typing-indicator { |
||||
|
display: flex; |
||||
|
gap: 4px; |
||||
|
padding: 4px 0; |
||||
|
|
||||
|
span { |
||||
|
width: 6px; |
||||
|
height: 6px; |
||||
|
border-radius: 50%; |
||||
|
background: $primary; |
||||
|
opacity: 0.4; |
||||
|
animation: typing 1.4s infinite; |
||||
|
|
||||
|
&:nth-child(2) { |
||||
|
animation-delay: 0.2s; |
||||
|
} |
||||
|
&:nth-child(3) { |
||||
|
animation-delay: 0.4s; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes typing { |
||||
|
0%, |
||||
|
60%, |
||||
|
100% { |
||||
|
opacity: 0.4; |
||||
|
transform: scale(1); |
||||
|
} |
||||
|
30% { |
||||
|
opacity: 1; |
||||
|
transform: scale(1.2); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.input-container { |
||||
|
position: relative; |
||||
|
z-index: 10; |
||||
|
padding: 16px 24px; |
||||
|
background: var(--agent-bg-surface); |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
|
||||
|
.input-wrapper { |
||||
|
max-width: 800px; |
||||
|
width: 100%; |
||||
|
display: flex; |
||||
|
align-items: flex-end; |
||||
|
background: var(--agent-bg-input); |
||||
|
border-radius: $radius-lg; |
||||
|
padding: 8px; |
||||
|
border: 1px solid transparent; |
||||
|
transition: |
||||
|
border-color 0.2s, |
||||
|
background 0.2s; |
||||
|
|
||||
|
&:focus-within { |
||||
|
border-color: $primary; |
||||
|
background: var(--agent-bg-surface); |
||||
|
box-shadow: var(--agent-input-focus-shadow); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.input-actions { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 12px; |
||||
|
flex-shrink: 0; |
||||
|
margin-bottom: 2px; |
||||
|
} |
||||
|
|
||||
|
.chat-input { |
||||
|
flex: 1; |
||||
|
background: transparent; |
||||
|
border: none; |
||||
|
padding: 8px 12px; |
||||
|
font-family: inherit; |
||||
|
font-size: 15px; |
||||
|
color: var(--agent-text); |
||||
|
resize: none; |
||||
|
max-height: 120px; |
||||
|
min-height: 24px; |
||||
|
outline: none; |
||||
|
line-height: 1.5; |
||||
|
|
||||
|
&::placeholder { |
||||
|
color: var(--agent-text-tertiary); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.send-btn { |
||||
|
width: 36px; |
||||
|
height: 36px; |
||||
|
border-radius: $radius-sm; |
||||
|
border: none; |
||||
|
background: var(--agent-send-bg); |
||||
|
color: white; |
||||
|
cursor: pointer; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
flex-shrink: 0; |
||||
|
margin-bottom: 2px; |
||||
|
transition: background 0.15s; |
||||
|
|
||||
|
&:hover:not(:disabled) { |
||||
|
background: var(--agent-send-hover); |
||||
|
} |
||||
|
|
||||
|
&:disabled { |
||||
|
background: var(--agent-send-disabled-bg); |
||||
|
color: var(--agent-send-disabled-color); |
||||
|
cursor: not-allowed; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes fadeIn { |
||||
|
from { |
||||
|
opacity: 0; |
||||
|
transform: translateY(10px); |
||||
|
} |
||||
|
to { |
||||
|
opacity: 1; |
||||
|
transform: translateY(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Model selector |
||||
|
.model-selector-container { |
||||
|
position: relative; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.model-pill { |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
gap: 4px; |
||||
|
height: 36px; |
||||
|
padding: 0 12px; |
||||
|
border: none; |
||||
|
border-radius: $radius-sm; |
||||
|
background: var(--agent-bg-hover-strong); |
||||
|
color: var(--agent-text-secondary); |
||||
|
font-size: 13px; |
||||
|
font-weight: 600; |
||||
|
font-family: inherit; |
||||
|
cursor: pointer; |
||||
|
white-space: nowrap; |
||||
|
transition: |
||||
|
background 0.15s, |
||||
|
color 0.15s; |
||||
|
|
||||
|
&:hover { |
||||
|
color: var(--agent-text); |
||||
|
} |
||||
|
|
||||
|
ion-icon { |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.model-dropdown { |
||||
|
position: absolute; |
||||
|
bottom: calc(100% + 8px); |
||||
|
right: 0; |
||||
|
min-width: 220px; |
||||
|
background: var(--agent-bg-surface); |
||||
|
border: 1px solid var(--agent-border); |
||||
|
border-radius: $radius-sm; |
||||
|
box-shadow: var(--agent-shadow-float); |
||||
|
z-index: 20; |
||||
|
padding: 6px; |
||||
|
} |
||||
|
|
||||
|
.model-option { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
gap: 12px; |
||||
|
width: 100%; |
||||
|
padding: 10px 12px; |
||||
|
border: none; |
||||
|
border-radius: $radius-sm; |
||||
|
background: transparent; |
||||
|
color: var(--agent-text); |
||||
|
font-size: 14px; |
||||
|
font-family: inherit; |
||||
|
cursor: pointer; |
||||
|
transition: background 0.12s; |
||||
|
text-align: left; |
||||
|
|
||||
|
&:hover, |
||||
|
&.selected { |
||||
|
background: var(--agent-bg-hover); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.model-option-text { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 1px; |
||||
|
} |
||||
|
|
||||
|
.model-name { |
||||
|
font-weight: 600; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
.model-tier { |
||||
|
font-size: 12px; |
||||
|
color: var(--agent-text-tertiary); |
||||
|
} |
||||
|
|
||||
|
.model-check { |
||||
|
color: var(--agent-text); |
||||
|
font-size: 16px; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 768px) { |
||||
|
.agent-sidebar { |
||||
|
display: none; |
||||
|
} |
||||
|
|
||||
|
.prompts-grid { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
|
||||
|
.chat-canvas { |
||||
|
padding: 1rem 0; |
||||
|
} |
||||
|
|
||||
|
.input-container { |
||||
|
padding: 12px 16px; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,143 @@ |
|||||
|
import { |
||||
|
BarController, |
||||
|
BarElement, |
||||
|
CategoryScale, |
||||
|
Chart, |
||||
|
Filler, |
||||
|
LinearScale, |
||||
|
LineController, |
||||
|
LineElement, |
||||
|
PointElement, |
||||
|
Tooltip |
||||
|
} from 'chart.js'; |
||||
|
|
||||
|
Chart.register( |
||||
|
BarController, |
||||
|
BarElement, |
||||
|
CategoryScale, |
||||
|
Filler, |
||||
|
LinearScale, |
||||
|
LineController, |
||||
|
LineElement, |
||||
|
PointElement, |
||||
|
Tooltip |
||||
|
); |
||||
|
|
||||
|
const PRIMARY_COLOR = '#4db6ac'; |
||||
|
const PRIMARY_FILL = 'rgba(77, 182, 172, 0.1)'; |
||||
|
|
||||
|
export class ChartInitializer { |
||||
|
private observer: MutationObserver | null = null; |
||||
|
private charts = new Map<HTMLCanvasElement, Chart>(); |
||||
|
private container: HTMLElement | null = null; |
||||
|
|
||||
|
attach(container: HTMLElement) { |
||||
|
this.container = container; |
||||
|
|
||||
|
this.observer?.disconnect(); |
||||
|
this.observer = new MutationObserver(() => this.scan()); |
||||
|
this.observer.observe(container, { childList: true, subtree: true }); |
||||
|
|
||||
|
this.scan(); |
||||
|
} |
||||
|
|
||||
|
reattach(container: HTMLElement) { |
||||
|
this.attach(container); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Scan for uninitialized canvases and clean up stale chart references. |
||||
|
* Safe to call repeatedly — idempotent. |
||||
|
*/ |
||||
|
scan() { |
||||
|
// Clean up charts whose canvas was removed from the DOM
|
||||
|
for (const [canvas, chart] of this.charts) { |
||||
|
if (!canvas.isConnected) { |
||||
|
chart.destroy(); |
||||
|
this.charts.delete(canvas); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Initialize any new canvases
|
||||
|
this.container |
||||
|
?.querySelectorAll<HTMLCanvasElement>('canvas.c-chart-canvas') |
||||
|
.forEach((c) => this.initCanvas(c)); |
||||
|
} |
||||
|
|
||||
|
detach() { |
||||
|
this.observer?.disconnect(); |
||||
|
this.observer = null; |
||||
|
this.container = null; |
||||
|
this.charts.forEach((chart) => chart.destroy()); |
||||
|
this.charts.clear(); |
||||
|
} |
||||
|
|
||||
|
private initCanvas(canvas: HTMLCanvasElement) { |
||||
|
if (this.charts.has(canvas)) return; |
||||
|
|
||||
|
// Chart config is stored as JSON in the canvas fallback text,
|
||||
|
// since Angular's sanitizer strips data-* attributes from [innerHTML].
|
||||
|
let type: 'area' | 'bar'; |
||||
|
let labels: string[]; |
||||
|
let values: number[]; |
||||
|
|
||||
|
try { |
||||
|
const config = JSON.parse(canvas.textContent ?? ''); |
||||
|
type = config.type; |
||||
|
labels = config.labels; |
||||
|
values = config.values; |
||||
|
} catch { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!labels?.length || !values?.length) return; |
||||
|
|
||||
|
// Clear fallback text before Chart.js init
|
||||
|
canvas.textContent = ''; |
||||
|
|
||||
|
const chart = new Chart(canvas, { |
||||
|
type: type === 'bar' ? 'bar' : 'line', |
||||
|
data: { |
||||
|
labels, |
||||
|
datasets: [ |
||||
|
{ |
||||
|
data: values, |
||||
|
borderColor: PRIMARY_COLOR, |
||||
|
backgroundColor: type === 'bar' ? PRIMARY_COLOR : PRIMARY_FILL, |
||||
|
fill: type !== 'bar', |
||||
|
tension: 0.3, |
||||
|
borderWidth: 2, |
||||
|
pointRadius: 3, |
||||
|
pointBackgroundColor: PRIMARY_COLOR |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
options: { |
||||
|
responsive: true, |
||||
|
maintainAspectRatio: false, |
||||
|
plugins: { |
||||
|
legend: { display: false }, |
||||
|
tooltip: { |
||||
|
backgroundColor: '#1f2937', |
||||
|
titleFont: { size: 12 }, |
||||
|
bodyFont: { size: 12 }, |
||||
|
padding: 8, |
||||
|
cornerRadius: 6 |
||||
|
} |
||||
|
}, |
||||
|
scales: { |
||||
|
x: { |
||||
|
grid: { display: false }, |
||||
|
ticks: { font: { size: 11 }, color: '#6b7280' } |
||||
|
}, |
||||
|
y: { |
||||
|
grid: { color: 'rgba(0,0,0,0.05)' }, |
||||
|
ticks: { font: { size: 11 }, color: '#6b7280' } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.charts.set(canvas, chart); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
import type { marked as MarkedType } from 'marked'; |
||||
|
|
||||
|
import { allocationExtension } from './extensions/allocation'; |
||||
|
import { chartAreaExtension, chartBarExtension } from './extensions/chart'; |
||||
|
import { metricsExtension } from './extensions/metrics'; |
||||
|
import { suggestionsExtension } from './extensions/pills'; |
||||
|
import { sparklineExtension } from './extensions/sparkline'; |
||||
|
import { tableRenderer } from './table-renderer'; |
||||
|
|
||||
|
export function configureMarked(marked: typeof MarkedType) { |
||||
|
// Register custom table renderer
|
||||
|
marked.use({ |
||||
|
renderer: { |
||||
|
table(token) { |
||||
|
const result = tableRenderer(token); |
||||
|
if (result === false) return false; |
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Register fenced block extensions
|
||||
|
marked.use({ |
||||
|
extensions: [ |
||||
|
allocationExtension, |
||||
|
metricsExtension, |
||||
|
sparklineExtension, |
||||
|
chartAreaExtension, |
||||
|
chartBarExtension, |
||||
|
suggestionsExtension |
||||
|
] |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,40 @@ |
|||||
|
import type { TokenizerAndRendererExtension } from 'marked'; |
||||
|
|
||||
|
import { parseLines } from '../line-parser'; |
||||
|
|
||||
|
export const allocationExtension: TokenizerAndRendererExtension = { |
||||
|
name: 'allocation', |
||||
|
level: 'block', |
||||
|
start(src: string) { |
||||
|
return src.indexOf('```allocation'); |
||||
|
}, |
||||
|
tokenizer(src: string) { |
||||
|
const match = src.match(/^```allocation\n([\s\S]*?)```/); |
||||
|
if (!match) return undefined; |
||||
|
return { |
||||
|
type: 'allocation', |
||||
|
raw: match[0], |
||||
|
body: match[1] |
||||
|
}; |
||||
|
}, |
||||
|
renderer(token: any) { |
||||
|
const lines = parseLines(token.body); |
||||
|
if (!lines.length) return ''; |
||||
|
|
||||
|
const rows = lines |
||||
|
.map((line) => { |
||||
|
const pct = parseFloat(line.value); |
||||
|
const width = isNaN(pct) ? 0 : Math.min(Math.max(pct, 0), 100); |
||||
|
return `<div class="c-progress-row">
|
||||
|
<div class="c-progress-meta"> |
||||
|
<span class="c-progress-label">${line.label}</span> |
||||
|
<span class="c-progress-value">${line.value}</span> |
||||
|
</div> |
||||
|
<div class="c-alloc-bar"><div class="c-alloc-fill" style="width:${width}%"></div></div> |
||||
|
</div>`;
|
||||
|
}) |
||||
|
.join(''); |
||||
|
|
||||
|
return `<div class="c-alloc-group">${rows}</div>`; |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,72 @@ |
|||||
|
import type { TokenizerAndRendererExtension } from 'marked'; |
||||
|
|
||||
|
function buildChartExtension( |
||||
|
chartType: 'area' | 'bar' |
||||
|
): TokenizerAndRendererExtension { |
||||
|
const tag = `chart-${chartType}`; |
||||
|
|
||||
|
return { |
||||
|
name: tag, |
||||
|
level: 'block', |
||||
|
start(src: string) { |
||||
|
return src.indexOf('```' + tag); |
||||
|
}, |
||||
|
tokenizer(src: string) { |
||||
|
const re = new RegExp('^```' + tag + '\\n([\\s\\S]*?)```'); |
||||
|
const match = src.match(re); |
||||
|
if (!match) return undefined; |
||||
|
return { |
||||
|
type: tag, |
||||
|
raw: match[0], |
||||
|
body: match[1] |
||||
|
}; |
||||
|
}, |
||||
|
renderer(token: any) { |
||||
|
const lines = token.body |
||||
|
.split('\n') |
||||
|
.map((l: string) => l.trim()) |
||||
|
.filter(Boolean); |
||||
|
|
||||
|
let title = ''; |
||||
|
const labels: string[] = []; |
||||
|
const values: number[] = []; |
||||
|
|
||||
|
for (const line of lines) { |
||||
|
if (line.startsWith('title:')) { |
||||
|
title = line.slice(6).trim(); |
||||
|
continue; |
||||
|
} |
||||
|
const colonIdx = line.lastIndexOf(':'); |
||||
|
if (colonIdx === -1) continue; |
||||
|
const label = line.slice(0, colonIdx).trim(); |
||||
|
const val = parseFloat(line.slice(colonIdx + 1).trim()); |
||||
|
if (label && !isNaN(val)) { |
||||
|
labels.push(label); |
||||
|
values.push(val); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!labels.length) return ''; |
||||
|
|
||||
|
const titleHtml = title |
||||
|
? `<div class="c-chart-title">${title}</div>` |
||||
|
: ''; |
||||
|
|
||||
|
// Encode data as canvas fallback text — Angular sanitizer strips
|
||||
|
// data-* attributes from [innerHTML], but preserves text content.
|
||||
|
const config = JSON.stringify({ type: chartType, labels, values }); |
||||
|
const escaped = config |
||||
|
.replace(/&/g, '&') |
||||
|
.replace(/</g, '<') |
||||
|
.replace(/>/g, '>'); |
||||
|
|
||||
|
return `<div class="c-chart-wrap">
|
||||
|
${titleHtml} |
||||
|
<canvas class="c-chart-canvas" width="400" height="180">${escaped}</canvas> |
||||
|
</div>`;
|
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export const chartAreaExtension = buildChartExtension('area'); |
||||
|
export const chartBarExtension = buildChartExtension('bar'); |
||||
@ -0,0 +1,48 @@ |
|||||
|
import type { TokenizerAndRendererExtension } from 'marked'; |
||||
|
|
||||
|
import { parseLines } from '../line-parser'; |
||||
|
import { classifySentiment } from '../value-classifier'; |
||||
|
|
||||
|
export const metricsExtension: TokenizerAndRendererExtension = { |
||||
|
name: 'metrics', |
||||
|
level: 'block', |
||||
|
start(src: string) { |
||||
|
return src.indexOf('```metrics'); |
||||
|
}, |
||||
|
tokenizer(src: string) { |
||||
|
const match = src.match(/^```metrics\n([\s\S]*?)```/); |
||||
|
if (!match) return undefined; |
||||
|
return { |
||||
|
type: 'metrics', |
||||
|
raw: match[0], |
||||
|
body: match[1] |
||||
|
}; |
||||
|
}, |
||||
|
renderer(token: any) { |
||||
|
const lines = parseLines(token.body); |
||||
|
if (!lines.length) return ''; |
||||
|
|
||||
|
const cards = lines |
||||
|
.map((line) => { |
||||
|
let deltaHtml = ''; |
||||
|
if (line.delta && line.delta !== '--') { |
||||
|
const sentiment = classifySentiment(line.delta); |
||||
|
const cls = |
||||
|
sentiment === 'positive' |
||||
|
? 'c-val-pos' |
||||
|
: sentiment === 'negative' |
||||
|
? 'c-val-neg' |
||||
|
: ''; |
||||
|
deltaHtml = `<span class="c-metric-delta ${cls}">${line.delta}</span>`; |
||||
|
} |
||||
|
return `<div class="c-metric-card">
|
||||
|
<div class="c-metric-label">${line.label}</div> |
||||
|
<div class="c-metric-value">${line.value}</div> |
||||
|
${deltaHtml} |
||||
|
</div>`;
|
||||
|
}) |
||||
|
.join(''); |
||||
|
|
||||
|
return `<div class="c-metric-row">${cards}</div>`; |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,42 @@ |
|||||
|
import type { TokenizerAndRendererExtension } from 'marked'; |
||||
|
|
||||
|
const MAX_SUGGESTIONS = 2; |
||||
|
|
||||
|
export const suggestionsExtension: TokenizerAndRendererExtension = { |
||||
|
name: 'suggestions', |
||||
|
level: 'block', |
||||
|
start(src: string) { |
||||
|
return src.indexOf('```suggestions'); |
||||
|
}, |
||||
|
tokenizer(src: string) { |
||||
|
const match = src.match(/^```suggestions\n([\s\S]*?)```/); |
||||
|
if (!match) return undefined; |
||||
|
return { |
||||
|
type: 'suggestions', |
||||
|
raw: match[0], |
||||
|
body: match[1] |
||||
|
}; |
||||
|
}, |
||||
|
renderer(token: any) { |
||||
|
const lines = token.body |
||||
|
.split('\n') |
||||
|
.map((l: string) => l.trim()) |
||||
|
.filter(Boolean) |
||||
|
.slice(0, MAX_SUGGESTIONS); |
||||
|
|
||||
|
if (!lines.length) return ''; |
||||
|
|
||||
|
const items = lines |
||||
|
.map((line: string) => { |
||||
|
const escaped = line |
||||
|
.replace(/&/g, '&') |
||||
|
.replace(/"/g, '"') |
||||
|
.replace(/</g, '<') |
||||
|
.replace(/>/g, '>'); |
||||
|
return `<span class="c-suggest"><span class="c-suggest-arrow">\u21B3</span> ${escaped}</span>`; |
||||
|
}) |
||||
|
.join(''); |
||||
|
|
||||
|
return `<div class="c-suggest-list">${items}</div>`; |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,74 @@ |
|||||
|
import type { TokenizerAndRendererExtension } from 'marked'; |
||||
|
|
||||
|
export const sparklineExtension: TokenizerAndRendererExtension = { |
||||
|
name: 'sparkline', |
||||
|
level: 'block', |
||||
|
start(src: string) { |
||||
|
return src.indexOf('```sparkline'); |
||||
|
}, |
||||
|
tokenizer(src: string) { |
||||
|
const match = src.match(/^```sparkline\n([\s\S]*?)```/); |
||||
|
if (!match) return undefined; |
||||
|
return { |
||||
|
type: 'sparkline', |
||||
|
raw: match[0], |
||||
|
body: match[1] |
||||
|
}; |
||||
|
}, |
||||
|
renderer(token: any) { |
||||
|
const lines = token.body |
||||
|
.split('\n') |
||||
|
.map((l: string) => l.trim()) |
||||
|
.filter(Boolean); |
||||
|
|
||||
|
let title = ''; |
||||
|
let values: number[] = []; |
||||
|
|
||||
|
for (const line of lines) { |
||||
|
if (line.startsWith('title:')) { |
||||
|
title = line.slice(6).trim(); |
||||
|
} else if (line.startsWith('values:')) { |
||||
|
values = line |
||||
|
.slice(7) |
||||
|
.split(',') |
||||
|
.map((v: string) => parseFloat(v.trim())) |
||||
|
.filter((v: number) => !isNaN(v)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!values.length) return ''; |
||||
|
|
||||
|
const min = Math.min(...values); |
||||
|
const max = Math.max(...values); |
||||
|
const range = max - min || 1; |
||||
|
const width = 200; |
||||
|
const height = 40; |
||||
|
const padding = 2; |
||||
|
|
||||
|
const points = values |
||||
|
.map((v, i) => { |
||||
|
const x = (i / (values.length - 1)) * width; |
||||
|
const y = padding + (1 - (v - min) / range) * (height - padding * 2); |
||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`; |
||||
|
}) |
||||
|
.join(' '); |
||||
|
|
||||
|
const trend = values[values.length - 1] >= values[0]; |
||||
|
const color = trend ? '#10b981' : '#ef4444'; |
||||
|
|
||||
|
// Build fill polygon (closed path under the line)
|
||||
|
const fillPoints = `0,${height} ${points} ${width},${height}`; |
||||
|
|
||||
|
const titleHtml = title |
||||
|
? `<div class="c-sparkline-title">${title}</div>` |
||||
|
: ''; |
||||
|
|
||||
|
return `<div class="c-sparkline">
|
||||
|
${titleHtml} |
||||
|
<svg class="c-spark-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"> |
||||
|
<polygon points="${fillPoints}" fill="${color}" opacity="0.1"/> |
||||
|
<polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> |
||||
|
</svg> |
||||
|
</div>`;
|
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,31 @@ |
|||||
|
export interface ParsedLine { |
||||
|
label: string; |
||||
|
value: string; |
||||
|
delta?: string; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Parses "Label: Value: Delta" or "Label: Value" lines. |
||||
|
* Colons inside the value (e.g. $1,234.56) are handled by |
||||
|
* splitting from the left on ": " (colon-space). |
||||
|
*/ |
||||
|
export function parseLine(raw: string): ParsedLine | null { |
||||
|
const parts = raw.split(/:\s+/); |
||||
|
if (parts.length < 2) return null; |
||||
|
|
||||
|
const label = parts[0].trim(); |
||||
|
const value = parts[1].trim(); |
||||
|
const delta = parts[2]?.trim(); |
||||
|
|
||||
|
if (!label || !value) return null; |
||||
|
return { label, value, delta: delta || undefined }; |
||||
|
} |
||||
|
|
||||
|
export function parseLines(block: string): ParsedLine[] { |
||||
|
return block |
||||
|
.split('\n') |
||||
|
.map((l) => l.trim()) |
||||
|
.filter(Boolean) |
||||
|
.map(parseLine) |
||||
|
.filter((p): p is ParsedLine => p !== null); |
||||
|
} |
||||
@ -0,0 +1,137 @@ |
|||||
|
import type { Tokens } from 'marked'; |
||||
|
|
||||
|
import { CellType, classifyCell, classifySentiment } from './value-classifier'; |
||||
|
|
||||
|
const COMPARISON_KEYWORDS = |
||||
|
/\b(prev|curr|before|after|prior|current|old|new|\d{4})\b/i; |
||||
|
|
||||
|
const CELL_CLASS: Record<CellType, string> = { |
||||
|
ticker: 'c-cell-ticker', |
||||
|
currency: 'c-cell-currency', |
||||
|
percent: 'c-cell-delta', |
||||
|
number: 'c-cell-num', |
||||
|
text: '' |
||||
|
}; |
||||
|
|
||||
|
function getCellText(cell: Tokens.TableCell): string { |
||||
|
return cell.tokens?.map((t: any) => t.raw ?? t.text ?? '').join('') ?? ''; |
||||
|
} |
||||
|
|
||||
|
function hasFinancialData(types: CellType[][]): boolean { |
||||
|
return types.some((row) => |
||||
|
row.some((t) => t === 'currency' || t === 'percent' || t === 'number') |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function isAllocationTable( |
||||
|
headers: string[], |
||||
|
_cellTypes: CellType[][], |
||||
|
cells: string[][] |
||||
|
): boolean { |
||||
|
if (headers.length !== 2) return false; |
||||
|
|
||||
|
return cells.every((row) => { |
||||
|
if (row.length < 2) return false; |
||||
|
const pct = parseFloat(row[1]); |
||||
|
return !isNaN(pct) && pct >= 0 && pct <= 100; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function isComparisonTable(headers: string[]): boolean { |
||||
|
return headers.some((h) => COMPARISON_KEYWORDS.test(h)); |
||||
|
} |
||||
|
|
||||
|
function renderAllocationBars(_headers: string[], cells: string[][]): string { |
||||
|
const rows = cells |
||||
|
.map((row) => { |
||||
|
const label = row[0]; |
||||
|
const pct = parseFloat(row[1]); |
||||
|
const width = isNaN(pct) ? 0 : Math.min(Math.max(pct, 0), 100); |
||||
|
return `<div class="c-progress-row">
|
||||
|
<div class="c-progress-meta"> |
||||
|
<span class="c-progress-label">${label}</span> |
||||
|
<span class="c-progress-value">${row[1]}</span> |
||||
|
</div> |
||||
|
<div class="c-alloc-bar"><div class="c-alloc-fill" style="width:${width}%"></div></div> |
||||
|
</div>`;
|
||||
|
}) |
||||
|
.join(''); |
||||
|
|
||||
|
return `<div class="c-alloc-group">${rows}</div>`; |
||||
|
} |
||||
|
|
||||
|
function renderEnhancedTable( |
||||
|
token: Tokens.Table, |
||||
|
_headerTexts: string[], |
||||
|
_cells: string[][], |
||||
|
cellTypes: CellType[][], |
||||
|
tableClass: string |
||||
|
): string { |
||||
|
const ths = token.header |
||||
|
.map((h, i) => { |
||||
|
const align = |
||||
|
cellTypes[0]?.[i] === 'currency' || cellTypes[0]?.[i] === 'number' |
||||
|
? ' style="text-align:right"' |
||||
|
: ''; |
||||
|
return `<th${align}>${getCellText(h)}</th>`; |
||||
|
}) |
||||
|
.join(''); |
||||
|
|
||||
|
const trs = token.rows |
||||
|
.map((row, ri) => { |
||||
|
const tds = row |
||||
|
.map((cell, ci) => { |
||||
|
const text = getCellText(cell); |
||||
|
const type = cellTypes[ri]?.[ci] ?? 'text'; |
||||
|
const cls = CELL_CLASS[type]; |
||||
|
let content = text; |
||||
|
|
||||
|
if (type === 'percent') { |
||||
|
const sentiment = classifySentiment(text); |
||||
|
if (sentiment === 'positive') { |
||||
|
content = `<span class="c-val-pos">${text}</span>`; |
||||
|
} else if (sentiment === 'negative') { |
||||
|
content = `<span class="c-val-neg">${text}</span>`; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const align = |
||||
|
type === 'currency' || type === 'number' |
||||
|
? ' style="text-align:right"' |
||||
|
: ''; |
||||
|
return `<td class="${cls}"${align}>${content}</td>`; |
||||
|
}) |
||||
|
.join(''); |
||||
|
return `<tr>${tds}</tr>`; |
||||
|
}) |
||||
|
.join(''); |
||||
|
|
||||
|
return `<table class="${tableClass}"><thead><tr>${ths}</tr></thead><tbody>${trs}</tbody></table>`; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Custom table renderer for marked. |
||||
|
* Returns the custom HTML string, or `false` to fall back to default rendering. |
||||
|
*/ |
||||
|
export function tableRenderer(token: Tokens.Table): string | false { |
||||
|
const headerTexts = token.header.map(getCellText); |
||||
|
|
||||
|
const cells = token.rows.map((row) => row.map(getCellText)); |
||||
|
const cellTypes = cells.map((row) => row.map(classifyCell)); |
||||
|
|
||||
|
// Allocation bars: 2 cols, all percentages 0-100
|
||||
|
if (isAllocationTable(headerTexts, cellTypes, cells)) { |
||||
|
return renderAllocationBars(headerTexts, cells); |
||||
|
} |
||||
|
|
||||
|
// Only enhance if financial data detected
|
||||
|
if (!hasFinancialData(cellTypes)) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const tableClass = isComparisonTable(headerTexts) |
||||
|
? 'c-comp-table' |
||||
|
: 'c-table'; |
||||
|
|
||||
|
return renderEnhancedTable(token, headerTexts, cells, cellTypes, tableClass); |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
export type ValueSentiment = 'positive' | 'negative' | 'neutral'; |
||||
|
|
||||
|
const CURRENCY_RE = /^[$€£¥]\s?[+-]?[\d,.]+$/; |
||||
|
const PERCENT_RE = /^[+-]?\d+(?:,\d{3})*(?:\.\d+)?%$/; |
||||
|
const NUMBER_RE = /^[+-]?[\d,.]+$/; |
||||
|
const TICKER_RE = /^[A-Z]{1,5}$/; |
||||
|
|
||||
|
export function classifySentiment(text: string): ValueSentiment { |
||||
|
const t = text.trim(); |
||||
|
if (t.startsWith('+')) return 'positive'; |
||||
|
if (t.startsWith('-') || t.startsWith('\u2212')) return 'negative'; |
||||
|
return 'neutral'; |
||||
|
} |
||||
|
|
||||
|
export type CellType = 'ticker' | 'currency' | 'percent' | 'number' | 'text'; |
||||
|
|
||||
|
export function classifyCell(text: string): CellType { |
||||
|
const t = text.trim(); |
||||
|
if (TICKER_RE.test(t)) return 'ticker'; |
||||
|
if (CURRENCY_RE.test(t)) return 'currency'; |
||||
|
if (PERCENT_RE.test(t)) return 'percent'; |
||||
|
if (NUMBER_RE.test(t) && t.length > 0) return 'number'; |
||||
|
return 'text'; |
||||
|
} |
||||
@ -0,0 +1,438 @@ |
|||||
|
// Agent page – markdown body & rich component styles |
||||
|
// Scoped to gf-agent-page so they don't leak to other views. |
||||
|
// Lives in global styles because Angular's ::ng-deep is deprecated |
||||
|
// and [innerHTML] content doesn't receive encapsulation attributes. |
||||
|
|
||||
|
$agent-primary: #4db6ac; |
||||
|
$agent-primary-dark: #3aa198; |
||||
|
$agent-primary-light: #e0f2f1; |
||||
|
$agent-text-secondary: #6b7280; |
||||
|
$agent-border: #e5e7eb; |
||||
|
$agent-bg-input: #f3f4f6; |
||||
|
$agent-radius-sm: 8px; |
||||
|
$agent-font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; |
||||
|
|
||||
|
gf-agent-page .markdown-body { |
||||
|
font-size: 15px; |
||||
|
line-height: 1.6; |
||||
|
|
||||
|
p { |
||||
|
margin: 0 0 12px; |
||||
|
&:last-child { |
||||
|
margin-bottom: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
ul, |
||||
|
ol { |
||||
|
margin: 0 0 12px 20px; |
||||
|
} |
||||
|
li { |
||||
|
margin: 4px 0; |
||||
|
} |
||||
|
|
||||
|
h1, |
||||
|
h2, |
||||
|
h3, |
||||
|
h4, |
||||
|
h5, |
||||
|
h6 { |
||||
|
margin: 16px 0 8px; |
||||
|
font-weight: 600; |
||||
|
&:first-child { |
||||
|
margin-top: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
h1 { |
||||
|
font-size: 18px; |
||||
|
} |
||||
|
h2 { |
||||
|
font-size: 16px; |
||||
|
} |
||||
|
h3 { |
||||
|
font-size: 15px; |
||||
|
} |
||||
|
|
||||
|
code { |
||||
|
background: $agent-primary-light; |
||||
|
color: $agent-primary-dark; |
||||
|
padding: 2px 6px; |
||||
|
border-radius: 4px; |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
pre { |
||||
|
background: $agent-bg-input; |
||||
|
border: 1px solid $agent-border; |
||||
|
border-radius: $agent-radius-sm; |
||||
|
padding: 12px 16px; |
||||
|
margin: 8px 0 12px; |
||||
|
overflow-x: auto; |
||||
|
code { |
||||
|
background: none; |
||||
|
padding: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
blockquote { |
||||
|
border-left: 3px solid $agent-border; |
||||
|
padding-left: 12px; |
||||
|
color: $agent-text-secondary; |
||||
|
margin: 8px 0 12px; |
||||
|
} |
||||
|
|
||||
|
// Tables – base styles apply to all tables including .c-table / .c-comp-table |
||||
|
table { |
||||
|
border-collapse: separate; |
||||
|
border-spacing: 0; |
||||
|
margin: 8px 0 12px; |
||||
|
font-size: 13px; |
||||
|
width: 100%; |
||||
|
border: 1px solid $agent-border; |
||||
|
border-radius: $agent-radius-sm; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
th, |
||||
|
td { |
||||
|
border-bottom: 1px solid $agent-border; |
||||
|
border-right: 1px solid $agent-border; |
||||
|
padding: 8px 12px; |
||||
|
text-align: left; |
||||
|
&:last-child { |
||||
|
border-right: none; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
tr:last-child td { |
||||
|
border-bottom: none; |
||||
|
} |
||||
|
|
||||
|
th { |
||||
|
background: $agent-bg-input; |
||||
|
font-weight: 600; |
||||
|
font-size: 12px; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.05em; |
||||
|
} |
||||
|
|
||||
|
td { |
||||
|
font-variant-numeric: tabular-nums; |
||||
|
} |
||||
|
|
||||
|
.c-comp-table th { |
||||
|
background: rgba($agent-primary, 0.08); |
||||
|
} |
||||
|
|
||||
|
a { |
||||
|
color: $agent-primary; |
||||
|
text-decoration: none; |
||||
|
&:hover { |
||||
|
text-decoration: underline; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
strong { |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
// Value sentiment |
||||
|
.value-positive, |
||||
|
.c-val-pos { |
||||
|
color: #10b981; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
.value-negative, |
||||
|
.c-val-neg { |
||||
|
color: #ef4444; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
// Cell types |
||||
|
.c-cell-ticker { |
||||
|
font-weight: 600; |
||||
|
color: #1e293b; |
||||
|
} |
||||
|
|
||||
|
.c-cell-currency, |
||||
|
.c-cell-num, |
||||
|
.c-cell-delta { |
||||
|
font-family: $agent-font-mono; |
||||
|
text-align: right; |
||||
|
} |
||||
|
|
||||
|
// Allocation bars |
||||
|
.c-alloc-group { |
||||
|
margin: 8px 0 12px; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 12px; |
||||
|
padding: 16px 20px; |
||||
|
background: white; |
||||
|
border: 1px solid $agent-border; |
||||
|
border-radius: $agent-radius-sm; |
||||
|
} |
||||
|
|
||||
|
.c-progress-row { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
|
||||
|
.c-progress-meta { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
margin-bottom: 4px; |
||||
|
} |
||||
|
|
||||
|
.c-progress-label { |
||||
|
font-size: 13px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.c-progress-value { |
||||
|
font-size: 13px; |
||||
|
font-variant-numeric: tabular-nums; |
||||
|
font-family: $agent-font-mono; |
||||
|
} |
||||
|
|
||||
|
.c-alloc-bar { |
||||
|
width: 100%; |
||||
|
height: 4px; |
||||
|
background: $agent-border; |
||||
|
border-radius: 2px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.c-alloc-fill { |
||||
|
height: 100%; |
||||
|
background: $agent-primary; |
||||
|
border-radius: 2px; |
||||
|
} |
||||
|
|
||||
|
// Sparkline |
||||
|
.c-sparkline { |
||||
|
margin: 8px 0 12px; |
||||
|
} |
||||
|
|
||||
|
.c-sparkline-title, |
||||
|
.c-chart-title { |
||||
|
font-size: 12px; |
||||
|
font-weight: 500; |
||||
|
color: $agent-text-secondary; |
||||
|
margin-bottom: 4px; |
||||
|
} |
||||
|
|
||||
|
.c-chart-title { |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
.c-spark-svg { |
||||
|
width: 100%; |
||||
|
height: 40px; |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
// Suggestions |
||||
|
.c-suggest-list { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 6px; |
||||
|
margin: 16px 0 4px; |
||||
|
} |
||||
|
|
||||
|
.c-suggest { |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
font-size: 14px; |
||||
|
color: $agent-text-secondary; |
||||
|
cursor: pointer; |
||||
|
transition: color 0.15s; |
||||
|
&:hover { |
||||
|
color: rgba(var(--dark-primary-text)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.c-suggest-arrow { |
||||
|
color: #9ca3af; |
||||
|
margin-right: 8px; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
// Metric cards |
||||
|
.c-metric-row { |
||||
|
display: flex; |
||||
|
gap: 12px; |
||||
|
margin: 8px 0 12px; |
||||
|
flex-wrap: wrap; |
||||
|
} |
||||
|
|
||||
|
.c-metric-card { |
||||
|
flex: 1; |
||||
|
min-width: 100px; |
||||
|
padding: 12px 16px; |
||||
|
background: $agent-bg-input; |
||||
|
border-radius: $agent-radius-sm; |
||||
|
border: 1px solid $agent-border; |
||||
|
} |
||||
|
|
||||
|
.c-metric-label { |
||||
|
font-size: 11px; |
||||
|
font-weight: 500; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.05em; |
||||
|
color: $agent-text-secondary; |
||||
|
margin-bottom: 4px; |
||||
|
} |
||||
|
|
||||
|
.c-metric-value { |
||||
|
font-size: 18px; |
||||
|
font-weight: 700; |
||||
|
font-variant-numeric: tabular-nums; |
||||
|
} |
||||
|
|
||||
|
.c-metric-delta { |
||||
|
font-size: 12px; |
||||
|
margin-top: 2px; |
||||
|
font-variant-numeric: tabular-nums; |
||||
|
} |
||||
|
|
||||
|
// Chart |
||||
|
.c-chart-wrap { |
||||
|
margin: 8px 0 12px; |
||||
|
} |
||||
|
.c-chart-canvas { |
||||
|
width: 100%; |
||||
|
height: 180px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Streaming cursor |
||||
|
gf-agent-page .bubble.streaming .markdown-body::after { |
||||
|
content: '\25CE'; |
||||
|
color: $agent-primary; |
||||
|
animation: agent-blink 1s steps(2) infinite; |
||||
|
} |
||||
|
|
||||
|
@keyframes agent-blink { |
||||
|
50% { |
||||
|
opacity: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Verification tooltip inner content (injected via innerHTML) |
||||
|
gf-agent-page .latency-tooltip { |
||||
|
.vt-row { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
gap: 16px; |
||||
|
padding: 2px 0; |
||||
|
} |
||||
|
|
||||
|
.vt-label { |
||||
|
color: $agent-text-secondary; |
||||
|
font-weight: 500; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.vt-value { |
||||
|
color: rgba(var(--dark-primary-text)); |
||||
|
font-family: $agent-font-mono; |
||||
|
font-variant-numeric: tabular-nums; |
||||
|
font-weight: 600; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Dark mode |
||||
|
.theme-dark gf-agent-page .markdown-body { |
||||
|
// Inline code |
||||
|
code { |
||||
|
background: rgba($agent-primary, 0.15); |
||||
|
color: $agent-primary; |
||||
|
} |
||||
|
|
||||
|
// Code blocks |
||||
|
pre { |
||||
|
background: rgba(255, 255, 255, 0.05); |
||||
|
border-color: rgba(255, 255, 255, 0.1); |
||||
|
} |
||||
|
|
||||
|
// Blockquotes |
||||
|
blockquote { |
||||
|
border-left-color: rgba(255, 255, 255, 0.15); |
||||
|
color: rgba(255, 255, 255, 0.6); |
||||
|
} |
||||
|
|
||||
|
// Tables |
||||
|
table { |
||||
|
border-color: rgba(255, 255, 255, 0.1); |
||||
|
} |
||||
|
|
||||
|
th, |
||||
|
td { |
||||
|
border-bottom-color: rgba(255, 255, 255, 0.08); |
||||
|
border-right-color: rgba(255, 255, 255, 0.08); |
||||
|
} |
||||
|
|
||||
|
th { |
||||
|
background: rgba(255, 255, 255, 0.06); |
||||
|
} |
||||
|
|
||||
|
.c-comp-table th { |
||||
|
background: rgba($agent-primary, 0.1); |
||||
|
} |
||||
|
|
||||
|
// Cell types |
||||
|
.c-cell-ticker { |
||||
|
color: #e2e8f0; |
||||
|
} |
||||
|
|
||||
|
// Allocation bars |
||||
|
.c-alloc-group { |
||||
|
background: rgba(255, 255, 255, 0.04); |
||||
|
border-color: rgba(255, 255, 255, 0.1); |
||||
|
} |
||||
|
|
||||
|
.c-alloc-bar { |
||||
|
background: rgba(255, 255, 255, 0.12); |
||||
|
} |
||||
|
|
||||
|
// Suggestions |
||||
|
.c-suggest { |
||||
|
color: rgba(255, 255, 255, 0.5); |
||||
|
&:hover { |
||||
|
color: rgb(var(--light-primary-text)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.c-suggest-arrow { |
||||
|
color: rgba(255, 255, 255, 0.35); |
||||
|
} |
||||
|
|
||||
|
// Metric cards |
||||
|
.c-metric-card { |
||||
|
background: rgba(255, 255, 255, 0.05); |
||||
|
border-color: rgba(255, 255, 255, 0.1); |
||||
|
} |
||||
|
|
||||
|
.c-metric-label { |
||||
|
color: rgba(255, 255, 255, 0.5); |
||||
|
} |
||||
|
|
||||
|
// Sparkline / chart titles |
||||
|
.c-sparkline-title, |
||||
|
.c-chart-title { |
||||
|
color: rgba(255, 255, 255, 0.5); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Dark mode – verification tooltip |
||||
|
.theme-dark gf-agent-page .latency-tooltip { |
||||
|
.vt-label { |
||||
|
color: rgba(255, 255, 255, 0.5); |
||||
|
} |
||||
|
.vt-value { |
||||
|
color: rgba(var(--light-primary-text)); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue