Browse Source

Align AI chat with AI SDK 7

pull/7444/head
Ross Kuehl 1 month ago
parent
commit
5f26778833
Failed to extract signature
  1. 4
      CHANGELOG.md
  2. 7
      apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.spec.ts
  3. 79
      apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.ts
  4. 11
      apps/api/src/app/endpoints/ai/ai.controller.spec.ts
  5. 41
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  6. 20
      apps/api/src/app/endpoints/ai/ai.service.ts
  7. 1
      apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts
  8. 14
      package-lock.json
  9. 2
      package.json

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added an opt-in, read-only AI portfolio chat to the analysis page (experimental)
### Changed ### Changed
- Moved the tags to the overview tab of the account detail dialog (experimental) - Moved the tags to the overview tab of the account detail dialog (experimental)

7
apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.spec.ts

@ -7,6 +7,10 @@ import {
AiPortfolioToolsService AiPortfolioToolsService
} from './ai-portfolio-tools.service'; } from './ai-portfolio-tools.service';
jest.mock('ai', () => ({
tool: jest.fn((definition: unknown) => definition)
}));
describe('AiPortfolioToolsService', () => { describe('AiPortfolioToolsService', () => {
let portfolioService: { let portfolioService: {
getDetails: jest.Mock; getDetails: jest.Mock;
@ -170,13 +174,14 @@ describe('AiPortfolioToolsService', () => {
it('propagates an already-aborted tool execution signal', async () => { it('propagates an already-aborted tool execution signal', async () => {
const abortController = new AbortController(); const abortController = new AbortController();
abortController.abort(new Error('request aborted')); abortController.abort(new Error('request aborted'));
const execute = service.createTools(scope).getPortfolioSummary.execute; const execute = service.createTools().getPortfolioSummary.execute;
await expect( await expect(
execute( execute(
{}, {},
{ {
abortSignal: abortController.signal, abortSignal: abortController.signal,
context: scope,
messages: [], messages: [],
toolCallId: 'tool-call-1' toolCallId: 'tool-call-1'
} }

79
apps/api/src/app/endpoints/ai/ai-portfolio-tools.service.ts

@ -14,44 +14,91 @@ export interface AiPortfolioScope {
userId: string; userId: string;
} }
type AiPortfolioToolContext = Omit<AiPortfolioScope, 'abortSignal'>;
const aiPortfolioToolInputSchema: z.ZodType<Record<string, never>> = z.object(
{}
);
const aiPortfolioToolContextSchema: z.ZodType<AiPortfolioToolContext> =
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() @Injectable()
export class AiPortfolioToolsService { export class AiPortfolioToolsService {
private static readonly HOLDINGS_LIMIT = 25; private static readonly HOLDINGS_LIMIT = 25;
public constructor(private readonly portfolioService: PortfolioService) {} public constructor(private readonly portfolioService: PortfolioService) {}
public createTools(scope: AiPortfolioScope) { public createTools() {
return { return {
getPortfolioHoldings: tool({ getPortfolioHoldings: tool<
Record<string, never>,
Awaited<ReturnType<AiPortfolioToolsService['getPortfolioHoldings']>>,
AiPortfolioToolContext
>({
contextSchema: aiPortfolioToolContextSchema,
description: description:
'Read the portfolio holdings in the active scope, ordered by allocation. Monetary values use the stated currency and percentages are percentage points.', '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({}), inputSchema: aiPortfolioToolInputSchema,
execute: async (_input, { abortSignal }) => { execute: async (_input, { abortSignal, context }) => {
return this.getPortfolioHoldings({ return this.getPortfolioHoldings({
...scope, ...context,
abortSignal: abortSignal ?? scope.abortSignal abortSignal
}); });
} }
}), }),
getPortfolioPerformance: tool({ getPortfolioPerformance: tool<
Record<string, never>,
Awaited<ReturnType<AiPortfolioToolsService['getPortfolioPerformance']>>,
AiPortfolioToolContext
>({
contextSchema: aiPortfolioToolContextSchema,
description: description:
'Read compact portfolio performance metrics for the active date range and filters. Chart history is intentionally excluded. Percentages are percentage points.', 'Read compact portfolio performance metrics for the active date range and filters. Chart history is intentionally excluded. Percentages are percentage points.',
inputSchema: z.object({}), inputSchema: aiPortfolioToolInputSchema,
execute: async (_input, { abortSignal }) => { execute: async (_input, { abortSignal, context }) => {
return this.getPortfolioPerformance({ return this.getPortfolioPerformance({
...scope, ...context,
abortSignal: abortSignal ?? scope.abortSignal abortSignal
}); });
} }
}), }),
getPortfolioSummary: tool({ getPortfolioSummary: tool<
Record<string, never>,
Awaited<ReturnType<AiPortfolioToolsService['getPortfolioSummary']>>,
AiPortfolioToolContext
>({
contextSchema: aiPortfolioToolContextSchema,
description: description:
'Read a compact portfolio snapshot for the active date range and filters. Monetary values use the stated currency and percentages are percentage points.', '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({}), inputSchema: aiPortfolioToolInputSchema,
execute: async (_input, { abortSignal }) => { execute: async (_input, { abortSignal, context }) => {
return this.getPortfolioSummary({ return this.getPortfolioSummary({
...scope, ...context,
abortSignal: abortSignal ?? scope.abortSignal abortSignal
}); });
} }
}) })

11
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 { AiController } from './ai.controller';
import { AiService } from './ai.service'; import { AiService } from './ai.service';
jest.mock('ai', () => { jest.mock('@openrouter/ai-sdk-provider', () => ({
const actual = jest.requireActual('ai'); createOpenRouter: jest.fn()
}));
return { jest.mock('ai', () => ({
...actual,
pipeUIMessageStreamToResponse: jest.fn() pipeUIMessageStreamToResponse: jest.fn()
}; }));
});
describe('AiController', () => { describe('AiController', () => {
let aiService: { streamChat: jest.Mock }; let aiService: { streamChat: jest.Mock };

41
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 { AiPortfolioToolsService } from './ai-portfolio-tools.service';
import { AiService } from './ai.service'; import { AiService } from './ai.service';
jest.mock('ai', () => { jest.mock('@openrouter/ai-sdk-provider', () => ({
const actual = jest.requireActual('ai'); createOpenRouter: jest.fn()
}));
return { jest.mock('ai', () => ({
...actual,
generateText: jest.fn(), generateText: jest.fn(),
stepCountIs: jest.fn(), stepCountIs: jest.fn(),
streamText: jest.fn() streamText: jest.fn()
}; }));
});
describe('AiService', () => { describe('AiService', () => {
let aiModelService: { getModel: jest.Mock }; let aiModelService: { getModel: jest.Mock };
@ -88,13 +87,7 @@ describe('AiService', () => {
]); ]);
expect(aiModelService.getModel).toHaveBeenCalledTimes(1); expect(aiModelService.getModel).toHaveBeenCalledTimes(1);
expect(stepCountIs).toHaveBeenCalledWith(4); expect(stepCountIs).toHaveBeenCalledWith(4);
expect(aiPortfolioToolsService.createTools).toHaveBeenCalledWith({ expect(aiPortfolioToolsService.createTools).toHaveBeenCalledWith();
abortSignal: abortController.signal,
dateRange: 'ytd',
filters: [{ id: 'account-1', type: 'ACCOUNT' }],
userCurrency: 'USD',
userId: 'user-1'
});
expect(streamText).toHaveBeenCalledWith( expect(streamText).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
abortSignal: abortController.signal, abortSignal: abortController.signal,
@ -102,7 +95,27 @@ describe('AiService', () => {
maxRetries: 1, maxRetries: 1,
messages, messages,
stopWhen: 'four-step-stop', 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'
}
}
}) })
); );

20
apps/api/src/app/endpoints/ai/ai.service.ts

@ -80,6 +80,13 @@ export class AiService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}) { }) {
const portfolioToolContext = {
dateRange,
filters,
userCurrency,
userId
};
const result = streamAiText({ const result = streamAiText({
abortSignal, abortSignal,
maxOutputTokens: 800, maxOutputTokens: 800,
@ -100,13 +107,12 @@ export class AiService {
`Respond in the user's preferred language (${languageCode}).` `Respond in the user's preferred language (${languageCode}).`
].join('\n'), ].join('\n'),
timeout: 30_000, timeout: 30_000,
tools: this.aiPortfolioToolsService.createTools({ tools: this.aiPortfolioToolsService.createTools(),
abortSignal, toolsContext: {
dateRange, getPortfolioHoldings: portfolioToolContext,
filters, getPortfolioPerformance: portfolioToolContext,
userCurrency, getPortfolioSummary: portfolioToolContext
userId }
})
}); });
return result return result

1
apps/client/src/app/pages/portfolio/analysis/ai-chat.component.ts

@ -295,7 +295,6 @@ export function parseSafeMarkdown(value: string): SafeMarkdownBlock[] {
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-ai-chat-inline', selector: 'gf-ai-chat-inline',
standalone: true,
template: ` template: `
@for (part of parts(); track $index) { @for (part of parts(); track $index) {
@switch (part.type) { @switch (part.type) {

14
package-lock.json

@ -10,7 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@ai-sdk/angular": "2.0.175", "@ai-sdk/angular": "3.0.37",
"@angular/animations": "21.2.7", "@angular/animations": "21.2.7",
"@angular/cdk": "21.2.5", "@angular/cdk": "21.2.5",
"@angular/common": "21.2.7", "@angular/common": "21.2.7",
@ -182,16 +182,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ai-sdk/angular": { "node_modules/@ai-sdk/angular": {
"version": "2.0.175", "version": "3.0.37",
"resolved": "https://registry.npmjs.org/@ai-sdk/angular/-/angular-2.0.175.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/angular/-/angular-3.0.37.tgz",
"integrity": "sha512-lOUQhYP6JycimBETRQtUGwbGB40GiHHHs50Pjw0IP6r5fX08XZZNCazwh+k6vymzj+rulK4bc1hwQBx1Wmr1FQ==", "integrity": "sha512-eoD17UY2VPRBru2kuwYMeVj5VjgMaNkobpq36VYuranT7ZRNivYuCbibjy2Bl25ASNsboa3Lwv2mJWrHrpl27g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/provider-utils": "4.0.26", "@ai-sdk/provider-utils": "5.0.12",
"ai": "6.0.174" "ai": "7.0.37"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=22"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": ">=16.0.0" "@angular/core": ">=16.0.0"

2
package.json

@ -54,7 +54,7 @@
"workspace-generator": "nx workspace-generator" "workspace-generator": "nx workspace-generator"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/angular": "2.0.175", "@ai-sdk/angular": "3.0.37",
"@angular/animations": "21.2.7", "@angular/animations": "21.2.7",
"@angular/cdk": "21.2.5", "@angular/cdk": "21.2.5",
"@angular/common": "21.2.7", "@angular/common": "21.2.7",

Loading…
Cancel
Save