Browse Source

feat(agent): in-app AI Agent chat + LangSmith tracing

- Add /agent route and full-page chat UI (suggestions, messages, send)
- Add 'AI Agent' nav link in header (desktop + mobile) for users with readAiPrompt
- Use session JWT for agent API (no security token prompt when logged in)
- Add LangSmith tracing (wrapAISDK, createLangSmithProviderOptions) when LANGSMITH_API_KEY set
- Document LANGSMITH_* in ENV_KEYS and agent README
- Add TESTING.md for curl/browser agent testing
- Add langsmith dep; internal route agent in routes.ts

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/6386/head
Yash Kuceriya 1 month ago
parent
commit
3d2b22891f
  1. 2
      .env.production.example
  2. 2
      ENV_KEYS.md
  3. 4
      apps/api/src/app/endpoints/agent/README.md
  4. 55
      apps/api/src/app/endpoints/agent/TESTING.md
  5. 46
      apps/api/src/app/endpoints/agent/agent.service.ts
  6. 9
      apps/client/src/app/app.routes.ts
  7. 28
      apps/client/src/app/components/header/header.component.html
  8. 7
      apps/client/src/app/components/header/header.component.ts
  9. 81
      apps/client/src/app/pages/agent/agent-page.component.html
  10. 139
      apps/client/src/app/pages/agent/agent-page.component.ts
  11. 112
      apps/client/src/app/pages/agent/agent-page.scss
  12. 6
      libs/common/src/lib/routes/routes.ts
  13. 1135
      package-lock.json
  14. 1
      package.json

2
.env.production.example

@ -21,5 +21,5 @@ NODE_ENV=production
PORT=3000 PORT=3000
# AGENT (OpenRouter) # AGENT (OpenRouter)
OPENROUTER_API_KEY=<your-openrouter-key-from-openrouter.ai> OPENROUTER_API_KEY=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
OPENROUTER_MODEL=openai/gpt-4o-mini OPENROUTER_MODEL=openai/gpt-4o-mini

2
ENV_KEYS.md

