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