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. 13
      apps/api/src/app/endpoints/ai/ai.controller.spec.ts
  5. 49
      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
### 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)

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

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

@ -14,44 +14,91 @@ export interface AiPortfolioScope {
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()
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<string, never>,
Awaited<ReturnType<AiPortfolioToolsService['getPortfolioHoldings']>>,
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<string, never>,
Awaited<ReturnType<AiPortfolioToolsService['getPortfolioPerformance']>>,
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<string, never>,
Awaited<ReturnType<AiPortfolioToolsService['getPortfolioSummary']>>,
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
});
}
})

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

49
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'
}
}
})
);

20
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

1
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) {

14
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"

2
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",

Loading…
Cancel
Save