@ -19,6 +19,7 @@ You **choose** the values; nothing is provided by a third party except OpenRoute
| **JWT_SECRET_KEY** | Generate a random string (e.g. `openssl rand -hex 32`) | `agentforge-jwt-secret-2026` | | **JWT_SECRET_KEY** | Generate a random string (e.g. `openssl rand -hex 32`) | `agentforge-jwt-secret-2026` |
| **OPENROUTER_API_KEY** | From [openrouter.ai](https://openrouter.ai) → Keys → Create Key | `sk-or-v1-...` | | **OPENROUTER_API_KEY** | From [openrouter.ai](https://openrouter.ai) → Keys → Create Key | `sk-or-v1-...` |
| **OPENROUTER_MODEL** | Your choice (optional; has default) | `openai/gpt-4o-mini` | | **OPENROUTER_MODEL** | Your choice (optional; has default) | `openai/gpt-4o-mini` |
| **LANGSMITH_API_KEY** (optional) | From [smith.langchain.com](https://smith.langchain.com) → Settings → API Key. For AI request tracing (like Collabboard). | `lsv2_pt_...` |
**Setup:** Copy `.env.dev` to `.env`, then replace every `<INSERT_...>` with your chosen values. For a quick local dev setup you can use the examples in the table above. **Setup:** Copy `.env.dev` to `.env`, then replace every `<INSERT_...>` with your chosen values. For a quick local dev setup you can use the examples in the table above.
@ -40,6 +41,7 @@ Here, **Postgres and Redis are provided by Railway**; you only generate the two
| **OPENROUTER_MODEL** | Your choice; e.g. `openai/gpt-4o-mini`. | | **OPENROUTER_MODEL** | Your choice; e.g. `openai/gpt-4o-mini`. |
| **NODE_ENV** | Set to `production`. | | **NODE_ENV** | Set to `production`. |
| **PORT** | **Required on Railway.** Set to `3000` so the app listens on the same port Railway routes to (target port 3000). The app default is 3333, so without this you get "Application failed to respond". | | **PORT** | **Required on Railway.** Set to `3000` so the app listens on the same port Railway routes to (target port 3000). The app default is 3333, so without this you get "Application failed to respond". |
| **LANGSMITH_API_KEY** (optional) | From [smith.langchain.com](https://smith.langchain.com). Enables LangSmith tracing for agent runs (latency, tokens, runs in dashboard). |
**Setup:** In your Railway project, open the **Ghostfolio** service (the one from GitHub) → **Variables** → add each variable. For Postgres and Redis, copy from the addon services. For `ACCESS_TOKEN_SALT` and `JWT_SECRET_KEY`, generate once and paste. **Setup:** In your Railway project, open the **Ghostfolio** service (the one from GitHub) → **Variables** → add each variable. For Postgres and Redis, copy from the addon services. For `ACCESS_TOKEN_SALT` and `JWT_SECRET_KEY`, generate once and paste.

4
apps/api/src/app/endpoints/agent/README.md

@ -58,6 +58,10 @@ node scripts/agent-setup.mjs --openrouter-key=sk-or-YOUR_KEY
This writes `API_KEY_OPENROUTER` and `OPENROUTER_MODEL` (default: `openai/gpt-4o-mini`) to the DB. Alternatively, set them in Ghostfolio Admin → Settings. This writes `API_KEY_OPENROUTER` and `OPENROUTER_MODEL` (default: `openai/gpt-4o-mini`) to the DB. Alternatively, set them in Ghostfolio Admin → Settings.
## LangSmith tracing (optional)
Same pattern as Collabboard. Set **`LANGSMITH_API_KEY`** (or `LANGCHAIN_API_KEY`) in env; the agent will wrap the Vercel AI SDK with LangSmith and send runs to [smith.langchain.com](https://smith.langchain.com). Optional: `LANGCHAIN_PROJECT=Ghostfolio` to set the project name. No code changes needed when the key is absent.
## Eval ## Eval
- **Test cases:** See `eval-cases.ts` (10 cases: 6 happy path, 2 edge, 1 adversarial, 1 multi-step). - **Test cases:** See `eval-cases.ts` (10 cases: 6 happy path, 2 edge, 1 adversarial, 1 multi-step).

55
apps/api/src/app/endpoints/agent/TESTING.md

@ -0,0 +1,55 @@
# How to Test the Agent (Production / Railway)
Base URL: `https://ghostfolio-production-1d0f.up.railway.app` (or your deployed URL)
## 1. Health check
```bash
curl -s https://ghostfolio-production-1d0f.up.railway.app/api/v1/health
# Expect: {"status":"OK"}
```
## 2. Get a JWT from your security token
Use your Ghostfolio **security token** (Admin → your user → access token).
```bash
export BASE=https://ghostfolio-production-1d0f.up.railway.app
export SECURITY_TOKEN="<paste your security token here>"
curl -s -X POST "$BASE/api/v1/auth/anonymous" \
-H "Content-Type: application/json" \
-d "{\"accessToken\": \"$SECURITY_TOKEN\"}"
# Expect: {"authToken":"eyJhbGc..."} → save the authToken value
```
## 3. Chat with the agent (API)
```bash
export JWT="<paste the authToken from step 2>"
curl -s -X POST "$BASE/api/v1/agent/chat" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $JWT" \
-d '{"messages":[{"role":"user","content":"What is my current portfolio allocation?"}]}'
# Expect: {"message":{"role":"assistant","content":"..."},"verification":{...}}
```
## 4. Chat in the browser (easiest)
1. Open: **https://ghostfolio-production-1d0f.up.railway.app/api/v1/agent/chat**
2. When prompted, paste your **security token**.
3. The page exchanges it for a JWT and then you can chat in the UI.
## 5. Example test questions
- "What is my portfolio allocation?"
- "How did my portfolio perform this year?"
- "List my recent transactions."
- "What is the current price of AAPL?"
## Troubleshooting
- **403 on /auth/anonymous:** Security token is wrong or expired. Regenerate in Ghostfolio Admin.
- **403 on /agent/chat:** JWT expired or missing. Get a fresh JWT from step 2.
- **500 or empty response:** Check `OPENROUTER_API_KEY` and `OPENROUTER_MODEL` in Railway Variables.

46
apps/api/src/app/endpoints/agent/agent.service.ts

@ -11,12 +11,34 @@ import { DataSource } from '@prisma/client';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import * as ai from 'ai';
import { generateText, tool } from 'ai'; import { generateText, tool } from 'ai';
import { Client } from 'langsmith';
import {
createLangSmithProviderOptions,
wrapAISDK
} from 'langsmith/experimental/vercel';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { z } from 'zod'; import { z } from 'zod';
import { AgentTraceService, ToolTrace } from './agent-trace.service'; import { AgentTraceService, ToolTrace } from './agent-trace.service';
const LANGSMITH_ENDPOINT = 'https://api.smith.langchain.com';
function ensureLangSmithEnv(): string | null {
const key =
process.env.LANGSMITH_API_KEY ?? process.env.LANGCHAIN_API_KEY;
if (!key) return null;
process.env.LANGCHAIN_API_KEY = process.env.LANGCHAIN_API_KEY ?? key;
process.env.LANGCHAIN_TRACING = 'true';
process.env.LANGSMITH_TRACING = 'true';
process.env.LANGCHAIN_ENDPOINT =
process.env.LANGCHAIN_ENDPOINT ?? LANGSMITH_ENDPOINT;
process.env.LANGSMITH_ENDPOINT =
process.env.LANGSMITH_ENDPOINT ?? LANGSMITH_ENDPOINT;
return key;
}
export interface AgentChatMessage { export interface AgentChatMessage {
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
@ -280,14 +302,34 @@ export class AgentService {
content: m.content content: m.content
})); }));
// Optional LangSmith tracing (same pattern as Collabboard)
const hasLangSmith = !!ensureLangSmithEnv();
const langsmithClient = hasLangSmith ? new Client() : null;
const tracedAi = langsmithClient
? wrapAISDK(ai, { client: langsmithClient })
: null;
const generateTextFn = tracedAi?.generateText ?? generateText;
const llmT0 = Date.now(); const llmT0 = Date.now();
const { text, usage } = await generateText({ const { text, usage } = await generateTextFn({
model: openRouter.chat(openRouterModel), model: openRouter.chat(openRouterModel),
system: systemPrompt, system: systemPrompt,
messages: coreMessages, messages: coreMessages,
tools, tools,
maxSteps: 5 maxSteps: 5,
...(langsmithClient && {
providerOptions: {
langsmith: createLangSmithProviderOptions({
name: 'Ghostfolio Agent',
tags: ['ghostfolio', 'agent'],
metadata: { traceId }
})
}
})
}); });
if (langsmithClient) {
await langsmithClient.awaitPendingTraceBatches?.();
}
const llmMs = Date.now() - llmT0; const llmMs = Date.now() - llmT0;
const { content, verification } = verifyAgentOutput(text); const { content, verification } = verifyAgentOutput(text);

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

@ -27,6 +27,15 @@ export const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/admin/admin-page.routes').then((m) => m.routes) import('./pages/admin/admin-page.routes').then((m) => m.routes)
}, },
{
canActivate: [AuthGuard],
loadComponent: () =>
import('./pages/agent/agent-page.component').then(
(c) => c.GfAgentPageComponent
),
path: internalRoutes.agent.path,
title: internalRoutes.agent.title
},
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
loadComponent: () => loadComponent: () =>

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

