diff --git a/apps/client/project.json b/apps/client/project.json index 38887ca8a..a4cefad4d 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -151,8 +151,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "6kb", - "maximumError": "10kb" + "maximumWarning": "10kb", + "maximumError": "14kb" } ], "buildOptimizer": true, diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index a4af01124..3988e8577 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -168,6 +168,7 @@ export class GfAppComponent implements OnDestroy, OnInit { this.currentRoute === publicRoutes.resources.path || this.currentRoute === internalRoutes.account.path || this.currentRoute === internalRoutes.adminControl.path || + this.currentRoute === internalRoutes.agent.path || this.currentRoute === internalRoutes.home.path || this.currentRoute === internalRoutes.portfolio.path || this.currentRoute === internalRoutes.zen.path) && diff --git a/apps/client/src/app/app.routes.ts b/apps/client/src/app/app.routes.ts index 9588cee68..c30c93de1 100644 --- a/apps/client/src/app/app.routes.ts +++ b/apps/client/src/app/app.routes.ts @@ -10,6 +10,11 @@ export const routes: Routes = [ loadChildren: () => 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, loadChildren: () => diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 501119b31..5551e815e 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -58,6 +58,22 @@ >Accounts + @if (hasPermissionToAccessAgent) { +
  • + Agent +
  • + } @if (hasPermissionToAccessAdminControl) {
  • Accounts + @if (hasPermissionToAccessAgent) { + Agent + }
  • - } - @if (user === null) { + } @else {
    ; + + public conversations: Conversation[] = []; + public activeConversation: Conversation | null = null; + public inputValue = ''; + public isLoading = false; + public user: User; + public openMenuId: string | null = null; + public renamingId: string | null = null; + public renameValue = ''; + + // Model selector + public models = [ + { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', tier: 'Fast' }, + { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', tier: 'Balanced' }, + { id: 'claude-opus-4-6', label: 'Opus 4.6', tier: 'Best' } + ]; + public selectedModel = + localStorage.getItem('agent-selected-model') || 'claude-sonnet-4-6'; + public modelDropdownOpen = false; + + public promptCards: PromptCard[] = [ + { + iconName: 'analytics-outline', + title: 'Analyze performance', + subtitle: 'Year to date overview', + message: 'How is my portfolio performing?' + }, + { + iconName: 'alert-circle-outline', + title: 'Check volatility', + subtitle: 'Risk assessment', + message: 'What is the risk profile of my portfolio?' + }, + { + iconName: 'pie-chart-outline', + title: 'Asset allocation', + subtitle: 'Current breakdown', + message: 'Show my asset allocation breakdown' + }, + { + iconName: 'swap-horizontal-outline', + title: 'Recent activity', + subtitle: 'Transaction history', + message: 'Summarize my recent transactions' + } + ]; + + private static readonly STORAGE_KEY = 'agent-conversations'; + private static readonly MAX_CONVERSATIONS = 50; + + private approvedActions: string[] = []; + private chartInitializer = new ChartInitializer(); + private marked: typeof import('marked') | null = null; + private remend: ((md: string) => string) | null = null; + private renderInterval: ReturnType | null = null; + private renderDirty = false; + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private sanitizer: DomSanitizer, + private tokenStorageService: TokenStorageService, + private userService: UserService + ) { + addIcons({ + addOutline, + alertCircleOutline, + analyticsOutline, + chatbubbleEllipsesOutline, + chatbubbleOutline, + checkmarkOutline, + chevronDownOutline, + chevronUpOutline, + copyOutline, + createOutline, + ellipsisHorizontalOutline, + ellipsisVerticalOutline, + linkOutline, + pieChartOutline, + pinOutline, + sendOutline, + swapHorizontalOutline, + thumbsDownOutline, + thumbsUpOutline, + trashOutline + }); + } + + public ngOnInit() { + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + this.changeDetectorRef.markForCheck(); + } + }); + + this.loadMarked().then(() => { + this.restoreConversations(); + this.focusInput(); + this.ensureChartObserver(); + }); + } + + public ngAfterViewInit() { + this.ensureChartObserver(); + } + + public ngOnDestroy() { + this.chartInitializer.detach(); + this.clearRenderInterval(); + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + @HostListener('document:click') + public onDocumentClick() { + let changed = false; + + if (this.openMenuId) { + this.openMenuId = null; + changed = true; + } + + if (this.modelDropdownOpen) { + this.modelDropdownOpen = false; + changed = true; + } + + if (changed) { + this.changeDetectorRef.markForCheck(); + } + } + + public onNewChat() { + const conversation: Conversation = { + id: crypto.randomUUID(), + title: 'New Chat', + messages: [], + createdAt: new Date() + }; + this.conversations.unshift(conversation); + this.sortConversations(); + this.activeConversation = conversation; + this.persistConversations(); + this.changeDetectorRef.markForCheck(); + this.focusInput(); + } + + public toggleMenu(event: Event, id: string) { + event.stopPropagation(); + this.openMenuId = this.openMenuId === id ? null : id; + this.changeDetectorRef.markForCheck(); + } + + public startRename(event: Event, conversation: Conversation) { + event.stopPropagation(); + this.openMenuId = null; + this.renamingId = conversation.id; + this.renameValue = conversation.title; + this.changeDetectorRef.markForCheck(); + } + + public confirmRename(conversation: Conversation) { + const trimmed = this.renameValue.trim(); + if (trimmed) { + conversation.title = trimmed; + } + this.renamingId = null; + this.persistConversations(); + this.changeDetectorRef.markForCheck(); + } + + public onRenameKeydown(event: KeyboardEvent, conversation: Conversation) { + if (event.key === 'Enter') { + event.preventDefault(); + this.confirmRename(conversation); + } else if (event.key === 'Escape') { + this.renamingId = null; + this.changeDetectorRef.markForCheck(); + } + } + + public togglePin(event: Event, conversation: Conversation) { + event.stopPropagation(); + this.openMenuId = null; + conversation.pinned = !conversation.pinned; + this.sortConversations(); + this.persistConversations(); + this.changeDetectorRef.markForCheck(); + } + + public deleteConversation(event: Event, conversation: Conversation) { + event.stopPropagation(); + this.openMenuId = null; + this.conversations = this.conversations.filter( + (c) => c.id !== conversation.id + ); + if (this.activeConversation?.id === conversation.id) { + this.activeConversation = null; + } + this.persistConversations(); + this.changeDetectorRef.markForCheck(); + } + + public onSelectConversation(conversation: Conversation) { + this.activeConversation = conversation; + this.changeDetectorRef.markForCheck(); + this.focusInput(); + this.ensureChartObserver(); + } + + public onPromptCardClick(card: PromptCard) { + this.sendMessageDirect(card.message); + } + + public onMarkdownClick(event: MouseEvent) { + const suggest = (event.target as HTMLElement).closest( + '.c-suggest' + ) as HTMLElement; + if (suggest) { + event.preventDefault(); + // Strip the arrow prefix from textContent + const text = suggest.textContent.replace(/^\u21B3\s*/, '').trim(); + if (text) { + this.sendMessageDirect(text); + } + } + } + + public onInputKeydown(event: KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendMessage(); + } + } + + public onInputChange(event: Event) { + const textarea = event.target as HTMLTextAreaElement; + this.inputValue = textarea.value; + textarea.style.height = 'auto'; + textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; + } + + public sendMessage() { + const text = this.inputValue.trim(); + if (!text || this.isLoading) { + return; + } + + this.inputValue = ''; + + if (this.messageInput?.nativeElement) { + this.messageInput.nativeElement.style.height = 'auto'; + } + + this.streamMessage(text); + } + + public async sendFeedback(message: ChatMessage, rating: number) { + if (message.feedbackRating || !message.requestId) { + return; + } + + message.feedbackRating = rating; + this.persistConversations(); + this.changeDetectorRef.markForCheck(); + + const token = this.tokenStorageService.getToken(); + + try { + await fetch('/api/v1/agent/feedback', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + requestId: message.requestId, + rating + }) + }); + } catch { + // Silently fail + } + } + + public async copyMessage(message: ChatMessage) { + try { + await navigator.clipboard.writeText(message.content); + } catch { + // Clipboard unavailable + } + } + + public async shareMessage(message: ChatMessage) { + try { + await navigator.clipboard.writeText(message.content); + } catch { + // Clipboard unavailable + } + } + + public formatLatency(ms: number): string { + return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms'; + } + + public formatTools(tools: string[] | undefined): string { + return tools?.join(', ') || 'none'; + } + + public buildVerificationTooltip(message: ChatMessage): string { + const row = (label: string, value: string | number) => + `${label}${value}`; + + const d = message.verificationData; + + if (d) { + return [ + row('Latency', d.latencyMs + 'ms'), + row('Steps', d.totalSteps), + row('Tools', this.formatTools(d.toolsUsed)), + row('Tokens', d.totalTokens), + row('Confidence', d.verificationScore ?? 'n/a') + ].join(''); + } + + return row('Response time', message.latencyMs + 'ms'); + } + + public async prefetchVerification(message: ChatMessage) { + if (message.verificationData || !message.requestId) { + return; + } + + const token = this.tokenStorageService.getToken(); + + try { + const res = await fetch( + `/api/v1/agent/verification/${message.requestId}`, + { + headers: { Authorization: `Bearer ${token}` } + } + ); + + if (res.ok) { + message.verificationData = await res.json(); + this.changeDetectorRef.markForCheck(); + } + } catch { + // Silently fail + } + } + + // Approval UI + public formatToolName(toolName: string): string { + return toolName.replace(/_/g, ' '); + } + + public formatApprovalInput(inv: ToolInvocation): string { + const input = inv.input as Record; + + if (!input) return JSON.stringify(inv.input, null, 2); + + switch (inv.toolName) { + case 'activity_manage': { + const parts = [input.type, input.quantity, input.symbol].filter( + Boolean + ); + if (input.unitPrice) parts.push(`@ $${input.unitPrice}`); + if (input.date) parts.push(`on ${input.date}`); + if (input.currency) parts.push(`(${input.currency})`); + return parts.join(' ') || input.action; + } + case 'account_manage': { + if (input.action === 'create') + return `Create account: ${input.name} (${input.currency})`; + if (input.action === 'update') + return `Update account balance to ${input.balance}`; + if (input.action === 'delete') return `Delete account`; + if (input.action === 'transfer') + return `Transfer ${input.balance} between accounts`; + return input.action; + } + case 'tag_manage': + return `${input.action} tag: ${input.name || input.tagId}`; + case 'watchlist_manage': + return `${input.action} ${input.symbol} (${input.dataSource})`; + default: + return JSON.stringify(input, null, 2); + } + } + + public async handleApproval(message: ChatMessage, approved: boolean) { + const inv = message.pendingApproval; + + if (!inv) return; + + message.pendingApproval = undefined; + + // Update tool invocation state + if (approved) { + inv.state = 'approval-responded'; + inv.approval = { ...inv.approval, approved: true }; + } else { + inv.state = 'output-denied'; + inv.approval = { ...inv.approval, approved: false }; + } + + // Update corresponding part in message.parts + const toolPart = message.parts.find( + (p): p is Extract => + p.type === 'dynamic-tool' && p.toolCallId === inv.toolCallId + ); + if (toolPart) { + toolPart.state = inv.state; + toolPart.approval = { + id: inv.approval.id, + approved: inv.approval.approved + }; + // approval-responded/output-denied must NOT have output + delete toolPart.output; + } + + if (approved) { + // Track action signature for prerequisite skip + const input = inv.input as Record; + const sig = `${inv.toolName}:${input?.action ?? ''}:${input?.symbol ?? input?.name ?? input?.accountId ?? ''}`; + if (!this.approvedActions.includes(sig)) { + this.approvedActions.push(sig); + } + + this.persistConversations(); + this.changeDetectorRef.markForCheck(); + + // Resume: send UIMessages (with approval-responded parts) to server + // and stream the response into the SAME assistant message + this.resumeAfterApproval(message); + } else { + this.persistConversations(); + this.changeDetectorRef.markForCheck(); + } + } + + /** + * Resume streaming into the SAME assistant message after an approval. + * The server executes the approved tool and streams back tool-output-available + * + the model's continuation text. We update the existing invocations in-place + * so the message ends with output-available parts (not approval-responded), + * which converts cleanly on future turns. + */ + private async resumeAfterApproval(assistantMessage: ChatMessage) { + if (!this.activeConversation) return; + + this.isLoading = true; + assistantMessage.isStreaming = true; + this.changeDetectorRef.markForCheck(); + + let fullText = ''; + + this.renderDirty = false; + this.renderInterval = setInterval(() => { + if (!this.renderDirty) return; + this.renderDirty = false; + + if (this.marked && this.remend) { + const streamSafe = this.stripTrailingFencedBlock(fullText); + assistantMessage.content = fullText; + assistantMessage.html = this.toSafeHtml( + this.marked.parse( + this.normalizeMarkdown(this.remend(streamSafe)) + ) as string + ); + } + + this.changeDetectorRef.markForCheck(); + this.scrollToBottom(); + }, 80); + + const streamStart = Date.now(); + + try { + const token = this.tokenStorageService.getToken(); + const uiMessages = this.buildUIMessages(); + const toolHistory = this.buildToolHistory(); + + const response = await fetch('/api/v1/agent/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + messages: uiMessages, + toolHistory: toolHistory.length > 0 ? toolHistory : undefined, + model: this.selectedModel, + approvedActions: + this.approvedActions.length > 0 ? this.approvedActions : undefined + }) + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data: ')) continue; + + const data = trimmed.slice(6); + if (data === '[DONE]') continue; + + try { + const evt = JSON.parse(data); + + if (evt.toolName && typeof evt.toolName === 'string') { + if (!assistantMessage.activeTools) + assistantMessage.activeTools = []; + if (!assistantMessage.activeTools.includes(evt.toolName)) + assistantMessage.activeTools.push(evt.toolName); + this.renderDirty = true; + } + + // Track tool invocations — check existing first (for approved tools) + if (evt.type === 'tool-input-start') { + const existing = assistantMessage.toolInvocations?.find( + (t) => t.toolCallId === evt.toolCallId + ); + if (!existing) { + if (!assistantMessage.toolInvocations) + assistantMessage.toolInvocations = []; + assistantMessage.toolInvocations.push({ + toolCallId: evt.toolCallId, + toolName: evt.toolName, + state: 'input-streaming' + }); + } + } else if (evt.type === 'tool-input-available') { + const inv = assistantMessage.toolInvocations?.find( + (t) => t.toolCallId === evt.toolCallId + ); + if (inv) { + inv.state = 'input-available'; + inv.input = evt.input; + } + } else if (evt.type === 'tool-approval-request') { + const inv = assistantMessage.toolInvocations?.find( + (t) => t.toolCallId === evt.toolCallId + ); + if (inv) { + inv.state = 'approval-requested'; + inv.approval = { id: evt.approvalId }; + assistantMessage.pendingApproval = inv; + this.renderDirty = true; + } + } else if (evt.type === 'tool-output-available') { + // This is the key event for approval resume: updates the + // approved invocation from approval-responded → output-available + const inv = assistantMessage.toolInvocations?.find( + (t) => t.toolCallId === evt.toolCallId + ); + if (inv) { + inv.state = 'output-available'; + inv.output = evt.output; + } + } + + if (evt.type === 'text-delta') { + fullText += evt.delta; + this.renderDirty = true; + } else if ( + (evt.type === 'finish' || evt.type === 'message-metadata') && + evt.messageMetadata?.requestId + ) { + assistantMessage.requestId = evt.messageMetadata.requestId; + } + } catch { + // Skip malformed JSON + } + } + } + + this.clearRenderInterval(); + const newContent = fullText.trim(); + if (newContent) { + assistantMessage.content = newContent; + if (this.marked) { + assistantMessage.html = this.toSafeHtml( + this.marked.parse( + this.normalizeMarkdown(assistantMessage.content) + ) as string + ); + } + } + + // Rebuild parts — tool invocations should now be output-available + this.rebuildParts(assistantMessage); + + setTimeout(() => this.chartInitializer.scan()); + } catch (err) { + this.clearRenderInterval(); + assistantMessage.content = `Error: ${err.message}`; + } + + assistantMessage.latencyMs = Date.now() - streamStart; + assistantMessage.isStreaming = false; + this.isLoading = false; + this.persistConversations(); + this.changeDetectorRef.markForCheck(); + this.scrollToBottom(); + this.focusInput(); + } + + // Model selector + public selectModel(id: string) { + this.selectedModel = id; + this.modelDropdownOpen = false; + localStorage.setItem('agent-selected-model', id); + this.changeDetectorRef.markForCheck(); + } + + public getModelLabel(id: string): string { + return this.models.find((m) => m.id === id)?.label ?? 'Sonnet 4.6'; + } + + private sendMessageDirect(text: string) { + if (!text || this.isLoading) { + return; + } + + this.inputValue = ''; + this.streamMessage(text); + } + + private async streamMessage(text: string) { + if (!this.activeConversation) { + this.onNewChat(); + } + + this.isLoading = true; + + const userMessage: ChatMessage = { + id: crypto.randomUUID(), + role: 'user', + content: text, + parts: [{ type: 'text', text }] + }; + this.activeConversation.messages.push(userMessage); + + if ( + this.activeConversation.messages.filter((m) => m.role === 'user') + .length === 1 + ) { + this.activeConversation.title = + text.slice(0, 40) + (text.length > 40 ? '...' : ''); + } + + const assistantMessage: ChatMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: '', + parts: [], + isStreaming: true, + toolInvocations: [] + }; + this.activeConversation.messages.push(assistantMessage); + this.changeDetectorRef.markForCheck(); + this.scrollToBottom(); + this.ensureChartObserver(); + + let fullText = ''; + + this.renderDirty = false; + this.renderInterval = setInterval(() => { + if (!this.renderDirty) { + return; + } + this.renderDirty = false; + + if (this.marked && this.remend) { + const streamSafe = this.stripTrailingFencedBlock(fullText); + assistantMessage.content = fullText; + assistantMessage.html = this.toSafeHtml( + this.marked.parse( + this.normalizeMarkdown(this.remend(streamSafe)) + ) as string + ); + } + + this.changeDetectorRef.markForCheck(); + this.scrollToBottom(); + }, 80); + + const streamStart = Date.now(); + + try { + const token = this.tokenStorageService.getToken(); + const uiMessages = this.buildUIMessages(); + const toolHistory = this.buildToolHistory(); + + const response = await fetch('/api/v1/agent/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + messages: uiMessages, + toolHistory: toolHistory.length > 0 ? toolHistory : undefined, + model: this.selectedModel, + approvedActions: + this.approvedActions.length > 0 ? this.approvedActions : undefined + }) + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data: ')) { + continue; + } + + const data = trimmed.slice(6); + if (data === '[DONE]') { + continue; + } + + try { + const evt = JSON.parse(data); + + if (evt.toolName && typeof evt.toolName === 'string') { + if (!assistantMessage.activeTools) { + assistantMessage.activeTools = []; + } + if (!assistantMessage.activeTools.includes(evt.toolName)) { + assistantMessage.activeTools.push(evt.toolName); + } + this.renderDirty = true; + } + + // Track tool invocations for UIMessage parts + if (evt.type === 'tool-input-start') { + const invocation: ToolInvocation = { + toolCallId: evt.toolCallId, + toolName: evt.toolName, + state: 'input-streaming' + }; + assistantMessage.toolInvocations.push(invocation); + } else if (evt.type === 'tool-input-available') { + const inv = assistantMessage.toolInvocations?.find( + (t) => t.toolCallId === evt.toolCallId + ); + if (inv) { + inv.state = 'input-available'; + inv.input = evt.input; + } + } else if (evt.type === 'tool-approval-request') { + const inv = assistantMessage.toolInvocations?.find( + (t) => t.toolCallId === evt.toolCallId + ); + if (inv) { + inv.state = 'approval-requested'; + inv.approval = { id: evt.approvalId }; + assistantMessage.pendingApproval = inv; + this.renderDirty = true; + } + } else if (evt.type === 'tool-output-available') { + const inv = assistantMessage.toolInvocations?.find( + (t) => t.toolCallId === evt.toolCallId + ); + if (inv) { + inv.state = 'output-available'; + inv.output = evt.output; + } + } + + if (evt.type === 'text-delta') { + fullText += evt.delta; + this.renderDirty = true; + } else if ( + (evt.type === 'finish' || evt.type === 'message-metadata') && + evt.messageMetadata?.requestId + ) { + assistantMessage.requestId = evt.messageMetadata.requestId; + } + } catch { + // Skip malformed JSON + } + } + } + + // Flush remaining buffer + if (buffer.trim()) { + const trimmed = buffer.trim(); + if (trimmed.startsWith('data: ')) { + const data = trimmed.slice(6); + if (data !== '[DONE]') { + try { + const evt = JSON.parse(data); + if (evt.type === 'text-delta') { + fullText += evt.delta; + } else if ( + (evt.type === 'finish' || evt.type === 'message-metadata') && + evt.messageMetadata?.requestId + ) { + assistantMessage.requestId = evt.messageMetadata.requestId; + } + } catch { + // Skip malformed JSON + } + } + } + } + + this.clearRenderInterval(); + assistantMessage.content = fullText; + if (assistantMessage.content && this.marked) { + assistantMessage.html = this.toSafeHtml( + this.marked.parse( + this.normalizeMarkdown(assistantMessage.content) + ) as string + ); + } + + // Build parts array from text + tool invocations + this.rebuildParts(assistantMessage); + + // Re-scan for chart canvases after final innerHTML update + setTimeout(() => this.chartInitializer.scan()); + } catch (err) { + this.clearRenderInterval(); + assistantMessage.content = `Error: ${err.message}`; + } + + assistantMessage.latencyMs = Date.now() - streamStart; + assistantMessage.isStreaming = false; + this.isLoading = false; + this.persistConversations(); + this.changeDetectorRef.markForCheck(); + this.scrollToBottom(); + this.focusInput(); + } + + /** + * Build UIMessages from the active conversation, sending full parts + * (including dynamic-tool parts) so the SDK can properly handle + * tool call/result pairs and approval flows. + */ + private buildUIMessages() { + return this.activeConversation.messages + .filter((m) => m.parts?.length > 0 || m.content) + .map((m) => ({ + id: m.id, + role: m.role, + parts: + m.parts?.length > 0 + ? m.parts + : [{ type: 'text' as const, text: m.content }] + })); + } + + private buildToolHistory(): string[] { + return [ + ...new Set( + this.activeConversation.messages.flatMap((m) => m.activeTools || []) + ) + ]; + } + + /** + * Rebuild the parts array from the message's content and tool invocations. + * Ensures tool parts have the correct Zod-compatible shape for each state. + */ + private rebuildParts(message: ChatMessage) { + const parts: ChatMessagePart[] = []; + if (message.content) { + parts.push({ type: 'text', text: message.content }); + } + for (const inv of message.toolInvocations || []) { + const part: Extract = { + type: 'dynamic-tool', + toolCallId: inv.toolCallId, + toolName: inv.toolName, + state: inv.state, + input: inv.input + }; + // Only include output for states that have it + if (inv.state === 'output-available' && inv.output !== undefined) { + part.output = inv.output; + } + // Include approval for states that need it + if (inv.approval) { + part.approval = { + id: inv.approval.id, + ...(inv.approval.approved !== undefined + ? { approved: inv.approval.approved } + : {}) + }; + } + parts.push(part); + } + message.parts = parts; + } + + private sortConversations() { + this.conversations.sort((a, b) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + } + + private persistConversations() { + try { + const toStore = this.conversations + .slice(0, GfAgentPageComponent.MAX_CONVERSATIONS) + .map((c) => ({ + id: c.id, + title: c.title, + createdAt: c.createdAt, + pinned: c.pinned || false, + messages: c.messages.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + parts: m.parts, + activeTools: m.activeTools, + toolInvocations: m.toolInvocations, + requestId: m.requestId, + feedbackRating: m.feedbackRating, + latencyMs: m.latencyMs, + pendingApproval: m.pendingApproval + })) + })); + + localStorage.setItem( + GfAgentPageComponent.STORAGE_KEY, + JSON.stringify({ + activeId: this.activeConversation?.id, + conversations: toStore + }) + ); + } catch { + // Storage full or unavailable — silently ignore + } + } + + private restoreConversations() { + try { + const raw = localStorage.getItem(GfAgentPageComponent.STORAGE_KEY); + + if (!raw) { + return; + } + + const { activeId, conversations } = JSON.parse(raw); + + this.conversations = (conversations || []).map( + (c: { + id: string; + title: string; + createdAt: string; + pinned?: boolean; + messages: any[]; + }) => ({ + ...c, + createdAt: new Date(c.createdAt), + pinned: c.pinned || false, + messages: c.messages.map((m: any) => ({ + ...m, + id: m.id || crypto.randomUUID(), + parts: (m.parts || [{ type: 'text', text: m.content || '' }]).map( + (p: any) => + p.type === 'tool-invocation' + ? { ...p, type: 'dynamic-tool' } + : p + ), + html: + m.role === 'assistant' && m.content && this.marked + ? this.toSafeHtml( + this.marked.parse( + this.normalizeMarkdown(m.content) + ) as string + ) + : undefined, + toolInvocations: m.toolInvocations || undefined, + pendingApproval: m.pendingApproval || undefined + })) + }) + ); + + this.sortConversations(); + + if (activeId) { + this.activeConversation = + this.conversations.find((c) => c.id === activeId) || null; + } + + this.changeDetectorRef.markForCheck(); + } catch { + // Corrupt storage — start fresh + } + } + + private async loadMarked() { + const [markedModule, remendModule] = await Promise.all([ + import('marked'), + import('remend') + ]); + this.marked = markedModule; + this.remend = remendModule.default; + configureMarked(markedModule.marked); + } + + private toSafeHtml(raw: string): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(this.postProcess(raw)); + } + + private normalizeMarkdown(text: string): string { + // Ensure headings at the start of a line have a blank line before them. + // Only match # after \n (line start), not mid-line (avoids breaking + // table cells like "| # of Trades |"). + let result = text.replace(/\n(#{1,6}\s)/g, '\n\n$1'); + + // Ensure custom fenced blocks start on their own line so marked + // parses them as fenced code blocks, not inline backticks. + result = result.replace( + /([^\n])(```(?:suggestions|metrics|sparkline|chart-area|chart-bar))/g, + '$1\n\n$2' + ); + + return result; + } + + private postProcess(html: string): string { + // Positive percentages → green + html = html.replace( + /(\+\d+(?:,\d{3})*(?:\.\d+)?%)/g, + '$1' + ); + // Negative percentages → red + html = html.replace( + /(-\d+(?:,\d{3})*(?:\.\d+)?%)/g, + '$1' + ); + return html; + } + + private clearRenderInterval() { + if (this.renderInterval) { + clearInterval(this.renderInterval); + this.renderInterval = null; + } + } + + private ensureChartObserver() { + setTimeout(() => { + if (this.messagesContainer?.nativeElement) { + this.chartInitializer.attach(this.messagesContainer.nativeElement); + } + }); + } + + private scrollToBottom() { + setTimeout(() => { + if (this.messagesContainer?.nativeElement) { + this.messagesContainer.nativeElement.scrollTop = + this.messagesContainer.nativeElement.scrollHeight; + } + }); + } + + /** + * Strip any trailing ```suggestions block during streaming so it + * only renders in the final pass (avoids flicker from partial tokens). + */ + private stripTrailingFencedBlock(text: string): string { + const idx = text.lastIndexOf('```suggestions'); + if (idx === -1) { + return text; + } + return text.slice(0, idx).trimEnd(); + } + + private focusInput() { + setTimeout(() => { + this.messageInput?.nativeElement?.focus(); + }); + } +} diff --git a/apps/client/src/app/pages/agent/agent-page.html b/apps/client/src/app/pages/agent/agent-page.html new file mode 100644 index 000000000..d5ebb02e5 --- /dev/null +++ b/apps/client/src/app/pages/agent/agent-page.html @@ -0,0 +1,317 @@ +
    + + + + +
    + @if (!activeConversation || activeConversation.messages.length === 0) { + +
    +
    +
    + +
    +

    Hello, Portfolio Owner

    +

    + I can help analyze your assets, check dividends, or rebalance. +

    +
    + @for (card of promptCards; track card.title) { + + } +
    +
    +
    + } @else { + +
    +
    + @for (message of activeConversation.messages; track $index) { + @if ( + message.role === 'user' || + message.content || + message.html || + message.activeTools?.length || + message.pendingApproval + ) { +
    +
    + @if (message.activeTools?.length) { +
    + @for (tool of message.activeTools; track tool) { + {{ tool }} + } +
    + } +
    + @if (message.role === 'assistant' && message.html) { +
    + } @else { + {{ message.content }} + } +
    + @if (message.pendingApproval) { +
    +
    Confirm action
    +
    + {{ + formatToolName(message.pendingApproval.toolName) + }} +
    {{
    +                          formatApprovalInput(message.pendingApproval)
    +                        }}
    +
    +
    + + +
    +
    + } + @if ( + message.role === 'assistant' && + message.requestId && + message.content && + !message.isStreaming + ) { +
    + + + + + + @if (message.latencyMs) { + + {{ formatLatency(message.latencyMs) }} + + + } +
    + } +
    +
    + } + } + @if ( + isLoading && + activeConversation.messages[activeConversation.messages.length - 1] + ?.content === '' && + !activeConversation.messages[activeConversation.messages.length - 1] + ?.activeTools?.length + ) { +
    +
    +
    +
    + +
    +
    +
    +
    + } +
    +
    + } + + +
    +
    + +
    +
    + + @if (modelDropdownOpen) { +
    + @for (m of models; track m.id) { + + } +
    + } +
    + +
    +
    +
    +
    +
    diff --git a/apps/client/src/app/pages/agent/agent-page.routes.ts b/apps/client/src/app/pages/agent/agent-page.routes.ts new file mode 100644 index 000000000..33f1e13b6 --- /dev/null +++ b/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 + } +]; diff --git a/apps/client/src/app/pages/agent/agent-page.scss b/apps/client/src/app/pages/agent/agent-page.scss new file mode 100644 index 000000000..0c4852e9f --- /dev/null +++ b/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; + } +} diff --git a/apps/client/src/app/pages/agent/rendering/chart-initializer.ts b/apps/client/src/app/pages/agent/rendering/chart-initializer.ts new file mode 100644 index 000000000..407564dea --- /dev/null +++ b/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(); + 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('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); + } +} diff --git a/apps/client/src/app/pages/agent/rendering/configure-marked.ts b/apps/client/src/app/pages/agent/rendering/configure-marked.ts new file mode 100644 index 000000000..0bef90c55 --- /dev/null +++ b/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 + ] + }); +} diff --git a/apps/client/src/app/pages/agent/rendering/extensions/allocation.ts b/apps/client/src/app/pages/agent/rendering/extensions/allocation.ts new file mode 100644 index 000000000..5372047fc --- /dev/null +++ b/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 `
    +
    + ${line.label} + ${line.value} +
    +
    +
    `; + }) + .join(''); + + return `
    ${rows}
    `; + } +}; diff --git a/apps/client/src/app/pages/agent/rendering/extensions/chart.ts b/apps/client/src/app/pages/agent/rendering/extensions/chart.ts new file mode 100644 index 000000000..623bb7b12 --- /dev/null +++ b/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 + ? `
    ${title}
    ` + : ''; + + // 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, '>'); + + return `
    + ${titleHtml} + ${escaped} +
    `; + } + }; +} + +export const chartAreaExtension = buildChartExtension('area'); +export const chartBarExtension = buildChartExtension('bar'); diff --git a/apps/client/src/app/pages/agent/rendering/extensions/metrics.ts b/apps/client/src/app/pages/agent/rendering/extensions/metrics.ts new file mode 100644 index 000000000..8313226c9 --- /dev/null +++ b/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 = `${line.delta}`; + } + return `
    +
    ${line.label}
    +
    ${line.value}
    + ${deltaHtml} +
    `; + }) + .join(''); + + return `
    ${cards}
    `; + } +}; diff --git a/apps/client/src/app/pages/agent/rendering/extensions/pills.ts b/apps/client/src/app/pages/agent/rendering/extensions/pills.ts new file mode 100644 index 000000000..22663313a --- /dev/null +++ b/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, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + return `\u21B3 ${escaped}`; + }) + .join(''); + + return `
    ${items}
    `; + } +}; diff --git a/apps/client/src/app/pages/agent/rendering/extensions/sparkline.ts b/apps/client/src/app/pages/agent/rendering/extensions/sparkline.ts new file mode 100644 index 000000000..fddad0e16 --- /dev/null +++ b/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 + ? `
    ${title}
    ` + : ''; + + return `
    + ${titleHtml} + + + + +
    `; + } +}; diff --git a/apps/client/src/app/pages/agent/rendering/line-parser.ts b/apps/client/src/app/pages/agent/rendering/line-parser.ts new file mode 100644 index 000000000..d1a155f4c --- /dev/null +++ b/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); +} diff --git a/apps/client/src/app/pages/agent/rendering/table-renderer.ts b/apps/client/src/app/pages/agent/rendering/table-renderer.ts new file mode 100644 index 000000000..0a349e96a --- /dev/null +++ b/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 = { + 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 `
    +
    + ${label} + ${row[1]} +
    +
    +
    `; + }) + .join(''); + + return `
    ${rows}
    `; +} + +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 `${getCellText(h)}`; + }) + .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 = `${text}`; + } else if (sentiment === 'negative') { + content = `${text}`; + } + } + + const align = + type === 'currency' || type === 'number' + ? ' style="text-align:right"' + : ''; + return `${content}`; + }) + .join(''); + return `${tds}`; + }) + .join(''); + + return `${ths}${trs}
    `; +} + +/** + * 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); +} diff --git a/apps/client/src/app/pages/agent/rendering/value-classifier.ts b/apps/client/src/app/pages/agent/rendering/value-classifier.ts new file mode 100644 index 000000000..35b99e5b7 --- /dev/null +++ b/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'; +} diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index b7a031bfa..56bcc1ec7 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -1,6 +1,7 @@ @import './styles/bootstrap'; @import './styles/table'; @import './styles/variables'; +@import './styles/agent-markdown'; @import 'svgmap/dist/svgMap'; diff --git a/apps/client/src/styles/agent-markdown.scss b/apps/client/src/styles/agent-markdown.scss new file mode 100644 index 000000000..a5708790e --- /dev/null +++ b/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)); + } +} diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts index 53ecd104e..023878d38 100644 --- a/libs/common/src/lib/routes/routes.ts +++ b/libs/common/src/lib/routes/routes.ts @@ -33,6 +33,11 @@ export const internalRoutes: Record = { }, title: $localize`Settings` }, + agent: { + path: 'agent', + routerLink: ['/agent'], + title: $localize`Agent` + }, adminControl: { excludeFromAssistant: (aUser: User) => { return hasPermission(aUser?.permissions, permissions.accessAdminControl);