Browse Source

feat(client): add agent chat page with streaming UI and rich rendering

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
Ryan Waits 1 month ago
parent
commit
ee25fc8ee5
  1. 4
      apps/client/project.json
  2. 1
      apps/client/src/app/app.component.ts
  3. 5
      apps/client/src/app/app.routes.ts
  4. 31
      apps/client/src/app/components/header/header.component.html
  5. 7
      apps/client/src/app/components/header/header.component.ts
  6. 1237
      apps/client/src/app/pages/agent/agent-page.component.ts
  7. 317
      apps/client/src/app/pages/agent/agent-page.html
  8. 15
      apps/client/src/app/pages/agent/agent-page.routes.ts
  9. 898
      apps/client/src/app/pages/agent/agent-page.scss
  10. 143
      apps/client/src/app/pages/agent/rendering/chart-initializer.ts
  11. 33
      apps/client/src/app/pages/agent/rendering/configure-marked.ts
  12. 40
      apps/client/src/app/pages/agent/rendering/extensions/allocation.ts
  13. 72
      apps/client/src/app/pages/agent/rendering/extensions/chart.ts
  14. 48
      apps/client/src/app/pages/agent/rendering/extensions/metrics.ts
  15. 42
      apps/client/src/app/pages/agent/rendering/extensions/pills.ts
  16. 74
      apps/client/src/app/pages/agent/rendering/extensions/sparkline.ts
  17. 31
      apps/client/src/app/pages/agent/rendering/line-parser.ts
  18. 137
      apps/client/src/app/pages/agent/rendering/table-renderer.ts
  19. 24
      apps/client/src/app/pages/agent/rendering/value-classifier.ts
  20. 1
      apps/client/src/styles.scss
  21. 438
      apps/client/src/styles/agent-markdown.scss
  22. 5
      libs/common/src/lib/routes/routes.ts

4
apps/client/project.json

@ -151,8 +151,8 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "6kb", "maximumWarning": "10kb",
"maximumError": "10kb" "maximumError": "14kb"
} }
], ],
"buildOptimizer": true, "buildOptimizer": true,

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

@ -168,6 +168,7 @@ export class GfAppComponent implements OnDestroy, OnInit {
this.currentRoute === publicRoutes.resources.path || this.currentRoute === publicRoutes.resources.path ||
this.currentRoute === internalRoutes.account.path || this.currentRoute === internalRoutes.account.path ||
this.currentRoute === internalRoutes.adminControl.path || this.currentRoute === internalRoutes.adminControl.path ||
this.currentRoute === internalRoutes.agent.path ||
this.currentRoute === internalRoutes.home.path || this.currentRoute === internalRoutes.home.path ||
this.currentRoute === internalRoutes.portfolio.path || this.currentRoute === internalRoutes.portfolio.path ||
this.currentRoute === internalRoutes.zen.path) && this.currentRoute === internalRoutes.zen.path) &&

5
apps/client/src/app/app.routes.ts