@ -88,6 +88,22 @@
>Resources</a >Resources</a
> >
</li> </li>
@if (hasPermissionToAccessAgent) {
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.agent.path,
'text-decoration-underline':
currentRoute === internalRoutes.agent.path
}"
[routerLink]="routerLinkAgent"
>AI Agent</a
>
</li>
}
@if ( @if (
hasPermissionForSubscription && user?.subscription?.type === 'Basic' hasPermissionForSubscription && user?.subscription?.type === 'Basic'
) { ) {
@ -289,6 +305,18 @@
>Admin Control</a >Admin Control</a
> >
} }
@if (hasPermissionToAccessAgent) {
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.agent.path
}"
[routerLink]="routerLinkAgent"
>AI Agent</a
>
}
<hr class="m-0" /> <hr class="m-0" />
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"

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

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

81
apps/client/src/app/pages/agent/agent-page.component.html

@ -0,0 +1,81 @@
<div class="container h-100 d-flex flex-column agent-page">
<h1 class="h3 line-height-1 mb-3 mt-3">
<span i18n>AI Agent</span>
<small class="text-muted d-block mt-1" i18n>Ask questions about your portfolio in plain language.</small>
</h1>
@if (messages.length === 0 && !isLoading) {
<div class="suggestions mb-3">
<p class="text-muted mb-2" i18n>Try asking:</p>
<div class="d-flex flex-wrap gap-2">
@for (s of suggestions; track s) {
<button
class="suggestion-chip"
mat-stroked-button
(click)="onSuggestionClick(s)"
>
{{ s }}
</button>
}
</div>
</div>
}
<div
#messagesContainer
class="messages-container flex-grow-1 overflow-auto mb-2 p-2 rounded"
>
@for (msg of messages; track $index) {
<div
class="message-row d-flex gap-2 mb-3"
[class.user]="msg.role === 'user'"
[class.assistant]="msg.role === 'assistant'"
>
@if (msg.role === 'assistant') {
<div class="avatar assistant-avatar">G</div>
}
<div
class="message-bubble p-2 rounded"
[class.user-bubble]="msg.role === 'user'"
[class.assistant-bubble]="msg.role === 'assistant'"
>
<span class="message-content">{{ msg.content }}</span>
</div>
@if (msg.role === 'user') {
<div class="avatar user-avatar">U</div>
}
</div>
}
@if (isLoading) {
<div class="message-row d-flex gap-2 mb-3 assistant">
<div class="avatar assistant-avatar">G</div>
<div class="message-bubble assistant-bubble p-2 rounded typing">
<span class="typing-dots">
<span></span><span></span><span></span>
</span>
</div>
</div>
}
</div>
<div class="input-bar d-flex gap-2 align-items-end p-2 rounded">
<textarea
#inputEl
class="flex-grow-1 p-2 rounded border-0"
[placeholder]="'Ask about your portfolio...' | i18n"
[(ngModel)]="inputText"
[disabled]="isLoading"
rows="1"
(keydown.enter)="onInputKeydown($event)"
></textarea>
<button
class="send-btn"
mat-flat-button
color="primary"
[disabled]="isLoading || !inputText?.trim()"
(click)="send()"
>
<span i18n>Send</span>
</button>
</div>
</div>

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

