diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b33b14059..f7d3ad15b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added an opt-in, read-only AI portfolio chat to the analysis page (experimental) + ### Changed - Moved the tags to the overview tab of the account detail dialog (experimental) diff --git a/apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.spec.ts b/apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.spec.ts index b7d376d9d3..1bc58ed76a 100644 --- a/apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.spec.ts @@ -7,6 +7,10 @@ import { AiPortfolioToolsService } from './ai-portfolio-tools.service'; +jest.mock('ai', () => ({ + tool: jest.fn((definition: unknown) => definition) +})); + describe('AiPortfolioToolsService', () => { let portfolioService: { getDetails: jest.Mock; @@ -170,13 +174,14 @@ describe('AiPortfolioToolsService', () => { it('propagates an already-aborted tool execution signal', async () => { const abortController = new AbortController(); abortController.abort(new Error('request aborted')); - const execute = service.createTools(scope).getPortfolioSummary.execute; + const execute = service.createTools().getPortfolioSummary.execute; await expect( execute( {}, { abortSignal: abortController.signal, + context: scope, messages: [], toolCallId: 'tool-call-1' } diff --git a/apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.ts b/apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.ts index 9075ea2739..25717a2503 100644 --- a/apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.ts +++ b/apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.ts @@ -14,44 +14,91 @@ export interface AiPortfolioScope { userId: string; } +type AiPortfolioToolContext = Omit; + +const aiPortfolioToolInputSchema: z.ZodType> = z.object( + {} +); + +const aiPortfolioToolContextSchema: z.ZodType = + z.object({ + dateRange: z.string(), + filters: z + .array( + z.object({ + id: z.string(), + label: z.string().optional(), + type: z.enum([ + 'ACCOUNT', + 'ASSET_CLASS', + 'ASSET_SUB_CLASS', + 'DATA_SOURCE', + 'HOLDING_TYPE', + 'PRESET_ID', + 'SEARCH_QUERY', + 'SYMBOL', + 'TAG' + ]) + }) + ) + .optional(), + userCurrency: z.string(), + userId: z.string() + }); + @Injectable() export class AiPortfolioToolsService { private static readonly HOLDINGS_LIMIT = 25; public constructor(private readonly portfolioService: PortfolioService) {} - public createTools(scope: AiPortfolioScope) { + public createTools() { return { - getPortfolioHoldings: tool({ + getPortfolioHoldings: tool< + Record, + Awaited>, + AiPortfolioToolContext + >({ + contextSchema: aiPortfolioToolContextSchema, description: 'Read the portfolio holdings in the active scope, ordered by allocation. Monetary values use the stated currency and percentages are percentage points.', - inputSchema: z.object({}), - execute: async (_input, { abortSignal }) => { + inputSchema: aiPortfolioToolInputSchema, + execute: async (_input, { abortSignal, context }) => { return this.getPortfolioHoldings({ - ...scope, - abortSignal: abortSignal ?? scope.abortSignal + ...context, + abortSignal }); } }), - getPortfolioPerformance: tool({ + getPortfolioPerformance: tool< + Record, + Awaited>, + AiPortfolioToolContext + >({ + contextSchema: aiPortfolioToolContextSchema, description: 'Read compact portfolio performance metrics for the active date range and filters. Chart history is intentionally excluded. Percentages are percentage points.', - inputSchema: z.object({}), - execute: async (_input, { abortSignal }) => { + inputSchema: aiPortfolioToolInputSchema, + execute: async (_input, { abortSignal, context }) => { return this.getPortfolioPerformance({ - ...scope, - abortSignal: abortSignal ?? scope.abortSignal + ...context, + abortSignal }); } }), - getPortfolioSummary: tool({ + getPortfolioSummary: tool< + Record, + Awaited>, + AiPortfolioToolContext + >({ + contextSchema: aiPortfolioToolContextSchema, description: 'Read a compact portfolio snapshot for the active date range and filters. Monetary values use the stated currency and percentages are percentage points.', - inputSchema: z.object({}), - execute: async (_input, { abortSignal }) => { + inputSchema: aiPortfolioToolInputSchema, + execute: async (_input, { abortSignal, context }) => { return this.getPortfolioSummary({ - ...scope, - abortSignal: abortSignal ?? scope.abortSignal + ...context, + abortSignal }); } }) diff --git a/apps/api/src/app/endpoints/ai/ai.controller.spec.ts b/apps/api/src/app/endpoints/ai/ai.controller.spec.ts index 5b95b3da9f..af8c308cd6 100644 --- a/apps/api/src/app/endpoints/ai/ai.controller.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.controller.spec.ts @@ -13,14 +13,13 @@ import type { Response } from 'express'; import { AiController } from './ai.controller'; import { AiService } from './ai.service'; -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); +jest.mock('@openrouter/ai-sdk-provider', () => ({ + createOpenRouter: jest.fn() +})); - return { - ...actual, - pipeUIMessageStreamToResponse: jest.fn() - }; -}); +jest.mock('ai', () => ({ + pipeUIMessageStreamToResponse: jest.fn() +})); describe('AiController', () => { let aiService: { streamChat: jest.Mock }; diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index 0547ce4567..9dd2d41542 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -9,16 +9,15 @@ import { AiModelService } from './ai-model.service'; import { AiPortfolioToolsService } from './ai-portfolio-tools.service'; import { AiService } from './ai.service'; -jest.mock('ai', () => { - const actual = jest.requireActual('ai'); - - return { - ...actual, - generateText: jest.fn(), - stepCountIs: jest.fn(), - streamText: jest.fn() - }; -}); +jest.mock('@openrouter/ai-sdk-provider', () => ({ + createOpenRouter: jest.fn() +})); + +jest.mock('ai', () => ({ + generateText: jest.fn(), + stepCountIs: jest.fn(), + streamText: jest.fn() +})); describe('AiService', () => { let aiModelService: { getModel: jest.Mock }; @@ -88,13 +87,7 @@ describe('AiService', () => { ]); expect(aiModelService.getModel).toHaveBeenCalledTimes(1); expect(stepCountIs).toHaveBeenCalledWith(4); - expect(aiPortfolioToolsService.createTools).toHaveBeenCalledWith({ - abortSignal: abortController.signal, - dateRange: 'ytd', - filters: [{ id: 'account-1', type: 'ACCOUNT' }], - userCurrency: 'USD', - userId: 'user-1' - }); + expect(aiPortfolioToolsService.createTools).toHaveBeenCalledWith(); expect(streamText).toHaveBeenCalledWith( expect.objectContaining({ abortSignal: abortController.signal, @@ -102,7 +95,27 @@ describe('AiService', () => { maxRetries: 1, messages, stopWhen: 'four-step-stop', - timeout: 30_000 + timeout: 30_000, + toolsContext: { + getPortfolioHoldings: { + dateRange: 'ytd', + filters: [{ id: 'account-1', type: 'ACCOUNT' }], + userCurrency: 'USD', + userId: 'user-1' + }, + getPortfolioPerformance: { + dateRange: 'ytd', + filters: [{ id: 'account-1', type: 'ACCOUNT' }], + userCurrency: 'USD', + userId: 'user-1' + }, + getPortfolioSummary: { + dateRange: 'ytd', + filters: [{ id: 'account-1', type: 'ACCOUNT' }], + userCurrency: 'USD', + userId: 'user-1' + } + } }) ); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 7aa87a6134..f6dec7c46f 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -80,6 +80,13 @@ export class AiService { userCurrency: string; userId: string; }) { + const portfolioToolContext = { + dateRange, + filters, + userCurrency, + userId + }; + const result = streamAiText({ abortSignal, maxOutputTokens: 800, @@ -100,13 +107,12 @@ export class AiService { `Respond in the user's preferred language (${languageCode}).` ].join('\n'), timeout: 30_000, - tools: this.aiPortfolioToolsService.createTools({ - abortSignal, - dateRange, - filters, - userCurrency, - userId - }) + tools: this.aiPortfolioToolsService.createTools(), + toolsContext: { + getPortfolioHoldings: portfolioToolContext, + getPortfolioPerformance: portfolioToolContext, + getPortfolioSummary: portfolioToolContext + } }); return result diff --git a/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts b/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts index db2fd0ec97..d1c090773c 100644 --- a/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts @@ -295,7 +295,6 @@ export function parseSafeMarkdown(value: string): SafeMarkdownBlock[] { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'gf-ai-chat-inline', - standalone: true, template: ` @for (part of parts(); track $index) { @switch (part.type) { diff --git a/package-lock.json b/package-lock.json index 0084b45992..3d57ee612a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { - "@ai-sdk/angular": "2.0.175", + "@ai-sdk/angular": "3.0.37", "@angular/animations": "21.2.7", "@angular/cdk": "21.2.5", "@angular/common": "21.2.7", @@ -182,16 +182,16 @@ "license": "MIT" }, "node_modules/@ai-sdk/angular": { - "version": "2.0.175", - "resolved": "https://registry.npmjs.org/@ai-sdk/angular/-/angular-2.0.175.tgz", - "integrity": "sha512-lOUQhYP6JycimBETRQtUGwbGB40GiHHHs50Pjw0IP6r5fX08XZZNCazwh+k6vymzj+rulK4bc1hwQBx1Wmr1FQ==", + "version": "3.0.37", + "resolved": "https://registry.npmjs.org/@ai-sdk/angular/-/angular-3.0.37.tgz", + "integrity": "sha512-eoD17UY2VPRBru2kuwYMeVj5VjgMaNkobpq36VYuranT7ZRNivYuCbibjy2Bl25ASNsboa3Lwv2mJWrHrpl27g==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider-utils": "4.0.26", - "ai": "6.0.174" + "@ai-sdk/provider-utils": "5.0.12", + "ai": "7.0.37" }, "engines": { - "node": ">=18" + "node": ">=22" }, "peerDependencies": { "@angular/core": ">=16.0.0" diff --git a/package.json b/package.json index a8755b938f..dbddafe076 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "workspace-generator": "nx workspace-generator" }, "dependencies": { - "@ai-sdk/angular": "2.0.175", + "@ai-sdk/angular": "3.0.37", "@angular/animations": "21.2.7", "@angular/cdk": "21.2.5", "@angular/common": "21.2.7",