@ -10,6 +10,11 @@ export const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.routes').then((m) => m.routes) import('./pages/about/about-page.routes').then((m) => m.routes)
}, },
{
path: internalRoutes.agent.path,
loadChildren: () =>
import('./pages/agent/agent-page.routes').then((m) => m.routes)
},
{ {
path: internalRoutes.account.path, path: internalRoutes.account.path,
loadChildren: () => loadChildren: () =>

31
apps/client/src/app/components/header/header.component.html

@ -58,6 +58,22 @@
>Accounts</a >Accounts</a
> >
</li> </li>
@if (hasPermissionToAccessAgent) {
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.agent.path,
'text-decoration-underline':
currentRoute === internalRoutes.agent.path
}"
[routerLink]="routerLinkAgent"
>Agent</a
>
</li>
}
@if (hasPermissionToAccessAdminControl) { @if (hasPermissionToAccessAdminControl) {
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
@ -267,6 +283,18 @@
[routerLink]="routerLinkAccounts" [routerLink]="routerLinkAccounts"
>Accounts</a >Accounts</a
> >
@if (hasPermissionToAccessAgent) {
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.agent.path
}"
[routerLink]="routerLinkAgent"
>Agent</a
>
}
<a <a
i18n i18n
mat-menu-item mat-menu-item
@ -330,8 +358,7 @@
</mat-menu> </mat-menu>
</li> </li>
</ul> </ul>
} } @else {
@if (user === null) {
<div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }"> <div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
<a <a
class="align-items-center justify-content-start rounded-0" class="align-items-center justify-content-start rounded-0"

7
apps/client/src/app/components/header/header.component.ts

@ -109,6 +109,7 @@ export class GfHeaderComponent implements OnChanges {
public hasPermissionForAuthToken: boolean; public hasPermissionForAuthToken: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
public hasPermissionToAccessAgent: boolean;
public hasPermissionToAccessAssistant: boolean; public hasPermissionToAccessAssistant: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateUser: boolean; public hasPermissionToCreateUser: boolean;
@ -124,6 +125,7 @@ export class GfHeaderComponent implements OnChanges {
public routerLinkAccount = internalRoutes.account.routerLink; public routerLinkAccount = internalRoutes.account.routerLink;
public routerLinkAccounts = internalRoutes.accounts.routerLink; public routerLinkAccounts = internalRoutes.accounts.routerLink;
public routerLinkAdminControl = internalRoutes.adminControl.routerLink; public routerLinkAdminControl = internalRoutes.adminControl.routerLink;
public routerLinkAgent = internalRoutes.agent.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink; public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkMarkets = publicRoutes.markets.routerLink; public routerLinkMarkets = publicRoutes.markets.routerLink;
public routerLinkPortfolio = internalRoutes.portfolio.routerLink; public routerLinkPortfolio = internalRoutes.portfolio.routerLink;
@ -191,6 +193,11 @@ export class GfHeaderComponent implements OnChanges {
permissions.accessAdminControl permissions.accessAdminControl
); );
this.hasPermissionToAccessAgent = hasPermission(
this.user?.permissions,
permissions.readAiPrompt
);
this.hasPermissionToAccessAssistant = hasPermission( this.hasPermissionToAccessAssistant = hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.accessAssistant permissions.accessAssistant

1237
apps/client/src/app/pages/agent/agent-page.component.ts

File diff suppressed because it is too large

317
apps/client/src/app/pages/agent/agent-page.html

@ -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>

15
apps/client/src/app/pages/agent/agent-page.routes.ts

@ -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
}
];

898
apps/client/src/app/pages/agent/agent-page.scss

@ -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;
}
}

143
apps/client/src/app/pages/agent/rendering/chart-initializer.ts

@ -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);
}
}

33
apps/client/src/app/pages/agent/rendering/configure-marked.ts

@ -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
]
});
}

40
apps/client/src/app/pages/agent/rendering/extensions/allocation.ts

@ -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>`;
}
};

72
apps/client/src/app/pages/agent/rendering/extensions/chart.ts

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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');

48
apps/client/src/app/pages/agent/rendering/extensions/metrics.ts

@ -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>`;
}
};

42
apps/client/src/app/pages/agent/rendering/extensions/pills.ts

@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<span class="c-suggest"><span class="c-suggest-arrow">\u21B3</span> ${escaped}</span>`;
})
.join('');
return `<div class="c-suggest-list">${items}</div>`;
}
};

74
apps/client/src/app/pages/agent/rendering/extensions/sparkline.ts

@ -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>`;
}
};

31
apps/client/src/app/pages/agent/rendering/line-parser.ts

@ -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);
}

137
apps/client/src/app/pages/agent/rendering/table-renderer.ts

@ -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);
}

24
apps/client/src/app/pages/agent/rendering/value-classifier.ts

@ -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';
}

1
apps/client/src/styles.scss

@ -1,6 +1,7 @@
@import './styles/bootstrap'; @import './styles/bootstrap';
@import './styles/table'; @import './styles/table';
@import './styles/variables'; @import './styles/variables';
@import './styles/agent-markdown';
@import 'svgmap/dist/svgMap'; @import 'svgmap/dist/svgMap';

438
apps/client/src/styles/agent-markdown.scss

@ -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));
}
}

5
libs/common/src/lib/routes/routes.ts

@ -33,6 +33,11 @@ export const internalRoutes: Record<string, InternalRoute> = {
}, },
title: $localize`Settings` title: $localize`Settings`
}, },
agent: {
path: 'agent',
routerLink: ['/agent'],
title: $localize`Agent`
},
adminControl: { adminControl: {
excludeFromAssistant: (aUser: User) => { excludeFromAssistant: (aUser: User) => {
return hasPermission(aUser?.permissions, permissions.accessAdminControl); return hasPermission(aUser?.permissions, permissions.accessAdminControl);

Loading…
Cancel
Save