@ -0,0 +1,139 @@
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { CommonModule } from '@angular/common';
import {
ChangeDetectorRef,
Component,
ElementRef,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
interface AgentChatResponse {
message: { role: string; content: string };
verification?: { passed: boolean; type: string; message?: string };
error?: string;
}
@Component({
host: { class: 'page' },
imports: [
CommonModule,
FormsModule,
MatButtonModule,
RouterModule
],
selector: 'gf-agent-page',
styleUrls: ['./agent-page.scss'],
templateUrl: './agent-page.html'
})
export class GfAgentPageComponent implements OnDestroy, OnInit {
@ViewChild('messagesContainer') messagesContainer: ElementRef<HTMLElement>;
@ViewChild('inputEl') inputEl: ElementRef<HTMLTextAreaElement>;
public inputText = '';
public isLoading = false;
public messages: ChatMessage[] = [];
public routerLinkPortfolio = internalRoutes.portfolio.routerLink;
public suggestions = [
$localize`What is my portfolio allocation?`,
$localize`How did my portfolio perform this year?`,
$localize`List my recent transactions.`,
$localize`What is the current price of AAPL?`
];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private http: HttpClient,
private notificationService: NotificationService
) {}
public ngOnInit() {
// No auth needed here - AuthGuard ensures user is logged in; interceptor adds JWT
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
public onInputKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
if (event.shiftKey) return; // allow newline with Shift+Enter
event.preventDefault();
this.send();
}
public onSuggestionClick(text: string) {
this.inputText = text;
this.send();
}
public send() {
const text = (this.inputText ?? '').trim();
if (!text || this.isLoading) return;
this.inputText = '';
this.messages.push({ role: 'user', content: text });
this.isLoading = true;
this.scrollToBottom();
this.changeDetectorRef.markForCheck();
const body = {
messages: this.messages.map((m) => ({ role: m.role, content: m.content }))
};
this.http
.post<AgentChatResponse>('/api/v1/agent/chat', body)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: (res) => {
this.messages.push({
role: 'assistant',
content: res.message?.content ?? $localize`No response.`
});
this.isLoading = false;
this.scrollToBottom();
this.changeDetectorRef.markForCheck();
},
error: (err) => {
const msg =
err?.error?.error ??
err?.message ??
$localize`Request failed. Check your connection and try again.`;
this.messages.push({
role: 'assistant',
content: $localize`Error: ${msg}`
});
this.isLoading = false;
this.scrollToBottom();
this.changeDetectorRef.markForCheck();
this.notificationService.alert({ title: $localize`Agent Error`, message: msg });
}
});
}
private scrollToBottom() {
setTimeout(() => {
const el = this.messagesContainer?.nativeElement;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, 50);
}
}

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

@ -0,0 +1,112 @@
.agent-page {
max-width: 48rem;
margin-left: auto;
margin-right: auto;
}
.messages-container {
min-height: 12rem;
background: var(--mat-sys-surface-container-low, #f5f5f5);
}
:host-context(.dark-theme) .messages-container {
background: var(--mat-sys-surface-container-low, #1e1e1e);
}
.message-row {
max-width: 90%;
&.user {
flex-direction: row-reverse;
margin-left: auto;
.user-bubble {
background: var(--mat-sys-primary-container, #d0bcff);
color: var(--mat-sys-on-primary-container, #1d1b20);
}
}
&.assistant {
margin-right: auto;
.assistant-bubble {
background: var(--mat-sys-surface-container-high, #e6e0e9);
color: var(--mat-sys-on-surface, #1c1b1f);
}
}
}
:host-context(.dark-theme) .message-row.assistant .assistant-bubble {
background: var(--mat-sys-surface-container-high, #2d2a32);
color: var(--mat-sys-on-surface, #e6e1e5);
}
.message-bubble {
max-width: 100%;
word-break: break-word;
white-space: pre-wrap;
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
&.user-avatar {
background: var(--mat-sys-primary, #6750a4);
color: white;
}
&.assistant-avatar {
background: var(--mat-sys-secondary-container, #e8def8);
color: var(--mat-sys-on-secondary-container, #1d1b20);
}
}
.typing-dots {
display: flex;
gap: 0.25rem;
span {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: currentColor;
opacity: 0.5;
animation: typing-blink 1.4s ease-in-out infinite both;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing-blink {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
.input-bar {
background: var(--mat-sys-surface-container, #f3edf7);
textarea {
min-height: 2.5rem;
resize: none;
font: inherit;
outline: none;
}
}
:host-context(.dark-theme) .input-bar {
background: var(--mat-sys-surface-container, #2d2a32);
}
.suggestion-chip {
font-size: 0.875rem;
}

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

@ -68,6 +68,12 @@ export const internalRoutes: Record<string, InternalRoute> = {
routerLink: ['/accounts'], routerLink: ['/accounts'],
title: $localize`Accounts` title: $localize`Accounts`
}, },
agent: {
excludeFromAssistant: true,
path: 'agent',
routerLink: ['/agent'],
title: $localize`AI Agent`
},
api: { api: {
excludeFromAssistant: true, excludeFromAssistant: true,
path: 'api', path: 'api',

1135
package-lock.json

File diff suppressed because it is too large

1
package.json

@ -114,6 +114,7 @@
"http-status-codes": "2.3.0", "http-status-codes": "2.3.0",
"ionicons": "8.0.13", "ionicons": "8.0.13",
"jsonpath": "1.1.1", "jsonpath": "1.1.1",
"langsmith": "^0.5.6",
"lodash": "4.17.23", "lodash": "4.17.23",
"marked": "17.0.2", "marked": "17.0.2",
"ms": "3.0.0-canary.1", "ms": "3.0.0-canary.1",

Loading…
Cancel
Save