diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000000..cd0fafca2ec --- /dev/null +++ b/PLAN.md @@ -0,0 +1,308 @@ +--- +name: AI Journey Creation Revival +overview: Plan to revive the AI journey creation prototype from `feature/25-05-ES-ai-journey-creation`, assessing merge feasibility vs. rebuild, cataloguing external dependencies, and designing token usage controls. +todos: + - id: merge-main + content: Merge main into feature branch, resolve 6 identified conflicts, regenerate lockfile + status: completed + - id: build-fix + content: Fix build errors and test failures from 6 months of main evolution + status: pending + - id: verify-services + content: Verify all external API keys (Gemini, Vertex AI, Langfuse prompts, Firecrawl, Algolia, Cloudflare) + status: pending + - id: rate-limiting + content: Add request-level rate limiting to /api/chat/route.ts (e.g., Upstash or Redis-backed, keyed by user_id) + status: pending + - id: langfuse-usage + content: Ensure Langfuse tracing captures token usage with user/team metadata for all model calls + status: pending + - id: feature-flag + content: Verify aiEditButton LaunchDarkly flag and deploy to stage with controlled access + status: pending + - id: e2e-validation + content: End-to-end validation of all tools on stage environment + status: pending +isProject: false +--- + +# AI Journey Creation Revival Plan + +## Current State Summary + +**Branch:** `feature/25-05-ES-ai-journey-creation` +**Last activity:** Aug 15, 2025 (~6 months stale) +**Merge base with main:** Aug 14, 2025 +**Feature branch:** 383 commits ahead of main, main is 479 commits ahead of feature branch +**Feature branch scope:** 110 files changed, ~11k insertions across the prototype + +--- + +## Phase 1: Assess Merge vs. Rebuild (Recommended First Step) + +### Merge Conflict Analysis (Already Done) + +A dry-run merge of `main` into the feature branch produces **6 conflicts**: + + +| File | Conflict Type | Estimated Difficulty | +| ---- | ------------- | -------------------- | + + +- `apps/journeys-admin/next-env.d.ts` - trivial (auto-generated types) +- `apps/journeys-admin/next.config.js` - low (webpack/instrumentation additions) +- `apps/journeys-admin/src/components/Editor/Editor.tsx` - medium (AI button integration point) +- `libs/locales/en/apps-journeys-admin.json` - low (locale keys additions) +- `package-lock.json` - **deleted on main** (project likely moved to different lock strategy) - must regenerate +- `package.json` - medium (dependency additions from both sides) + +**Verdict: Merge is feasible.** The conflicts are few and well-understood. The prototype's code is almost entirely *new files* (76 new files) with only 11 modified files. This means the AI code is largely additive and won't collide with main's 6 months of evolution. + +### Recommended Approach: Merge main into feature branch + +1. Create a working branch from the feature branch +2. Merge `main` into it +3. Resolve the 6 conflicts (estimated 1-2 hours) +4. Delete `package-lock.json`, run the project's install command to regenerate +5. Run builds and tests to verify integration + +**Why not rebuild?** The prototype has ~76 new files, well-structured with tests. Rebuilding would discard proven logic for the chat UI, tool invocation rendering, image generation pipeline, video analysis, and Langfuse tracing. The merge conflicts are minimal relative to that cost. + +--- + +## Phase 2: Prototype Architecture and External Dependency Audit + +### Architecture Overview + +```mermaid +flowchart TD + subgraph frontend [Frontend - journeys-admin] + AiEditButton["AiEditButton (behind aiEditButton flag)"] + AiChat["AiChat (useChat from @ai-sdk/react)"] + ToolUI["Tool Invocation UI Components"] + AiEditButton --> AiChat + AiChat --> ToolUI + end + + subgraph apiRoute [Next.js API Route] + ChatRoute["POST /api/chat/route.ts"] + ChatRoute -->|"streamText"| GeminiFlash["Gemini 2.5 Flash"] + ChatRoute -->|"telemetry"| LangfuseTrace["Langfuse Tracing"] + ChatRoute -->|"prompt mgmt"| LangfusePrompt["Langfuse Prompts"] + end + + subgraph agentTools [Server-Side Agent Tools] + GenImage["generateImage (Vertex AI Imagen 3)"] + WebSearch["webSearch (Firecrawl)"] + VideoSearch["internalVideoSearch (Algolia)"] + YouTubeAnalyzer["youtubeAnalyzer (Gemini + Google GenAI)"] + end + + subgraph journeyTools [Journey CRUD Tools] + JourneyGet["journeySimpleGet (GraphQL)"] + JourneyUpdate["journeySimpleUpdate (GraphQL)"] + end + + subgraph clientTools [Client-Side Tools - partially commented out] + SelectImage["selectImage"] + SelectVideo["selectVideo"] + RedirectEditor["redirectUserToEditor"] + RequestForm["requestForm"] + end + + subgraph sharedLib [Shared Lib - libs/shared/ai] + SimpleTypes["journeySimpleTypes.ts (Zod schemas)"] + LangTypes["languageTypes.ts"] + VideoSubTypes["videoSubtitleTypes.ts"] + end + + AiChat -->|"POST /api/chat"| ChatRoute + ChatRoute --> agentTools + ChatRoute --> journeyTools + ChatRoute --> clientTools + GenImage -->|"upload"| CloudflareImages["Cloudflare Images"] + JourneyGet --> GraphQLAPI["api-journeys-modern GraphQL"] + JourneyUpdate --> GraphQLAPI +``` + + + +### External Services and API Keys Required + + +| Service | Env Variable(s) | Purpose | Status to Verify | +| ------- | --------------- | ------- | ---------------- | + + +- **Google Gemini (primary LLM)** - `GOOGLE_GENERATIVE_AI_API_KEY` - Main chat model (gemini-2.5-flash) + YouTube analysis - Check API key validity and billing +- **Google Vertex AI** - `NEXT_PUBLIC_FIREBASE_PROJECT_ID`, `PRIVATE_FIREBASE_CLIENT_EMAIL`, `PRIVATE_FIREBASE_PRIVATE_KEY` - Image generation (Imagen 3.0) - Check Vertex AI is enabled in GCP project +- **Langfuse** - `NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY`, `NEXT_PUBLIC_LANGFUSE_BASE_URL`, plus server-side defaults - Prompt management, tracing, telemetry - Check Langfuse project exists and prompts are configured (especially `system/api/chat/route`) +- **Firecrawl** - `FIRECRAWL_API_KEY` - Web scraping for the webSearch tool - Check active subscription/API key +- **Algolia** - `NEXT_PUBLIC_ALGOLIA_APP_ID`, `NEXT_PUBLIC_ALGOLIA_API_KEY`, `NEXT_PUBLIC_ALGOLIA_INDEX` - Internal video search - Likely already configured for existing features +- **Cloudflare Images** - `NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY` + GraphQL mutation - AI-generated image hosting - Likely already configured for existing features + +### Feature Flag + +The prototype uses the `aiEditButton` LaunchDarkly flag via `useFlags()`. This is already the right approach for controlled rollout. + +--- + +## Phase 3: Token Usage Protection + +The prototype currently has **zero token usage controls**. The chat route at `app/api/chat/route.ts` calls `streamText` with no rate limiting, no token budgets, and no cost tracking beyond Langfuse telemetry. + +### Recommended Token Usage Strategy + +**Tier 1 - Observability (Quick Win, do first)** + +- Langfuse is already integrated for tracing. Ensure all model calls (Gemini chat, Gemini YouTube analysis, Imagen generation) are traced with `userId` and `sessionId` tags +- Use Langfuse dashboards to monitor cost per user/team before building enforcement + +**Tier 2 - Rate Limiting (Medium effort)** + +- Add rate limiting at the `/api/chat/route.ts` API endpoint level +- Options: + - **Simple approach:** Use an in-memory or Redis-backed rate limiter (e.g., `@upstash/ratelimit`) keyed by `user_id` from JWT. Limit requests per minute/hour + - **Per-team:** Decode the user's team from JWT or a DB lookup, apply team-level limits + +**Tier 3 - Token Budget Tracking (Higher effort)** + +- Track cumulative token usage per user/team in a database +- The Vercel AI SDK's `streamText` provides `usage` data in the `onFinish` callback (already partially used). Extract `promptTokens`, `completionTokens`, and `totalTokens` +- Before each request, check if the user/team has remaining budget +- Langfuse also captures token usage data - could query Langfuse API for usage summaries instead of building a separate store + +**Recommended starting point:** Tier 1 + Tier 2 (Langfuse monitoring + simple rate limiting). This protects against runaway costs quickly without a heavy implementation. + +--- + +## Execution Order + +### Step 1: Merge and Stabilize (~1-2 days) + +1. Branch off `feature/25-05-ES-ai-journey-creation` into a new working branch +2. Merge `main` in, resolve the 6 identified conflicts +3. Regenerate lockfile, install dependencies +4. Fix any build errors from 6 months of main evolution (API changes, import path changes, etc.) +5. Run test suite for affected packages + +### Step 2: Verify External Services (~0.5 day) + +1. Verify each API key listed above is active +2. Confirm Langfuse project has the `system/api/chat/route` prompt configured +3. Test each tool individually (image gen, web search, video search, YouTube analysis) +4. Document any expired keys or services that need re-provisioning + +### Step 3: Add Rate Limiting (~1 day) + +1. Add request-level rate limiting to `/api/chat/route.ts` +2. Ensure Langfuse tracing captures all token usage with user/team metadata +3. Set conservative limits initially (e.g., 20 requests/hour per user) + +### Step 4: Feature Flag and Deployment (~0.5 day) + +1. Verify `aiEditButton` flag exists in LaunchDarkly +2. Deploy to stage environment with flag enabled only for test users +3. Validate end-to-end flow on stage + +### Step 5: Iterate (~ongoing) + +1. Monitor usage via Langfuse +2. Adjust rate limits based on observed patterns +3. Consider Tier 3 token budgets if needed + + +# Report + +## Phase 1: Merge Report: AI Journey Creation Revival + +### Branch Created +- **`feature/26-02-JB-ai-journey-creation-revival`** - based off `origin/feature/25-05-ES-ai-journey-creation` with `main` merged in + +### Conflicts Resolved (6/6) + +| File | Resolution | +|---|---| +| `apps/journeys-admin/next-env.d.ts` | Took main's updated Next.js type references | +| `apps/journeys-admin/next.config.js` | Combined main's `reactCompiler` with feature's `instrumentationHook` + webpack `raw-loader` config. Removed duplicated `outputFileTracingExcludes` (already at top level) | +| `apps/journeys-admin/src/components/Editor/Editor.tsx` | Added `AiEditButton` (behind `aiEditButton` flag) inside main's new `MuxVideoUploadProvider` wrapper | +| `libs/locales/en/apps-journeys-admin.json` | Dropped 3 old locale keys ("Delete Card?", "Delete", "Are you sure...") that main consolidated into "Delete {{ label }}" | +| `package-lock.json` | Deleted (main removed it -- project moved to a different lock strategy) | +| `package.json` | Merged all deps -- details below | + +### Post-Merge Codegen Cleanup +Running codegen after the merge produced several unexpected changes: +1. **`pnpm-lock.yaml`**: Regenerated as expected. +2. **`LoadLanguages.ts` & `LoadVideoSubtitleContent.ts`**: New generated types for AI prototype tools. Committed. +3. **`TemplateVideoUpload*` vs `TemplateCustomize*`**: Codegen renamed these files due to operation name changes in `TemplateVideoUploadProvider/graphql.ts` (from a recent main merge). +4. **`apis/api-journeys/src/__generated__/graphql.ts`**: Added `UserMediaProfile` types from the gateway schema. + +**Decision:** To keep this feature branch focused, only the lockfile and the new AI-specific types (`LoadLanguages.ts`, `LoadVideoSubtitleContent.ts`) were committed. The unrelated `TemplateVideoUpload` renaming and `UserMediaProfile` additions will be handled in a separate dedicated branch to keep this PR clean. + +### `package.json` Dependency Decisions + +| Dependency | Feature Branch | Main | Resolution | +|---|---|---|---| +| `@ai-sdk/google` | ^1.2.18 | ^2.0.26 | Took main's ^2.0.26 | +| `@ai-sdk/google-vertex` | ^2.2.27 | absent | Kept (AI feature) | +| `@ai-sdk/openai` | ^1.3.22 | absent | Kept (AI feature) | +| `ai` (Vercel AI SDK) | ^4.3.15 | **^5.0.86** | Took main's v5 (MAJOR change) | +| `zod` | ^3.23.8 | **^4.1.12** | Took main's v4 (MAJOR change) | +| `react` | 18.3.1 | **^19.0.0** | Took main's v19 (MAJOR change) | +| `langfuse-vercel` | ^3.37.4 | absent | Kept (AI tracing) | +| `@vercel/otel` | ^1.13.0 | absent | Kept (AI instrumentation) | +| `launchdarkly-node-server-sdk` | ^7.0.3 | absent (replaced by `@launchdarkly/node-server-sdk`) | **Dropped** (old package) | +| OpenTelemetry packages | ^0.57.x / ^1.26.x | ^0.200.x / ^2.0.x | Took main's versions, kept feature's `api-logs` + `sdk-logs` additions | + +### Known Risks for Build-Fix Phase + +Three major version bumps will require code changes in the AI prototype: + +1. **`ai` v4 -> v5**: The Vercel AI SDK had breaking API changes. The `useChat` hook, `streamText`, `tool()`, and `ToolSet` types likely changed. This is the highest-risk area. + +2. **`zod` v3 -> v4**: The AI prototype heavily uses Zod schemas (`journeySimpleTypes.ts`, all tool parameter definitions). Zod 4 has API differences that may affect `.superRefine()`, `.describe()`, and `zod-to-json-schema` compatibility. + +3. **React 18 -> 19**: The `AiChat` component and its children use hooks (`useChat`, `useState`, `useCallback`, etc.) which should be mostly compatible, but some patterns may need updating. + +### Issue resolution (build / serve) + +Issues found when running `nx serve journeys-admin` and entering AI-related areas; fixes applied so far: + +| # | Symptom | Cause | Resolution | +|---|---------|--------|------------| +| 1 | `Module not found: Can't resolve 'langfuse'` in `src/libs/ai/langfuse/server.ts` (and client) | Code imports `Langfuse` / `LangfuseWeb` from the `langfuse` package and `LangfuseExporter` from `langfuse-vercel`. Only `langfuse-vercel` was in `package.json`; the core SDK is a separate dependency. | Added `langfuse: "^3.37.4"` to `package.json` and ran `pnpm install`. | +| 2 | `Module not found: Can't resolve '@ai-sdk/react'` in `AiChat.tsx` (and other AI UI components) | Merge took main's `ai` v5 and kept `@ai-sdk/google` etc., but the React bindings package `@ai-sdk/react` was never added. | Added `@ai-sdk/react: "^2.0.0"` to `package.json` (v2 pairs with ai v5; v3 requires ai v6) and ran `pnpm install`. | +| 3 | Runtime error at `value.trim()` in `Form.tsx` (line 33) when checking empty input | In AI SDK v5, `useChat`'s `input` can be `undefined`. `isInputEmpty(value: string)` was called with `input`, so `.trim()` threw when `input` was undefined. | Made `isInputEmpty` accept `string or undefined` and guard with null check and `String(value).trim().length === 0`. | +| 4 | Submit button disabled; type errors in AiChat (append, handleSubmit, input, reload, etc. missing from useChat) | AI SDK v5 changed the `useChat` API: no built-in input/handleSubmit/append/reload; uses transport, `sendMessage`, and local input state. Code was still written for v4. | Migrated to v5: `DefaultChatTransport` with `prepareSendMessagesRequest` (auth + body); local `input`/`setInput` state; submit calls `sendMessage({ text })`; `onFinish` uses `message`/`message.parts`; `addToolResult` adapter and legacy tool-part normalizer for existing UI. Request body converted to legacy `{ role, content }[]` for current API route. **Files changed:** AiChat.tsx, Form.tsx, State/Empty, State/Error, State/Loading, MessageList.tsx, TextPart.tsx, ToolInvocationPart.tsx + BasicTool, GenerateImageTool, RedirectUserToEditorTool, RequestFormTool, SelectImageTool, SelectVideoTool. | +| 5 | POST /api/chat 500: ZodError "expected array, received undefined" for `messages` | Request body had `messages: undefined`. Possible causes: SDK sometimes calls `prepareSendMessagesRequest` with `options.messages` undefined in a code path, or our returned body was not used. | **Client:** In `prepareSendMessagesRequest`, guard with `Array.isArray(options.messages) ? options.messages : []` so we always send an array and never `messages: undefined`. **Server:** Return 400 with a clear message when `body.messages` is null/undefined (include received keys for debugging); use `schema.safeParse(body)` and return 400 with flattened error detail instead of throwing. | +| 6 | POST /api/chat 500: `TypeError: result.toDataStreamResponse is not a function` | In AI SDK v5, `streamText()` result no longer has `toDataStreamResponse()`; the streaming response API was replaced by the UI message stream. | In `app/api/chat/route.ts`, use `result.toUIMessageStreamResponse({ headers })` instead of `result.toDataStreamResponse({ headers, getErrorMessage })`. Dropped `getErrorMessage` (not in v5 options). Client (DefaultChatTransport) expects this UI message stream format. | + +More issues are expected (e.g. Zod v4, lint failures) and will be added here as they are resolved. + +### Current Status + +**Phase 1 (Merge and Stabilize)** is effectively complete: merge done, conflicts resolved, dependencies and build/serve issues fixed (see issue resolution table). The app runs, and the AI chat can send a message and receive a streamed response. Unrelated codegen changes remain deferred. Build/test suite and full manual test still to run; not pushed to remote yet. + +**Observations (early Step 2 – verify behaviour)** — these sit between Phase 1 and Phase 2 and affect prototype reliability: + +- **aiEditButton visibility:** The button appears intermittently when opening journeys in the editor. Expected behaviour is that it shows every time a journey is opened. This will interfere with testing but is not the top priority. +- **aiChat and tool-call reliability:** Responses are intermittent (e.g. possibly only the first query works). Tool calls may be failing intermittently, making recovery difficult. Consider re‑introducing per-tool try/catch and a controlled error response (experimented with previously but likely not committed). +- **No actual journey updates / mirror responses:** The AI often reflects the user’s request back instead of applying journey changes. Likely causes: tool calls failing silently, and/or the system prompt (e.g. Langfuse `system/api/chat/route`) not in a good state. Needs verification of tool execution and prompt content. + +**Build and test run (journeys-admin, 2025-02-24)** — scope limited to journeys-admin and related shared-ui/api as above. + +- **Fixes applied (low-hanging):** + - **ESLint (AiChat and related):** Import order and grouping in AiChat, MessageList, TextPart, ToolInvocationPart, RequestFormTool, SelectImageTool, SelectVideoTool, Empty; satisfied no-floating-promises (void where needed); removed unnecessary type assertion; sort-imports in MessageList resolved via eslint-disable for type vs value order. + - **Spec:** `get.spec.ts` → `get.spec.tsx` (JSX parse) and import group fix. + - **Next route:** Moved `errorHandler` and `messagesSchema` to `src/libs/ai/chatRouteUtils.ts` so the route only exports allowed symbols. + - **AI SDK (chat route):** `experimental_repairToolCall`: `parameterSchema` → `inputSchema`; repair logic **disabled** (tools no longer expose `tool.parameters`; only `inputSchema`; `generateObject` expects Zod). Removed `maxSteps` (no longer in streamText options). Dropped unused `generateObject` import. + - **Types:** MessageList `normalizeToolPart(part)` typed as `ToolUIPart | DynamicToolUIPart` with correct UITools constraint; RedirectUserToEditorTool `args` cast to `RedirectArgs`; RequestFormTool `getNumberValidator` return type `z.ZodType` for coerced number + optional. + +- **Remaining / report-only:** + - **Build still fails:** Next.js build fails on **ESLint warnings** (many `@typescript-eslint/no-unused-vars` across pages/components; config treats warnings as errors). One run also exited with SIGKILL during lint/type-check (likely resource limit). + - **AI SDK breaking changes (for follow-up):** Route export constraint (fixed); repair callback disabled until tool schema can be supplied in a way compatible with `generateObject`; `maxSteps` removed/renamed in streamText; tool types (ToolUIPart / DynamicToolUIPart, UITools `output`). + - **Tests:** `nx test journeys-admin` started; several specs PASS (e.g. transformSteps, YouTubeDetails, EventLabel, CopyToTeamMenuItem). Console: Jest “Unknown option collectCoverage”, React “outdated JSX transform”, some “not wrapped in act(...)”, Apollo refetchQueries / “No more mocked responses”. Full run did not complete within timeout; no final pass/fail tally. + +- **Suggested next steps:** (1) Fix or relax ESLint so warnings do not fail the build. (2) Re-enable and reimplement tool-call repair using current SDK if needed. (3) Re-run `nx test journeys-admin` to completion and fix failing specs and mock/act warnings. + +Next: run builds and test suites **limited to journeys-admin and any related shared-ui and api files**, then address the observations above (feature flag/visibility, tool error handling, system prompt and tool verification) before or alongside Phase 2 (architecture and dependency audit). \ No newline at end of file diff --git a/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts b/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts index ed613a5dd36..ed4b7bc379d 100644 --- a/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts +++ b/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts @@ -24,18 +24,44 @@ export function simplifyJourney( ) // --- VIDEO BLOCK HANDLING --- - const videoBlock = childBlocks.find( + const youtubeBlock = childBlocks.find( (block) => block.typename === 'VideoBlock' && block.source === 'youTube' ) + if (youtubeBlock) { + const card: JourneySimpleCard = { + id: `card-${index + 1}`, + x: stepBlock.x ?? 0, + y: stepBlock.y ?? 0, + video: { + src: `https://youtube.com/watch?v=${youtubeBlock.videoId}`, + startAt: youtubeBlock.startAt ?? undefined, + endAt: youtubeBlock.endAt ?? undefined, + source: 'youTube' + } + } + if (stepBlock.nextBlockId) { + const nextStepBlockIndex = stepBlocks.findIndex( + (s) => s.id === stepBlock.nextBlockId + ) + if (nextStepBlockIndex >= 0) { + card.defaultNextCard = `card-${nextStepBlockIndex + 1}` + } + } + return card + } + const videoBlock = childBlocks.find( + (block) => block.typename === 'VideoBlock' && block.source === 'internal' + ) if (videoBlock) { const card: JourneySimpleCard = { id: `card-${index + 1}`, x: stepBlock.x ?? 0, y: stepBlock.y ?? 0, video: { - url: `https://youtube.com/watch?v=${videoBlock.videoId}`, + src: videoBlock.src ?? '', startAt: videoBlock.startAt ?? undefined, - endAt: videoBlock.endAt ?? undefined + endAt: videoBlock.endAt ?? undefined, + source: 'internal' } } if (stepBlock.nextBlockId) { diff --git a/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts b/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts index 5ea3635f77d..332f04857ab 100644 --- a/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts +++ b/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts @@ -228,11 +228,17 @@ export async function updateSimpleJourney( card.defaultNextCard != null ? stepBlocks.find((s) => s.simpleCardId === card.defaultNextCard) : undefined - const videoId = extractYouTubeVideoId(card.video.url) + const videoId = + card.video.source === 'youTube' + ? extractYouTubeVideoId(card.video.src ?? '') + : card.video.src if (videoId == null) { throw new Error('Invalid YouTube video URL') } - const videoDuration = await getYouTubeVideoDuration(videoId) + const videoDuration = + card.video.source === 'youTube' + ? await getYouTubeVideoDuration(videoId) + : undefined await tx.block.create({ data: { journeyId, @@ -240,7 +246,8 @@ export async function updateSimpleJourney( parentBlockId: cardBlockId, parentOrder: parentOrder++, videoId, - source: 'youTube', + videoVariantLanguageId: '529', + source: card.video.source, autoplay: true, startAt: card.video.startAt ?? 0, endAt: card.video.endAt ?? videoDuration, diff --git a/apps/journeys-admin/__generated__/AiCreateCloudflareUploadByFileMutation.ts b/apps/journeys-admin/__generated__/AiCreateCloudflareUploadByFileMutation.ts new file mode 100644 index 00000000000..b66b3c0f7a8 --- /dev/null +++ b/apps/journeys-admin/__generated__/AiCreateCloudflareUploadByFileMutation.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: AiCreateCloudflareUploadByFileMutation +// ==================================================== + +export interface AiCreateCloudflareUploadByFileMutation_createCloudflareUploadByFile { + __typename: "CloudflareImage"; + id: string; + uploadUrl: string | null; +} + +export interface AiCreateCloudflareUploadByFileMutation { + createCloudflareUploadByFile: AiCreateCloudflareUploadByFileMutation_createCloudflareUploadByFile; +} diff --git a/apps/journeys-admin/__generated__/JourneySimpleGet.ts b/apps/journeys-admin/__generated__/JourneySimpleGet.ts new file mode 100644 index 00000000000..8051f430dc5 --- /dev/null +++ b/apps/journeys-admin/__generated__/JourneySimpleGet.ts @@ -0,0 +1,16 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: JourneySimpleGet +// ==================================================== + +export interface JourneySimpleGet { + journeySimpleGet: any | null; +} + +export interface JourneySimpleGetVariables { + id: string; +} diff --git a/apps/journeys-admin/__generated__/JourneySimpleUpdate.ts b/apps/journeys-admin/__generated__/JourneySimpleUpdate.ts new file mode 100644 index 00000000000..971f4ee8879 --- /dev/null +++ b/apps/journeys-admin/__generated__/JourneySimpleUpdate.ts @@ -0,0 +1,17 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: JourneySimpleUpdate +// ==================================================== + +export interface JourneySimpleUpdate { + journeySimpleUpdate: any | null; +} + +export interface JourneySimpleUpdateVariables { + id: string; + journey: any; +} diff --git a/apps/journeys-admin/__generated__/LoadLanguages.ts b/apps/journeys-admin/__generated__/LoadLanguages.ts new file mode 100644 index 00000000000..fb37443702e --- /dev/null +++ b/apps/journeys-admin/__generated__/LoadLanguages.ts @@ -0,0 +1,22 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: LoadLanguages +// ==================================================== + +export interface LoadLanguages_languages { + __typename: "Language"; + id: string; + slug: string | null; +} + +export interface LoadLanguages { + languages: LoadLanguages_languages[]; +} + +export interface LoadLanguagesVariables { + subtitles: string[]; +} diff --git a/apps/journeys-admin/__generated__/LoadVideoSubtitleContent.ts b/apps/journeys-admin/__generated__/LoadVideoSubtitleContent.ts new file mode 100644 index 00000000000..aecfdb46894 --- /dev/null +++ b/apps/journeys-admin/__generated__/LoadVideoSubtitleContent.ts @@ -0,0 +1,38 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: LoadVideoSubtitleContent +// ==================================================== + +export interface LoadVideoSubtitleContent_video_variant_subtitle { + __typename: "VideoSubtitle"; + id: string; + languageId: string; + edition: string; + primary: boolean; + srtSrc: string | null; +} + +export interface LoadVideoSubtitleContent_video_variant { + __typename: "VideoVariant"; + id: string; + subtitle: LoadVideoSubtitleContent_video_variant_subtitle[]; +} + +export interface LoadVideoSubtitleContent_video { + __typename: "Video"; + id: string; + variant: LoadVideoSubtitleContent_video_variant | null; +} + +export interface LoadVideoSubtitleContent { + video: LoadVideoSubtitleContent_video; +} + +export interface LoadVideoSubtitleContentVariables { + videoId: string; + languageId: string; +} diff --git a/apps/journeys-admin/app/api/chat/route.ts b/apps/journeys-admin/app/api/chat/route.ts new file mode 100644 index 00000000000..ae40a84bd4b --- /dev/null +++ b/apps/journeys-admin/app/api/chat/route.ts @@ -0,0 +1,119 @@ +import { google } from '@ai-sdk/google' +import { NoSuchToolError, streamText } from 'ai' +import { jwtDecode } from 'jwt-decode' +import { NextRequest } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' + +import { messagesSchema } from '../../../src/libs/ai/chatRouteUtils' +import { + langfuse, + langfuseEnvironment, + langfuseExporter +} from '../../../src/libs/ai/langfuse/server' +import { tools } from '../../../src/libs/ai/tools' +import { createApolloClient } from '../../../src/libs/apolloClient' + +// Allow streaming responses up to 30 seconds +export const maxDuration = 30 + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})) + if (body.messages == null) { + return Response.json( + { + error: 'Missing or invalid request body: messages array is required', + detail: 'The chat client must send { messages: [{ role, content }], ... }. Received keys: ' + + Object.keys(body).join(', ') + }, + { status: 400 } + ) + } + const schema = z.object({ + messages: messagesSchema, + journeyId: z.string().optional(), + selectedStepId: z.string().optional(), + selectedBlockId: z.string().optional(), + sessionId: z.string().optional() + }) + const parseResult = schema.safeParse(body) + if (!parseResult.success) { + return Response.json( + { error: 'Invalid request body', detail: parseResult.error.flatten() }, + { status: 400 } + ) + } + const { messages, journeyId, selectedStepId, selectedBlockId, sessionId } = + parseResult.data + + const token = req.headers.get('Authorization') + + if (token == null) + return Response.json({ error: 'Missing token' }, { status: 400 }) + const decoded = z + .object({ + user_id: z.string(), + auth_time: z.number() + }) + .parse(jwtDecode(token.split(' ')[1])) + + const client = createApolloClient(token.split(' ')[1]) + + const langfuseTraceId = uuidv4() + + const systemPrompt = await langfuse.getPrompt( + 'system/api/chat/route', + undefined, + { + label: langfuseEnvironment, + cacheTtlSeconds: ['development', 'preview'].includes(langfuseEnvironment) + ? 0 + : 60 + } + ) + + const result = streamText({ + model: google('gemini-2.5-flash'), + messages: messages.filter((message) => message.role !== 'system'), + system: systemPrompt.compile({ + journeyId: journeyId ?? 'none', + selectedStepId: selectedStepId ?? 'none', + selectedBlockId: selectedBlockId ?? 'none' + }), + tools: tools(client, { langfuseTraceId }), + experimental_telemetry: { + isEnabled: true, + functionId: 'ai-chat-stream', + metadata: { + langfuseTraceId, + langfusePrompt: systemPrompt.toJSON(), + userId: decoded.user_id, + sessionId: sessionId ?? `${decoded.user_id}-${decoded.auth_time}` + } + }, + // Repair disabled: AI SDK no longer exposes tool.parameters (Zod) on tools; + // only inputSchema (JSONSchema7) is available, and generateObject expects Zod. + experimental_repairToolCall: async ({ error }) => { + if (NoSuchToolError.isInstance(error)) return null + return null // TODO: re-enable when SDK supports repair with current tool types + }, + onFinish: async (result) => { + await langfuseExporter.forceFlush() + const trace = langfuse.trace({ + id: langfuseTraceId + }) + await trace.update({ + output: result.text, + tags: ['output-added'] + }) + } + }) + + return result.toUIMessageStreamResponse({ + headers: { + 'Transfer-Encoding': 'chunked', + Connection: 'keep-alive', + 'x-trace-id': langfuseTraceId + } + }) +} diff --git a/apps/journeys-admin/instrumentation.ts b/apps/journeys-admin/instrumentation.ts new file mode 100644 index 00000000000..34bdaabddf4 --- /dev/null +++ b/apps/journeys-admin/instrumentation.ts @@ -0,0 +1,10 @@ +import { registerOTel } from '@vercel/otel' + +import { langfuseExporter } from './src/libs/ai/langfuse/server' + +export function register() { + registerOTel({ + serviceName: 'journeys-admin', + traceExporter: langfuseExporter + }) +} diff --git a/apps/journeys-admin/next-env.d.ts b/apps/journeys-admin/next-env.d.ts index 254b73c165d..36a4fe488ad 100644 --- a/apps/journeys-admin/next-env.d.ts +++ b/apps/journeys-admin/next-env.d.ts @@ -1,6 +1,7 @@ /// /// +/// /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/journeys-admin/next.config.js b/apps/journeys-admin/next.config.js index 15d32681a46..d35714d1c47 100644 --- a/apps/journeys-admin/next.config.js +++ b/apps/journeys-admin/next.config.js @@ -11,6 +11,10 @@ const nextConfig = { i18n, images: { remotePatterns: [ + { + protocol: 'https', + hostname: '**' + }, { protocol: 'http', hostname: 'localhost' }, { protocol: 'https', hostname: 'unsplash.com' }, { protocol: 'https', hostname: 'images.unsplash.com' }, @@ -107,7 +111,15 @@ const nextConfig = { ] }, experimental: { + instrumentationHook: true, reactCompiler: true + }, + webpack: (config) => { + config.module.rules.push({ + test: /\.md$/, + use: 'raw-loader' + }) + return config } } const plugins = [withNx] diff --git a/apps/journeys-admin/src/components/AiChat/AiChat.spec.tsx b/apps/journeys-admin/src/components/AiChat/AiChat.spec.tsx new file mode 100644 index 00000000000..36d333d67d5 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/AiChat.spec.tsx @@ -0,0 +1,776 @@ +import { ApolloClient, useApolloClient } from '@apollo/client' +import { MockedProvider } from '@apollo/client/testing' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { delay, http } from 'msw' +import { useUser } from 'next-firebase-auth' + +import { EditorProvider } from '@core/journeys/ui/EditorProvider' +import { JourneyProvider } from '@core/journeys/ui/JourneyProvider' +import { defaultJourney } from '@core/journeys/ui/TemplateView/data' + +import { mswServer } from '../../../test/mswServer' + +import { AiChat } from './AiChat' + +jest.mock('next-firebase-auth', () => ({ + __esModule: true, + useUser: jest.fn() +})) + +jest.mock('@apollo/client', () => ({ + __esModule: true, + ...jest.requireActual('@apollo/client'), + useApolloClient: jest.fn() +})) + +jest.mock('../../libs/ai/langfuse/client', () => ({ + langfuseWeb: { + score: jest.fn().mockResolvedValue(undefined) + } +})) + +const validateChatRequestPayload = ( + payload: any, + expectedMessage: { content: string; role: string } = { + content: '', + role: 'user' + } +) => { + expect(payload).toMatchObject({ + id: expect.any(String), + journeyId: defaultJourney.id, + selectedStepId: 'step0.id', + selectedBlockId: 'card0.id', + sessionId: expect.any(String) + }) + + expect(payload.messages).toHaveLength(1) + expect(payload.messages[0]).toMatchObject({ + role: expectedMessage.role, + content: expectedMessage.content, + createdAt: expect.any(String), + id: expect.any(String), + parts: [ + { + text: expectedMessage.content, + type: 'text' + } + ] + }) +} + +const createMockStreamResponse = (chunks: string[]) => { + return new ReadableStream({ + start(controller) { + chunks.forEach((chunk) => { + controller.enqueue(new TextEncoder().encode(chunk)) + }) + controller.close() + } + }) +} + +const renderAiChat = () => { + return render( + + + + + + + + ) +} + +describe('AiChat', () => { + const mockUseApolloClient = useApolloClient as jest.MockedFunction< + typeof useApolloClient + > + const mockUseUser = useUser as jest.MockedFunction + + const mockRefetchQueries = jest.fn().mockResolvedValue([]) + + // when running tests which return an mswServer response, 'Jest did not exit one second after the test run has completed.' warning appears + // Tried to follow this ticket https://github.com/mswjs/msw/issues/170, + // using mswServer.listen()/.resetHandlers()/.close(), but it didn't work + beforeAll(() => { + mswServer.listen() + }) + + beforeEach(() => { + jest.clearAllMocks() + mswServer.resetHandlers() + + mockUseUser.mockReturnValue({ + displayName: 'Test User', + getIdToken: jest.fn().mockResolvedValue('mock-jwt-token') + } as any) + + mockUseApolloClient.mockReturnValue({ + refetchQueries: mockRefetchQueries + } as unknown as ApolloClient) + }) + + afterEach(() => { + mswServer.resetHandlers() + }) + + afterAll(() => { + mswServer.close() + }) + + describe('Basic Functionality', () => { + it('should send a request to the chat API', async () => { + let capturedRequestBody: any = null + + mswServer.use( + http.post('/api/chat', async (req) => { + capturedRequestBody = await req.request.json() + + const stream = createMockStreamResponse([ + '0:"Hello"\n', + '0:"! How can I help you with your journey today?"\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":3114,"completionTokens":13}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + renderAiChat() + + await userEvent.type(screen.getByRole('textbox'), 'Hello') + + await userEvent.click(screen.getByTestId('FormSubmitButton')) + + await waitFor(() => + expect( + screen.getByText('Hello! How can I help you with your journey today?') + ).toBeInTheDocument() + ) + + validateChatRequestPayload(capturedRequestBody, { + content: 'Hello', + role: 'user' + }) + }) + + it('should display all chips and handle clicking the "Customize my journey" chip', async () => { + let capturedRequestBody: any = null + + mswServer.use( + http.post('/api/chat', async (req) => { + capturedRequestBody = await req.request.json() + + const stream = createMockStreamResponse([ + 'f:{"messageId":"msg-Ado0WzVxSTT379nBfNdWdHnE"}\n', + '0:"I"\n', + '0:" can help with that! What would you like to customize about the journey? For example"\n', + '0:", you could update the journey\'s title, description, theme, or even"\n', + '0:" the blocks within the journey. Tell me what you\'d like to change, and I\'ll do my best to assist.\\n"\n', + 'e:{"finishReason":"stop","usage":{"promptTokens":3115,"completionTokens":61},"isContinued":false}\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":3115,"completionTokens":61}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + renderAiChat() + + // Verify all 4 chips are present initially + expect(screen.getByText('Customize my journey')).toBeInTheDocument() + expect( + screen.getByText('Translate to another language') + ).toBeInTheDocument() + expect(screen.getByText('Tell me about my journey')).toBeInTheDocument() + expect( + screen.getByText('What can I do to improve my journey?') + ).toBeInTheDocument() + + await userEvent.click(screen.getByText('Customize my journey')) + + // Wait for and verify the complete response message + await waitFor(() => + expect( + screen.getByText( + "I can help with that! What would you like to customize about the journey? For example, you could update the journey's title, description, theme, or even the blocks within the journey. Tell me what you'd like to change, and I'll do my best to assist." + ) + ).toBeInTheDocument() + ) + + validateChatRequestPayload(capturedRequestBody, { + content: 'Help me customize my journey.', + role: 'user' + }) + }) + + it('should send correct authorization headers', async () => { + const mockGetIdToken = jest.fn().mockResolvedValue('correct-jwt-token') + + mockUseUser.mockReturnValue({ + displayName: 'Test User', + getIdToken: mockGetIdToken + } as any) + + let capturedAuthHeader: string | null = null + + mswServer.use( + http.post('/api/chat', async (req) => { + capturedAuthHeader = req.request.headers.get('authorization') + + const stream = createMockStreamResponse([ + '0:"Test response"\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":10,"completionTokens":5}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + renderAiChat() + + await userEvent.type(screen.getByRole('textbox'), 'Test message') + await userEvent.click(screen.getByTestId('FormSubmitButton')) + + await waitFor(() => + expect(screen.getByText('Test response')).toBeInTheDocument() + ) + + expect(capturedAuthHeader).toBe('JWT correct-jwt-token') + expect(mockGetIdToken).toHaveBeenCalled() + }) + }) + + describe('UI State Management', () => { + it('should handle stop button click during streaming with UI changes', async () => { + mswServer.use( + http.post('/api/chat', async () => { + await delay(100) + + // this response should never be seen due to abort (stop button click) + const stream = createMockStreamResponse([ + '0:"This response should never be seen due to abort"\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":10,"completionTokens":5}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + renderAiChat() + + // Initial state: submit button should be present, stop button should not + expect(screen.getByTestId('FormSubmitButton')).toBeInTheDocument() + expect(screen.queryByTestId('FormStopButton')).not.toBeInTheDocument() + + await userEvent.type(screen.getByRole('textbox'), 'Tell me a long story') + await userEvent.click(screen.getByTestId('FormSubmitButton')) + + // Wait for streaming to begin (stop button should appear) + await waitFor(() => { + expect(screen.getByTestId('FormStopButton')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByTestId('FormStopButton')) + + // should return to normal state + await waitFor(() => { + expect(screen.getByTestId('FormSubmitButton')).toBeInTheDocument() + expect(screen.queryByTestId('FormStopButton')).not.toBeInTheDocument() + }) + }) + + it('should display loading state during message processing', async () => { + mswServer.use( + http.post('/api/chat', async () => { + await delay(100) + + const stream = createMockStreamResponse([ + '0:"Response after loading"\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":10,"completionTokens":5}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + renderAiChat() + + // Initial state - no loading + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + + await userEvent.type(screen.getByRole('textbox'), 'Test message') + await userEvent.click(screen.getByTestId('FormSubmitButton')) + + // Loading should appear immediately after submission + expect(screen.getByRole('progressbar')).toBeInTheDocument() + + // Wait for response and verify loading disappears + await waitFor(() => + expect(screen.getByText('Response after loading')).toBeInTheDocument() + ) + + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + ) + }) + }) + + describe('Error Handling', () => { + it('should display error state and handle retry functionality', async () => { + // First request fails with 500 error + mswServer.use( + http.post('/api/chat', async () => { + return new Response(null, { status: 500 }) + }) + ) + + renderAiChat() + + await userEvent.type(screen.getByRole('textbox'), 'Test message') + await userEvent.click(screen.getByTestId('FormSubmitButton')) + + await waitFor(() => { + expect( + screen.getByText('An error occurred. Please try again.') + ).toBeInTheDocument() + }) + + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + + // Now mock a successful retry + mswServer.use( + http.post('/api/chat', async () => { + const stream = createMockStreamResponse([ + '0:"Retry successful!"\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":10,"completionTokens":5}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + await userEvent.click(screen.getByRole('button', { name: 'Retry' })) + + await waitFor(() => { + expect(screen.getByText('Retry successful!')).toBeInTheDocument() + }) + + expect( + screen.queryByText('An error occurred. Please try again.') + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Retry' }) + ).not.toBeInTheDocument() + }) + + it('should display error state when missing auth token', async () => { + mockUseUser.mockReturnValue({ + displayName: 'Test User', + getIdToken: jest.fn().mockResolvedValue(null) + } as any) + + // Mock successful API call (should not be reached due to auth error) + mswServer.use( + http.post('/api/chat', async () => { + const stream = createMockStreamResponse([ + '0:"Hello"\n', + '0:"! How can I help you with your journey today?"\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":3114,"completionTokens":13}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + renderAiChat() + + await userEvent.type(screen.getByRole('textbox'), 'Test message') + await userEvent.click(screen.getByTestId('FormSubmitButton')) + + await waitFor(() => { + expect( + screen.getByText('An error occurred. Please try again.') + ).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + + // Ensure the successful response text does not appear (auth should have prevented API call) + expect( + screen.queryByText('Hello! How can I help you with your journey today?') + ).not.toBeInTheDocument() + }) + }) + + describe('Tool Call Handling', () => { + it('should handle journeySimpleUpdate tool call and trigger refetch', async () => { + let capturedRequestBody: any = null + + mswServer.use( + http.post('/api/chat', async (req) => { + capturedRequestBody = await req.request.json() + + await delay(200) + const stream = createMockStreamResponse([ + // // journeySimpleUpdate tool call + 'f:{"messageId":"msg-2"}\n', + '9:{"toolCallId":"tool-call-2","toolName":"journeySimpleUpdate","args":{"journeyId":"' + + defaultJourney.id + + '","journey":{"title":"my test journey","description":"Journey Description","cards":[{"id":"card-1","text":"First Card"}]}}}\n', + 'a:{"toolCallId":"tool-call-2","result":{"success":true,"data":{"title":"my test journey","description":"Journey Description","cards":[{"id":"card-1","text":"First Card"}]}}}\n', + 'e:{"finishReason":"tool-calls","usage":{"promptTokens":5255,"completionTokens":51},"isContinued":false}\n', + // Final AI response (split into two chunks) + 'f:{"messageId":"msg-3"}\n', + '0:"I\'"\n', + '0:"ve updated the title of your journey to \\"my test journey\\".\\n"\n', + 'e:{"finishReason":"stop","usage":{"promptTokens":5332,"completionTokens":16},"isContinued":false}\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":15786,"completionTokens":102}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + renderAiChat() + + await userEvent.type( + screen.getByRole('textbox'), + 'update the title of my journey to be "my test journey"' + ) + await userEvent.click(screen.getByTestId('FormSubmitButton')) + // Wait for the tool call loading state to appear + await waitFor(() => + expect(screen.getByText('Updating journey...')).toBeInTheDocument() + ) + + // Wait for the tool call completion state + await waitFor(() => + expect(screen.getByText('Journey updated')).toBeInTheDocument() + ) + + // Wait for the final AI response (combined string) + await waitFor(() => + expect( + screen.getByText( + 'I\'ve updated the title of your journey to "my test journey".' + ) + ).toBeInTheDocument() + ) + + expect(mockRefetchQueries).toHaveBeenCalledWith({ + include: ['GetAdminJourney', 'GetStepBlocksWithPosition'] + }) + + validateChatRequestPayload(capturedRequestBody, { + content: 'update the title of my journey to be "my test journey"', + role: 'user' + }) + }) + + it('should handle agentWebSearch tool call', async () => { + let capturedRequestBody: any = null + + mswServer.use( + http.post('/api/chat', async (req) => { + capturedRequestBody = await req.request.json() + + await delay(200) + + const stream = createMockStreamResponse([ + 'f:{"messageId":"msg-test789"}\n', + '9:{"toolCallId":"web-search-123","toolName":"agentWebSearch","args":{"prompt":"who won the 2025 nba finals"}}\n', + 'a:{"toolCallId":"web-search-123","result":"The Oklahoma City Thunder won the 2025 NBA Finals"}\n', + 'e:{"finishReason":"tool-calls","usage":{"promptTokens":5829,"completionTokens":14},"isContinued":false}\n', + 'f:{"messageId":"msg-test890"}\n', + '0:"The Oklahoma City Thunder won the 2025 NBA Finals"\n', + 'e:{"finishReason":"stop","usage":{"promptTokens":6354,"completionTokens":43},"isContinued":false}\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":12183,"completionTokens":57}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + renderAiChat() + + await userEvent.type( + screen.getByRole('textbox'), + 'Who won the 2025 NBA finals?' + ) + await userEvent.click(screen.getByTestId('FormSubmitButton')) + + // Wait for the tool call loading state to appear + await waitFor(() => + expect(screen.getByText('Searching the web...')).toBeInTheDocument() + ) + + // Wait for the final AI response + await waitFor(() => + expect( + screen.getByText('The Oklahoma City Thunder won the 2025 NBA Finals') + ).toBeInTheDocument() + ) + + expect(mockRefetchQueries).not.toHaveBeenCalled() + + validateChatRequestPayload(capturedRequestBody, { + content: 'Who won the 2025 NBA finals?', + role: 'user' + }) + }) + + it('should handle clientRequestForm tool call with user interaction', async () => { + let firstRequestBody: any = null + + mswServer.use( + http.post('/api/chat', async (req) => { + firstRequestBody = await req.request.json() + await delay(200) + const stream = createMockStreamResponse([ + 'f:{"messageId":"msg-form-123"}\n', + '9:{"toolCallId":"ImGv4QdGx87rgx4z","toolName":"clientRequestForm","args":{"formItems":[{"type":"text","name":"journeyTitle","label":"Journey Title","required":true,"helperText":"The title of your journey."},{"type":"textarea","name":"description","label":"Description","required":true,"helperText":"A brief description of your journey."},{"type":"text","name":"church","label":"Church","required":true,"helperText":"The name of your church."}]}}\n', + 'e:{"finishReason":"tool-calls","usage":{"promptTokens":7607,"completionTokens":56},"isContinued":false}\n', + 'd:{"finishReason":"tool-calls","usage":{"promptTokens":7607,"completionTokens":56}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + renderAiChat() + + await userEvent.type( + screen.getByRole('textbox'), + 'create a form asking 3 questions: journey title, description, church' + ) + await userEvent.click(screen.getByTestId('FormSubmitButton')) + + // Wait for the form to appear + await waitFor(() => + expect(screen.getByLabelText('Journey Title')).toBeInTheDocument() + ) + expect(screen.getByLabelText('Description')).toBeInTheDocument() + expect(screen.getByLabelText('Church')).toBeInTheDocument() + + validateChatRequestPayload(firstRequestBody, { + content: + 'create a form asking 3 questions: journey title, description, church', + role: 'user' + }) + + // Fill out the form + await userEvent.type( + screen.getByLabelText('Journey Title'), + 'my test journey' + ) + await userEvent.type( + screen.getByLabelText('Description'), + 'a test journey' + ) + await userEvent.type(screen.getByLabelText('Church'), 'My Church') + + let secondRequestBody: any = null + // second API call - form submission + mswServer.use( + http.post('/api/chat', async (req) => { + const requestBody = await req.request.json() + secondRequestBody = requestBody + const stream = createMockStreamResponse([ + 'f:{"messageId":"msg-form-456"}\n', + '0:"OK, I\'ve created a form with fields for \\"Journey Title\\", \\"Description\\", and \\"Church\\"."\n', + 'e:{"finishReason":"stop","usage":{"promptTokens":7683,"completionTokens":23},"isContinued":false}\n', + 'd:{"finishReason":"stop","usage":{"promptTokens":7683,"completionTokens":23}}\n' + ]) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1' + } + }) + }) + ) + + await userEvent.click(screen.getByRole('button', { name: /submit/i })) + + // verify that the clientRequestForm tool call was part of the request body + expect(secondRequestBody.messages.length).toBeGreaterThanOrEqual(2) + const assistantMessage = secondRequestBody.messages.find( + (msg: any) => msg.role === 'assistant' + ) + expect(assistantMessage).toBeDefined() + const toolInvocationPart = assistantMessage.parts.find( + (part: any) => + part.type === 'tool-invocation' && + part.toolInvocation.state === 'result' + ) + expect(toolInvocationPart).toBeDefined() + expect(toolInvocationPart.toolInvocation).toMatchObject({ + toolCallId: 'ImGv4QdGx87rgx4z', + toolName: 'clientRequestForm', + state: 'result', + result: { + journeyTitle: 'my test journey', + description: 'a test journey', + church: 'My Church' + } + }) + + // verify that the form results are displayed + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(3) + + expect( + within(listItems[0]).getByText('Journey Title') + ).toBeInTheDocument() + expect( + within(listItems[0]).getByText('my test journey') + ).toBeInTheDocument() + + expect(within(listItems[1]).getByText('Description')).toBeInTheDocument() + expect( + within(listItems[1]).getByText('a test journey') + ).toBeInTheDocument() + + expect(within(listItems[2]).getByText('Church')).toBeInTheDocument() + expect(within(listItems[2]).getByText('My Church')).toBeInTheDocument() + + expect( + screen.getByText( + 'OK, I\'ve created a form with fields for "Journey Title", "Description", and "Church".' + ) + ).toBeInTheDocument() + + expect(mockRefetchQueries).not.toHaveBeenCalled() + }) + }) + + describe('Variant Styling', () => { + it('should apply popup variant styling (default)', () => { + // renders with variant="popup" + renderAiChat() + + const container = screen.getByTestId('AiChatContainer') + expect(container).toBeInTheDocument() + + expect(container).toHaveStyle({ + // common styles + display: 'flex', + 'flex-direction': 'column-reverse', + 'padding-top': '40px', + 'padding-bottom': '40px', + 'padding-left': '32px', + 'padding-right': '32px', + 'min-height': '150px', + 'overflow-y': 'auto', + // popup-specific styles + 'max-height': 'calc(100svh - 400px)', + 'flex-grow': '0', + 'justify-content': '' // undefined + }) + }) + + it('should apply page variant styling', () => { + render( + + + + + + + + ) + + const container = screen.getByTestId('AiChatContainer') + expect(container).toBeInTheDocument() + + expect(container).toHaveStyle({ + // common styles + display: 'flex', + 'flex-direction': 'column-reverse', + 'padding-top': '40px', + 'padding-bottom': '40px', + 'padding-left': '32px', + 'padding-right': '32px', + 'min-height': '150px', + 'overflow-y': 'auto', + // page-specific styles + 'max-height': '100%', + 'flex-grow': '1', + 'justify-content': 'flex-end' + }) + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/AiChat.tsx b/apps/journeys-admin/src/components/AiChat/AiChat.tsx new file mode 100644 index 00000000000..027c381272d --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/AiChat.tsx @@ -0,0 +1,206 @@ +import { useChat } from '@ai-sdk/react' +import { useApolloClient } from '@apollo/client' +import Box from '@mui/material/Box' +import { DefaultChatTransport, UIMessage } from 'ai' +import { useUser } from 'next-firebase-auth' +import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' + +import { useEditor } from '@core/journeys/ui/EditorProvider' +import { useJourney } from '@core/journeys/ui/JourneyProvider' + +import { Form } from './Form' +import { MessageList } from './MessageList' +import { StateEmpty, StateError, StateLoading } from './State' + +/** Convert UIMessage[] to legacy { role, content }[] for the current API route. */ +function toLegacyMessages(messages: UIMessage[]): { role: string; content: string }[] { + return messages.map((msg) => { + const content = + msg.parts + ?.filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text) + .join('') ?? '' + return { role: msg.role, content } + }) +} + +interface AiChatProps { + variant?: 'popup' | 'page' +} + +export function AiChat({ variant = 'popup' }: AiChatProps): ReactElement { + const user = useUser() + const client = useApolloClient() + const { journey } = useJourney() + const traceId = useRef(null) + const sessionId = useRef(null) + const { + state: { selectedStepId, selectedBlockId } + } = useEditor() + const [waitForToolResult, setWaitForToolResult] = useState(false) + const [input, setInput] = useState('') + + useEffect(() => { + sessionId.current = uuidv4() + }, []) + + const transport = useMemo(() => { + return new DefaultChatTransport({ + api: '/api/chat', + credentials: 'omit', + prepareSendMessagesRequest: async (options) => { + const token = await user?.getIdToken() + if (!token) throw new Error('Missing auth token') + const sourceMessages = Array.isArray(options.messages) ? options.messages : [] + const legacyMessages = toLegacyMessages(sourceMessages) + return { + body: { + ...options.body, + messages: legacyMessages, + journeyId: journey?.id, + selectedStepId, + selectedBlockId, + sessionId: sessionId.current + }, + headers: { + ...(options.headers as Record), + Authorization: `JWT ${token}` + } + } + } + }) + }, [user, journey?.id, selectedStepId, selectedBlockId]) + + const { + messages, + sendMessage, + status, + addToolResult: addToolResultV5, + setMessages, + error, + regenerate, + stop + } = useChat({ + transport, + onToolCall: ({ toolCall }) => { + if (toolCall.toolName.startsWith('client')) setWaitForToolResult(true) + }, + onFinish: ({ message, messages: currentMessages }) => { + setMessages((prev) => + prev.map((msg) => { + if (msg.id === message.id) { + return { ...msg, traceId: traceId.current } + } + return msg + }) + ) + const shouldRefetch = message.parts?.some( + (part: { type: string; toolInvocation?: { toolName: string } }) => + part.type === 'tool-invocation' && + part.toolInvocation?.toolName.endsWith('Update') + ) + if (shouldRefetch) { + void client.refetchQueries({ + include: ['GetAdminJourney', 'GetStepBlocksWithPosition'] + }) + } + } + }) + + const handleSubmit = useCallback( + (e?: { preventDefault?: () => void }) => { + e?.preventDefault?.() + const text = input.trim() + if (!text) return + setInput('') + void sendMessage({ text }) + }, + [input, sendMessage] + ) + + const handleAddToolResult = useCallback( + ({ + tool, + toolCallId, + result + }: { + tool: string + toolCallId: string + result: unknown + }) => { + setWaitForToolResult(false) + void addToolResultV5({ + tool, + toolCallId, + output: result + }) + }, + [addToolResultV5] + ) + + return ( + <> + + + + message.role !== 'system')} + onSendMessage={(text) => { + void sendMessage({ text }) + }} + /> + { + void regenerate() + }} + /> + + + +
+ + ) +} diff --git a/apps/journeys-admin/src/components/AiChat/Form/Form.spec.tsx b/apps/journeys-admin/src/components/AiChat/Form/Form.spec.tsx new file mode 100644 index 00000000000..9f080679a1c --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/Form/Form.spec.tsx @@ -0,0 +1,251 @@ +import { UseChatHelpers } from '@ai-sdk/react' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { Form } from './Form' + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +jest.mock('@core/shared/ui/icons/ArrowUp', () => { + return function MockArrowUpIcon() { + return
ArrowUp
+ } +}) + +describe('Form', () => { + const mockHandleSubmit = jest.fn() + const mockHandleInputChange = jest.fn() + const mockStop = jest.fn() + + const defaultProps = { + input: '', + onSubmit: mockHandleSubmit as UseChatHelpers['handleSubmit'], + onInputChange: mockHandleInputChange as UseChatHelpers['handleInputChange'], + error: undefined, + status: 'ready' as UseChatHelpers['status'], + stop: mockStop, + waitForToolResult: false + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Basic Rendering & Structure', () => { + it('should render form with all elements and proper structure', () => { + render() + + const form = screen.getByRole('textbox') + expect(form).toBeInTheDocument() + + const textField = screen.getByRole('textbox') + expect(textField).toBeInTheDocument() + expect(textField).toHaveAttribute('placeholder', 'Ask Anything') + expect(textField.tagName.toLowerCase()).toBe('textarea') + expect(textField).toHaveFocus() + + const submitButton = screen.getByRole('button') + expect(submitButton).toBeInTheDocument() + expect(submitButton).toHaveAttribute('type', 'submit') + expect(screen.getByTestId('arrow-up-icon')).toBeInTheDocument() + }) + + it('should display input value', () => { + const inputValue = 'Test message' + render() + + const textField = screen.getByRole('textbox') + expect(textField).toHaveValue(inputValue) + expect(textField).toHaveAttribute('name', 'userMessage') + }) + }) + + describe('Form Submission Flow', () => { + it('should handle form submission via submit button with valid input', async () => { + render() + + const submitButton = screen.getByRole('button') + expect(submitButton).not.toBeDisabled() + await userEvent.click(submitButton) + + expect(mockHandleSubmit).toHaveBeenCalledTimes(1) + }) + + it('should handle form submission via Enter key with valid input', async () => { + render() + + const textField = screen.getByRole('textbox') + fireEvent.keyDown(textField, { key: 'Enter', code: 'Enter' }) + + expect(mockHandleSubmit).toHaveBeenCalledTimes(1) + }) + + it('should allow line breaks with Shift+Enter without submitting', async () => { + // Use a local state to simulate controlled input behavior + let currentValue = '' + const mockInputChange = jest.fn().mockImplementation((e) => { + currentValue = e.target.value + }) + + render( + + ) + + const textField = screen.getByRole('textbox') + + await userEvent.type(textField, 'Line 1') + + fireEvent.keyDown(textField, { + key: 'Enter', + code: 'Enter', + shiftKey: true + }) + + expect(mockHandleSubmit).not.toHaveBeenCalled() + expect(mockInputChange).toHaveBeenCalled() + }) + + it('should handle input changes properly', async () => { + render() + + const textField = screen.getByRole('textbox') + await userEvent.type(textField, 'T') + + expect(mockHandleInputChange).toHaveBeenCalled() + }) + }) + + describe('Button States & Interaction', () => { + it('should show stop button when submitted or streaming and handle stop action', async () => { + const { rerender } = render() + + let stopButton = screen.getByRole('button') + expect(stopButton).not.toHaveAttribute('type', 'submit') + expect(screen.queryByTestId('arrow-up-icon')).not.toBeInTheDocument() + + expect(stopButton).toBeInTheDocument() + + await userEvent.click(stopButton) + expect(mockStop).toHaveBeenCalledTimes(1) + + rerender() + + stopButton = screen.getByRole('button') + expect(stopButton).not.toHaveAttribute('type', 'submit') + expect(screen.queryByTestId('arrow-up-icon')).not.toBeInTheDocument() + }) + + it('should show submit button with ArrowUp icon when not submitted or streaming', () => { + const { rerender } = render() + + let submitButton = screen.getByRole('button') + expect(submitButton).toHaveAttribute('type', 'submit') + expect(screen.getByTestId('arrow-up-icon')).toBeInTheDocument() + + rerender() + + submitButton = screen.getByRole('button') + expect(submitButton).toHaveAttribute('type', 'submit') + expect(screen.getByTestId('arrow-up-icon')).toBeInTheDocument() + }) + }) + + describe('Disabled States', () => { + it('should disable TextField and buttons when error is present', () => { + const error = new Error('Test error') + const { rerender } = render( + + ) + + let textField = screen.getByRole('textbox') + const submitButton = screen.getByRole('button') + + expect(textField).toBeDisabled() + expect(submitButton).toBeDisabled() + + rerender() + + textField = screen.getByRole('textbox') + const stopButton = screen.getByRole('button') + + expect(textField).toBeDisabled() + expect(stopButton).toBeDisabled() + }) + + it('should disable TextField and buttons when waiting for tool result', () => { + const { rerender } = render( + + ) + + let textField = screen.getByRole('textbox') + const submitButton = screen.getByRole('button') + + expect(textField).toBeDisabled() + expect(submitButton).toBeDisabled() + + // Test stop button disabled + rerender( + + ) + + textField = screen.getByRole('textbox') + const stopButton = screen.getByRole('button') + + expect(textField).toBeDisabled() + expect(stopButton).toBeDisabled() + }) + + it('should not respond to keyboard interactions when disabled', async () => { + const error = new Error('Test error') + render() + + const textField = screen.getByRole('textbox') + + expect(textField).toBeDisabled() + + // Should not respond to typing when disabled + await userEvent.type(textField, 'test') + expect(mockHandleInputChange).not.toHaveBeenCalled() + }) + }) + + describe('Empty Input Validation', () => { + it('should handle empty input correctly', async () => { + const { rerender } = render() + + const textField = screen.getByRole('textbox') + const submitButton = screen.getByRole('button') + + expect(submitButton).toBeDisabled() + + textField.focus() + fireEvent.keyDown(textField, { key: 'Enter', code: 'Enter' }) + expect(mockHandleSubmit).not.toHaveBeenCalled() + + rerender() + const enabledSubmitButton = screen.getByRole('button') + expect(enabledSubmitButton).not.toBeDisabled() + }) + + it('should handle whitespace-only input correctly', async () => { + render() + + const textField = screen.getByRole('textbox') + const submitButton = screen.getByRole('button') + + expect(submitButton).toBeDisabled() + + textField.focus() + fireEvent.keyDown(textField, { key: 'Enter', code: 'Enter' }) + expect(mockHandleSubmit).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/Form/Form.tsx b/apps/journeys-admin/src/components/AiChat/Form/Form.tsx new file mode 100644 index 00000000000..e2e98128da6 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/Form/Form.tsx @@ -0,0 +1,138 @@ +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +import ArrowUpIcon from '@core/shared/ui/icons/ArrowUp' + +interface FormProps { + input: string + setInput: (value: string) => void + onSubmit: (e?: { preventDefault?: () => void }) => void + error: Error | undefined + status: 'ready' | 'submitted' | 'streaming' | 'error' + stop: () => void + waitForToolResult?: boolean +} + +export function Form({ + input, + setInput, + onSubmit: handleSubmit, + error, + status, + stop, + waitForToolResult +}: FormProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + + const isInputEmpty = (value: string | undefined): boolean => { + return value == null || String(value).trim().length === 0 + } + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (isInputEmpty(input)) { + return + } + handleSubmit(e) + } + + return ( + + + `1px solid ${theme.palette.divider}`, + borderRadius: 2 + }} + > + setInput(e.target.value)} + placeholder={t('Ask Anything')} + fullWidth + multiline + maxRows={4} + aria-label={t('Message')} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + if (!isInputEmpty(input)) { + handleSubmit(e) + } + } + }} + disabled={error != null || waitForToolResult} + autoFocus + sx={{ + '& .MuiOutlinedInput-root': { + pb: 0, + '& fieldset': { + border: 'none' + } + } + }} + /> + + + + {status === 'submitted' || status === 'streaming' ? ( + + ) : ( + + )} + + + + +
+ ) +} diff --git a/apps/journeys-admin/src/components/AiChat/Form/index.ts b/apps/journeys-admin/src/components/AiChat/Form/index.ts new file mode 100644 index 00000000000..404c366b324 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/Form/index.ts @@ -0,0 +1 @@ +export { Form } from './Form' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/MessageList.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/MessageList.spec.tsx new file mode 100644 index 00000000000..eb46e7523c8 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/MessageList.spec.tsx @@ -0,0 +1,663 @@ +import { Message } from '@ai-sdk/react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { MessageList } from './MessageList' + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +jest.mock('next/router', () => ({ + useRouter: () => ({ + push: jest.fn(), + replace: jest.fn() + }) +})) + +jest.mock('../../../libs/ai/langfuse/client', () => ({ + langfuseWeb: { + score: jest.fn().mockResolvedValue({}) + } +})) + +jest.mock('../../Editor/Slider/Settings/Drawer/ImageLibrary', () => ({ + ImageLibrary: function MockImageLibrary() { + return
Image Library
+ } +})) + +jest.mock( + 'next/image', + () => + function MockImage({ alt, ...props }: any) { + // eslint-disable-next-line @next/next/no-img-element + return {alt} + } +) + +describe('MessageList', () => { + const mockAddToolResult = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Empty State', () => { + it('should render empty component when no messages provided', () => { + const { container } = render( + + ) + + expect(container.firstChild).toBeNull() + }) + }) + + describe('Message Filtering', () => { + it('should filter out system messages', () => { + const messages = [ + { + id: '1', + role: 'system' as const, + content: 'System message', + parts: [{ type: 'text' as const, text: 'System message' }] + }, + { + id: '2', + role: 'user' as const, + content: 'User message', + parts: [{ type: 'text' as const, text: 'User message' }] + } + ] as Message[] + + render( + + ) + + expect(screen.queryByText('System message')).not.toBeInTheDocument() + expect(screen.getByText('User message')).toBeInTheDocument() + }) + }) + + describe('Message Order', () => { + it('should render messages in reverse order (newest at bottom)', () => { + const messages = [ + { + id: '1', + role: 'user' as const, + content: 'First message', + parts: [{ type: 'text' as const, text: 'First message' }] + }, + { + id: '2', + role: 'assistant' as const, + content: 'Second message', + parts: [{ type: 'text' as const, text: 'Second message' }] + }, + { + id: '3', + role: 'user' as const, + content: 'Third message', + parts: [{ type: 'text' as const, text: 'Third message' }] + } + ] as Message[] + + render( + + ) + + const messageElements = screen.getAllByText(/message/) + const messageTexts = messageElements.map((el) => el.textContent) + + // Messages should appear in reverse order + expect(messageTexts).toEqual([ + 'Third message', + 'Second message', + 'First message' + ]) + }) + }) + + describe('Text Part Rendering', () => { + it('should render user text messages with correct styling', () => { + const messages = [ + { + id: '1', + role: 'user' as const, + content: 'Hello AI!', + parts: [{ type: 'text' as const, text: 'Hello AI!' }] + } + ] as Message[] + + render( + + ) + + const messageBox = screen.getByText('Hello AI!').closest('div') + expect(messageBox).toHaveClass('text-part') + }) + + it('should render assistant text messages as markdown', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: '**Bold text** and *italic text*', + parts: [ + { type: 'text' as const, text: '**Bold text** and *italic text*' } + ] + } + ] as Message[] + + render( + + ) + + expect(screen.getByText('Bold text')).toBeInTheDocument() + expect(screen.getByText('italic text')).toBeInTheDocument() + }) + }) + + describe('Tool Invocation Part Rendering', () => { + it('should render basic tool invocation in call state', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: '', + parts: [ + { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'agentWebSearch', + args: { query: 'test search' }, + state: 'call' as const + } + } + ] + } + ] as Message[] + + render( + + ) + + expect(screen.getByText('Searching the web...')).toBeInTheDocument() + }) + + it('should render journey tool invocation with result state', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: '', + parts: [ + { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'journeySimpleUpdate', + args: { journeyId: 'journey-123' }, + state: 'result' as const, + result: { success: true } + } + } + ] + } + ] as Message[] + + render( + + ) + + expect(screen.getByText('Journey updated')).toBeInTheDocument() + }) + + it('should render client form tool and handle submission', async () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: '', + parts: [ + { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'form-test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + name: 'title', + label: 'Journey Title', + type: 'text', + required: true, + placeholder: 'Enter title', + helperText: 'Please enter a title for your journey' + } + ] + }, + state: 'call' as const + } + } + ] + } + ] as Message[] + + render( + + ) + + expect(screen.getByLabelText('Journey Title')).toBeInTheDocument() + + // Fill and submit form + const titleInput = screen.getByLabelText('Journey Title') + await userEvent.type(titleInput, 'My Test Journey') + + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'form-test-id', + result: { title: 'My Test Journey' } + }) + }) + }) + + it('should render client select image tool', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: '', + parts: [ + { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'image-test-id', + toolName: 'clientSelectImage', + args: { message: 'Select a landscape image' }, + state: 'call' as const + } + } + ] + } + ] as Message[] + + render( + + ) + + expect(screen.getByText('Select a landscape image')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Open Image Library' }) + ).toBeInTheDocument() + }) + + it('should render client redirect tool with navigation button', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: '', + parts: [ + { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'redirect-test-id', + toolName: 'clientRedirectUserToEditor', + args: { + message: 'Your journey is ready!', + journeyId: 'journey-123' + }, + state: 'call' as const + } + } + ] + } + ] as Message[] + + render( + + ) + + expect(screen.getByText('Your journey is ready!')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'See My Journey!' }) + ).toBeInTheDocument() + }) + }) + + describe('Mixed Message Parts', () => { + it('should render messages with multiple parts correctly', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: '', + parts: [ + { + type: 'text' as const, + text: 'Let me search for that information.' + }, + { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'search-id', + toolName: 'agentWebSearch', + args: { query: 'test' }, + state: 'call' as const + } + }, + { type: 'text' as const, text: 'Here are the results:' } + ] + } + ] as Message[] + + render( + + ) + + expect( + screen.getByText('Let me search for that information.') + ).toBeInTheDocument() + expect(screen.getByText('Searching the web...')).toBeInTheDocument() + expect(screen.getByText('Here are the results:')).toBeInTheDocument() + }) + }) + + describe('UserFeedback Integration', () => { + it('should show user feedback for completed messages with traceId', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: 'Helpful response', + parts: [{ type: 'text' as const, text: 'Helpful response' }], + traceId: 'trace-123' + } + ] as (Message & { traceId?: string })[] + + render( + + ) + + expect(screen.getByLabelText('Good Response')).toBeInTheDocument() + expect(screen.getByLabelText('Bad Response')).toBeInTheDocument() + }) + + it('should show user feedback for last message when status is ready', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: 'First response', + parts: [{ type: 'text' as const, text: 'First response' }], + traceId: 'trace-1' + }, + { + id: '2', + role: 'assistant' as const, + content: 'Latest response', + parts: [{ type: 'text' as const, text: 'Latest response' }], + traceId: 'trace-2' + } + ] as (Message & { traceId?: string })[] + + render( + + ) + + // Both messages should show feedback when status is ready + const feedbackButtons = screen.getAllByLabelText('Good Response') + expect(feedbackButtons).toHaveLength(2) + }) + + it('should not show user feedback for last message when status is not ready', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: 'First response', + parts: [{ type: 'text' as const, text: 'First response' }], + traceId: 'trace-1' + }, + { + id: '2', + role: 'assistant' as const, + content: 'Streaming response', + parts: [{ type: 'text' as const, text: 'Streaming response' }], + traceId: 'trace-2' + } + ] as (Message & { traceId?: string })[] + + render( + + ) + + // Only first message should show feedback, not the last one while streaming + const feedbackButtons = screen.getAllByLabelText('Good Response') + expect(feedbackButtons).toHaveLength(1) + }) + + it('should not show user feedback without traceId on message', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: 'Response without trace', + parts: [{ type: 'text' as const, text: 'Response without trace' }] + } + ] as Message[] + + render( + + ) + + expect(screen.queryByLabelText('Good Response')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Bad Response')).not.toBeInTheDocument() + }) + + it('should handle user feedback interactions', async () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: 'Test response', + parts: [{ type: 'text' as const, text: 'Test response' }], + traceId: 'trace-feedback-test' + } + ] as (Message & { traceId?: string })[] + + render( + + ) + + const thumbsUpButton = screen.getByLabelText('Good Response') + await userEvent.click(thumbsUpButton) + + expect(thumbsUpButton).toHaveClass('MuiIconButton-colorPrimary') + }) + }) + + describe('Unknown Part Types', () => { + it('should handle unknown part types gracefully', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: '', + parts: [ + { type: 'text' as const, text: 'Normal text' }, + { type: 'unknown-type' as any, data: 'some data' }, + { type: 'text' as const, text: 'More normal text' } + ] + } + ] as Message[] + + render( + + ) + + expect(screen.getByText('Normal text')).toBeInTheDocument() + expect(screen.getByText('More normal text')).toBeInTheDocument() + expect(screen.queryByText('some data')).not.toBeInTheDocument() + }) + }) + + describe('Complex Integration Scenarios', () => { + it('should handle complete conversation flow with multiple message types', async () => { + const messages = [ + { + id: '1', + role: 'user' as const, + content: 'Create a journey about prayer', + parts: [ + { type: 'text' as const, text: 'Create a journey about prayer' } + ] + }, + { + id: '2', + role: 'assistant' as const, + content: "I'll help you create a prayer journey.", + parts: [ + { + type: 'text' as const, + text: "I'll help you create a prayer journey." + }, + { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'form-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + name: 'prayerType', + label: 'Type of Prayer', + type: 'radio', + required: true, + helperText: 'Choose the type of prayer journey', + options: [ + { label: 'Personal', value: 'personal' }, + { label: 'Group', value: 'group' } + ] + } + ] + }, + state: 'call' as const + } + } + ], + traceId: 'conversation-trace' + } + ] as (Message & { traceId?: string })[] + + render( + + ) + + // Check all elements are present + expect( + screen.getByText('Create a journey about prayer') + ).toBeInTheDocument() + expect( + screen.getByText("I'll help you create a prayer journey.") + ).toBeInTheDocument() + expect(screen.getByText('Type of Prayer')).toBeInTheDocument() + expect(screen.getByLabelText('Personal')).toBeInTheDocument() + expect(screen.getByLabelText('Group')).toBeInTheDocument() + expect(screen.getByLabelText('Good Response')).toBeInTheDocument() + + // Interact with form + const groupOption = screen.getByLabelText('Group') + await userEvent.click(groupOption) + + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'form-id', + result: { prayerType: 'group' } + }) + }) + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/MessageList.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/MessageList.tsx new file mode 100644 index 00000000000..daf7690189e --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/MessageList.tsx @@ -0,0 +1,126 @@ +import Box from '@mui/material/Box' +/* eslint-disable sort-imports -- type ToolUIPart and value UIMessage order required for both type and alphabetical rules */ +import { + DynamicToolUIPart, + getToolOrDynamicToolName, + isToolOrDynamicToolUIPart, + type ToolUIPart, + UIMessage +} from 'ai' +/* eslint-enable sort-imports */ +import { ReactElement } from 'react' + +import { TextPart } from './TextPart' +import { ToolInvocationPart } from './ToolInvocationPart' +import { UserFeedback } from './UserFeedback' + +export type AddToolResultArg = { + tool: string + toolCallId: string + result: unknown +} + +/** Normalize v5 tool part to legacy shape for existing UI components (toolInvocation.state: 'call' | 'result', args/result). */ +export function normalizeToolPart( + part: ToolUIPart> | DynamicToolUIPart +): LegacyToolInvocationPart { + const toolName = getToolOrDynamicToolName(part) + const state = + part.state === 'output-available' || part.state === 'output-error' + ? 'result' + : 'call' + const input = 'input' in part ? part.input : undefined + const output = 'output' in part ? part.output : undefined + return { + toolInvocation: { + toolName, + toolCallId: part.toolCallId, + state, + args: input, + ...(part.state === 'output-available' && output !== undefined + ? { result: output } + : {}) + } + } +} + +export type LegacyToolInvocationPart = { + toolInvocation: { + toolName: string + toolCallId: string + state: 'call' | 'result' + args: unknown + result?: unknown + } +} + +interface MessageListProps { + status: 'ready' | 'submitted' | 'streaming' | 'error' + messages: (UIMessage & { traceId?: string | null })[] + addToolResult: (arg: AddToolResultArg) => void +} + +export function MessageList({ + status, + messages, + addToolResult +}: MessageListProps): ReactElement { + return ( + <> + {messages + .map((message) => { + const isLastMessage = messages[messages.length - 1].id === message.id + switch (message.role) { + case 'system': + return null + default: + return ( + div + div': { + mt: 2 + }, + '&:last-child .text-part': { + mt: 0 + }, + '&:nth-last-child .text-part': { + mb: 0 + } + }} + > + {message.parts?.map((part, i) => { + if (part.type === 'text') { + return ( + + ) + } + if (isToolOrDynamicToolUIPart(part)) { + return ( + + ) + } + return null + })} + {((isLastMessage && status === 'ready') || !isLastMessage) && + message.traceId && ( + + )} + + ) + } + }) + .reverse()} + + ) +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/TextPart/TextPart.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/TextPart/TextPart.spec.tsx new file mode 100644 index 00000000000..598897e7bc0 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/TextPart/TextPart.spec.tsx @@ -0,0 +1,105 @@ +import { render, screen } from '@testing-library/react' + +import { TextPart } from './TextPart' + +describe('TextPart', () => { + const mockTextPart = { + type: 'text' as const, + text: 'This is test message content' + } + + describe('User Messages', () => { + const userMessage = { + id: '1', + role: 'user' as const, + content: 'user message' + } + + it('should render user message with styled box and text-part class', () => { + render() + + expect( + screen.getByText('This is test message content') + ).toBeInTheDocument() + + const container = screen + .getByText('This is test message content') + .closest('.text-part') + expect(container).toBeInTheDocument() + + const typography = screen.getByText('This is test message content') + expect(typography.tagName.toLowerCase()).toBe('span') + }) + + it('should not render markdown for user messages', () => { + const userMessageWithMarkdown = { + ...userMessage + } + const markdownTextPart = { + type: 'text' as const, + text: '**Bold text** and *italic text*' + } + + render( + + ) + + expect( + screen.getByText('**Bold text** and *italic text*') + ).toBeInTheDocument() + expect(screen.queryByRole('strong')).not.toBeInTheDocument() + expect(screen.queryByRole('emphasis')).not.toBeInTheDocument() + }) + }) + + describe('Assistant Messages', () => { + const aiMessage = { + id: '1', + role: 'assistant' as const, + content: 'AI response' + } + + it('should render AI message with markdown and no user styling', () => { + const markdownTextPart = { + type: 'text' as const, + text: 'This has **bold text** in it' + } + + render() + + const boldElement = screen.getByText('bold text') + expect(boldElement).toBeInTheDocument() + expect(boldElement.tagName.toLowerCase()).toBe('strong') + + expect( + screen.queryByText('bold text')?.closest('.text-part') + ).not.toBeInTheDocument() + }) + + it('should render complex markdown with multiple elements', () => { + const markdownTextPart = { + type: 'text' as const, + text: "## Journey Creation\n\nHere are the steps:\n\n1. **Create** your journey\n2. *Customize* the content\n3. [Publish](https://example.com) it\n\nThat's it!" + } + + render() + + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( + 'Journey Creation' + ) + + const orderedList = screen.getByRole('list') + expect(orderedList.tagName.toLowerCase()).toBe('ol') + + expect(screen.getByText('Create')).toBeInTheDocument() + expect(screen.getByText('Create').tagName.toLowerCase()).toBe('strong') + expect(screen.getByText('Customize')).toBeInTheDocument() + expect(screen.getByText('Customize').tagName.toLowerCase()).toBe('em') + + const link = screen.getByRole('link', { name: 'Publish' }) + expect(link).toHaveAttribute('href', 'https://example.com') + + expect(screen.getByText("That's it!")).toBeInTheDocument() + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/TextPart/TextPart.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/TextPart/TextPart.tsx new file mode 100644 index 00000000000..44742006091 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/TextPart/TextPart.tsx @@ -0,0 +1,48 @@ +import Box from '@mui/material/Box' +import Collapse from '@mui/material/Collapse' +import Typography from '@mui/material/Typography' +import { UIMessage } from 'ai' +import { ReactElement } from 'react' +import Markdown from 'react-markdown' + +interface TextPartProps { + message: UIMessage + part: { type: 'text'; text: string } +} + +export function TextPart({ message, part }: TextPartProps): ReactElement { + return message.role === 'user' ? ( + + + {part.text} + + + ) : ( + *:first-child': { mt: 0 }, + '& > *:last-child': { mb: 0 } + }} + > + {part.text} + + ) +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/TextPart/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/TextPart/index.ts new file mode 100644 index 00000000000..b749eccbcba --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/TextPart/index.ts @@ -0,0 +1 @@ +export { TextPart } from './TextPart' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/BasicTool/BasicTool.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/BasicTool/BasicTool.spec.tsx new file mode 100644 index 00000000000..d862a29046b --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/BasicTool/BasicTool.spec.tsx @@ -0,0 +1,108 @@ +import { ToolInvocationUIPart } from '@ai-sdk/ui-utils' +import { render, screen } from '@testing-library/react' + +import { BasicTool } from './BasicTool' + +describe('BasicTool', () => { + describe('Call State', () => { + const mockToolInvocationCallPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'testTool', + args: {}, + state: 'call' as const + } + } as ToolInvocationUIPart + + it('should render shimmer typography when state is call and callText provided', () => { + render( + + ) + + expect(screen.getByText('Processing your request...')).toBeInTheDocument() + + const shimmerText = screen.getByText('Processing your request...') + expect(shimmerText.tagName.toLowerCase()).toBe('span') + + expect(screen.queryByText('Result text')).not.toBeInTheDocument() + }) + + it('should return null when state is call but callText is not provided', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + }) + + describe('Result State', () => { + const mockToolInvocationResultPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'testTool', + args: {}, + state: 'result' as const + } + } as ToolInvocationUIPart + + it('should render chip when state is result and resultText provided', () => { + render( + + ) + + expect( + screen.getByText('Task completed successfully') + ).toBeInTheDocument() + + const chip = screen + .getByText('Task completed successfully') + .closest('[class*="MuiChip"]') + expect(chip).toBeInTheDocument() + + expect(screen.queryByText('Call text')).not.toBeInTheDocument() + }) + + it('should return null when state is result but resultText is not provided', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + }) + + describe('Default State', () => { + it('should return null for unknown state', () => { + const mockToolInvocationUnknownPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'testTool', + args: {}, + state: 'unknown' as const + } + } as unknown as ToolInvocationUIPart + + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/BasicTool/BasicTool.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/BasicTool/BasicTool.tsx new file mode 100644 index 00000000000..ddb09f99185 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/BasicTool/BasicTool.tsx @@ -0,0 +1,68 @@ +import Box from '@mui/material/Box' +import Chip from '@mui/material/Chip' +import { lighten } from '@mui/material/styles' +import Typography from '@mui/material/Typography' +import { ReactElement, ReactNode } from 'react' + +import type { LegacyToolInvocationPart } from '../../MessageList' + +interface BasicToolProps { + part: LegacyToolInvocationPart + callText?: string + resultText?: string +} + +export function BasicTool({ + part, + callText, + resultText +}: BasicToolProps): ReactElement | null { + switch (part.toolInvocation.state) { + case 'call': + if (callText == null) return null + return {callText} + case 'result': { + if (resultText == null) return null + return ( + + + + ) + } + default: { + return null + } + } +} + +function ShimmerTypography({ + children +}: { + children: ReactNode +}): ReactElement { + return ( + + + `linear-gradient(135deg, ${theme.palette.text.secondary}, ${lighten(theme.palette.text.secondary, 0.75)}, ${theme.palette.text.secondary})`, + backgroundClip: 'text', + color: 'transparent', + backgroundSize: '200% 100%', + animation: 'shimmer 3s linear infinite', + '@keyframes shimmer': { + '0%': { + backgroundPosition: '200% 0' + }, + '100%': { + backgroundPosition: '-200% 0' + } + } + }} + > + {children} + + + ) +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/BasicTool/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/BasicTool/index.ts new file mode 100644 index 00000000000..2a69e1a696e --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/BasicTool/index.ts @@ -0,0 +1 @@ +export { BasicTool } from './BasicTool' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/ToolInvocationPart.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/ToolInvocationPart.spec.tsx new file mode 100644 index 00000000000..5255d877364 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/ToolInvocationPart.spec.tsx @@ -0,0 +1,392 @@ +import { ToolInvocationUIPart } from '@ai-sdk/ui-utils' +import { render, screen } from '@testing-library/react' + +import { ToolInvocationPart } from './ToolInvocationPart' + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +jest.mock('next/image', () => { + return function MockedImage({ src, alt, width, height, onClick }: any) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ) + } +}) + +jest.mock('next/router', () => ({ + useRouter: () => ({ + push: jest.fn() + }) +})) + +jest.mock('../../../Editor/Slider/Settings/Drawer/ImageLibrary', () => ({ + ImageLibrary: function MockedImageLibrary({ open }: any) { + return ( +
+ ) + } +})) + +jest.mock('../../../Editor/Slider/Settings/Drawer/VideoLibrary', () => ({ + VideoLibrary: function MockedVideoLibrary({ open }: any) { + return ( +
+ ) + } +})) + +describe('ToolInvocationPart', () => { + const mockAddToolResult = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('BasicTool Cases', () => { + const agentWebSearchPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'agentWebSearch', + args: {}, + state: 'call' as const + } + } as ToolInvocationUIPart + + const journeySimpleGetPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'journeySimpleGet', + args: {}, + state: 'call' as const + } + } as ToolInvocationUIPart + + const journeySimpleUpdatePart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'journeySimpleUpdate', + args: {}, + state: 'call' as const + } + } as ToolInvocationUIPart + + const agentInternalVideoSearchPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'agentInternalVideoSearch', + args: {}, + state: 'call' as const + } + } as ToolInvocationUIPart + + it('should render BasicTool for agentWebSearch with shimmer text', () => { + render( + + ) + + expect(screen.getByText('Searching the web...')).toBeInTheDocument() + }) + + it('should render BasicTool for journeySimpleGet with shimmer text', () => { + render( + + ) + + expect(screen.getByText('Getting journey...')).toBeInTheDocument() + }) + + it('should render BasicTool for journeySimpleUpdate with shimmer text', () => { + render( + + ) + + expect(screen.getByText('Updating journey...')).toBeInTheDocument() + }) + + it('should render BasicTool for agentInternalVideoSearch with shimmer text', () => { + render( + + ) + + expect( + screen.getByText('Searching Internal Videos...') + ).toBeInTheDocument() + }) + + it('should render BasicTool result state for agentInternalVideoSearch', () => { + const agentInternalVideoSearchResultPart = { + ...agentInternalVideoSearchPart, + toolInvocation: { + ...agentInternalVideoSearchPart.toolInvocation, + state: 'result' as const + } + } as ToolInvocationUIPart + + render( + + ) + + expect(screen.getByText('Videos Search Completed!')).toBeInTheDocument() + expect( + screen.queryByText('Searching Internal Videos...') + ).not.toBeInTheDocument() + }) + + it('should render BasicTool result state for journeySimpleGet', () => { + const journeySimpleGetResultPart = { + ...journeySimpleGetPart, + toolInvocation: { + ...journeySimpleGetPart.toolInvocation, + state: 'result' as const + } + } as ToolInvocationUIPart + + render( + + ) + + expect(screen.getByText('Journey retrieved')).toBeInTheDocument() + expect(screen.queryByText('Getting journey...')).not.toBeInTheDocument() + }) + }) + + describe('Client Tool Cases', () => { + const clientSelectImagePart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientSelectImage', + args: { + message: 'Select an image' + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + const clientRedirectUserToEditorPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRedirectUserToEditor', + args: { + message: 'Click to view your journey', + journeyId: 'journey-123' + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + const clientSelectVideoPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientSelectVideo', + args: { + message: 'Select a video' + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + const clientRequestFormPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + helperText: 'Enter a title for your content' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + it('should render ClientSelectImageTool with message and buttons', () => { + render( + + ) + + expect(screen.getByText('Select an image')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Open Image Library' }) + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('should render ClientRedirectUserToEditorTool with message and button', () => { + render( + + ) + + expect(screen.getByText('Click to view your journey')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'See My Journey!' }) + ).toBeInTheDocument() + }) + + it('should render ClientSelectVideoTool with message and buttons', () => { + render( + + ) + + expect(screen.getByText('Select a video')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Open Video Library' }) + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('should render RequestFormTool with form fields', () => { + render( + + ) + + expect(screen.getByLabelText('Title')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Submit form' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Cancel form' }) + ).toBeInTheDocument() + }) + }) + + describe('Agent Tool Cases', () => { + const agentGenerateImagePart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'agentGenerateImage', + args: {}, + state: 'call' as const + } + } as ToolInvocationUIPart + + const agentGenerateImageResultPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'agentGenerateImage', + args: {}, + state: 'result' as const, + result: [ + { + url: 'https://example.com/generated.png', + width: 256, + height: 256, + blurhash: 'blurhash' + } + ] + } + } as ToolInvocationUIPart + + it('should render AgentGenerateImageTool in call state', () => { + render( + + ) + + expect(screen.getByText('Generating image...')).toBeInTheDocument() + }) + + it('should render AgentGenerateImageTool in result state with images', () => { + render( + + ) + + const image = screen.getByTestId('next-image') + expect(image).toBeInTheDocument() + expect(image).toHaveAttribute('src', 'https://example.com/generated.png') + expect(image).toHaveAttribute('alt', 'Generated image') + expect(image).toHaveAttribute('width', '256') + expect(image).toHaveAttribute('height', '256') + }) + }) + + describe('Default Case', () => { + const unknownToolPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'unknownTool', + args: {}, + state: 'call' as const + } + } as unknown as ToolInvocationUIPart + + it('should return null for unknown tool name', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/ToolInvocationPart.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/ToolInvocationPart.tsx new file mode 100644 index 00000000000..eab834fb7d5 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/ToolInvocationPart.tsx @@ -0,0 +1,120 @@ +import { useTranslation } from 'next-i18next' +import { ReactElement, useCallback } from 'react' + +import type { AddToolResultArg, LegacyToolInvocationPart } from '../MessageList' + +import { AgentGenerateImageTool } from './agent/GenerateImageTool' +import { BasicTool } from './BasicTool' +import { ClientRedirectUserToEditorTool } from './client/RedirectUserToEditorTool' +import { RequestFormTool } from './client/RequestFormTool' +import { ClientSelectImageTool } from './client/SelectImageTool' +import { ClientSelectVideoTool } from './client/SelectVideoTool' + +interface ToolInvocationPartProps { + part: LegacyToolInvocationPart + addToolResult: (arg: AddToolResultArg) => void +} + +/** Shape client tools use when calling addToolResult (tool name is added by this component). */ +export type AddToolResultChildArg = { + toolCallId: string + result: unknown +} + +export function ToolInvocationPart({ + part, + addToolResult +}: ToolInvocationPartProps): ReactElement | null { + const { t } = useTranslation('apps-journeys-admin') + + const addToolResultForChild = useCallback( + ({ toolCallId, result }: AddToolResultChildArg) => { + addToolResult({ + tool: part.toolInvocation.toolName, + toolCallId, + result + }) + }, + [addToolResult, part.toolInvocation.toolName] + ) + + switch (part.toolInvocation.toolName) { + case 'agentWebSearch': + return + case 'journeySimpleGet': + return ( + + ) + case 'journeySimpleUpdate': + return ( + + ) + case 'agentInternalVideoSearch': + return ( + + ) + case 'loadVideoSubtitleContent': + return ( + + ) + case 'loadLanguages': + return ( + + ) + case 'youtubeAnalyzerTool': + return ( + + ) + case 'clientSelectImage': + return ( + + ) + case 'clientRedirectUserToEditor': + return + case 'clientSelectVideo': + return ( + + ) + case 'clientRequestForm': + return ( + + ) + case 'agentGenerateImage': + return + default: + return null + } +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/agent/GenerateImageTool/GenerateImageTool.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/agent/GenerateImageTool/GenerateImageTool.spec.tsx new file mode 100644 index 00000000000..aa9bec6ef14 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/agent/GenerateImageTool/GenerateImageTool.spec.tsx @@ -0,0 +1,145 @@ +import { render, screen } from '@testing-library/react' + +import { AgentGenerateImageTool } from './GenerateImageTool' + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +jest.mock('next/image', () => { + return function MockedImage({ src, alt, width, height }: any) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ) + } +}) + +describe('AgentGenerateImageTool', () => { + const callPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'agentGenerateImage', + args: { prompt: 'test prompt' }, + state: 'call' as const + } + } + + const resultPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'agentGenerateImage', + args: { prompt: 'test prompt' }, + state: 'result' as const, + result: [ + { + url: 'https://example.com/generated-image.png', + width: 256, + height: 256 + } + ] + } + } + + const unknownPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'agentGenerateImage', + args: { prompt: 'test prompt' }, + state: 'unknown' as any + } + } + + describe('Call State', () => { + it('should render generating message when state is call', () => { + render() + + expect(screen.getByText('Generating image...')).toBeInTheDocument() + + expect(screen.queryByTestId('generated-image')).not.toBeInTheDocument() + }) + }) + + describe('Result State', () => { + it('should render single generated image when state is result', () => { + render() + + const image = screen.getByTestId('generated-image') + expect(image).toBeInTheDocument() + expect(image).toHaveAttribute( + 'src', + 'https://example.com/generated-image.png' + ) + expect(image).toHaveAttribute('alt', 'Generated image') + expect(image).toHaveAttribute('width', '256') + expect(image).toHaveAttribute('height', '256') + + expect(screen.queryByText('Generating image...')).not.toBeInTheDocument() + }) + + it('should render multiple generated images when multiple results', () => { + const multipleResultsPart = { + ...resultPart, + toolInvocation: { + ...resultPart.toolInvocation, + result: [ + { url: 'https://example.com/image1.png', width: 256, height: 256 }, + { url: 'https://example.com/image2.png', width: 256, height: 256 }, + { url: 'https://example.com/image3.png', width: 256, height: 256 } + ] + } + } + + render() + + const images = screen.getAllByTestId('generated-image') + expect(images).toHaveLength(3) + + expect(images[0]).toHaveAttribute('src', 'https://example.com/image1.png') + expect(images[1]).toHaveAttribute('src', 'https://example.com/image2.png') + expect(images[2]).toHaveAttribute('src', 'https://example.com/image3.png') + + images.forEach((image) => { + expect(image).toHaveAttribute('alt', 'Generated image') + expect(image).toHaveAttribute('width', '256') + expect(image).toHaveAttribute('height', '256') + }) + }) + + it('should handle empty result array', () => { + const emptyResultPart = { + ...resultPart, + toolInvocation: { + ...resultPart.toolInvocation, + result: [] + } + } + + render() + + expect(screen.queryByTestId('generated-image')).not.toBeInTheDocument() + expect(screen.queryByText('Generating image...')).not.toBeInTheDocument() + }) + }) + + describe('Default State', () => { + it('should return null for unknown state', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/agent/GenerateImageTool/GenerateImageTool.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/agent/GenerateImageTool/GenerateImageTool.tsx new file mode 100644 index 00000000000..58a5626888f --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/agent/GenerateImageTool/GenerateImageTool.tsx @@ -0,0 +1,50 @@ +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import Image from 'next/image' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +import type { LegacyToolInvocationPart } from '../../../MessageList' + +interface AgentGenerateImageToolProps { + part: LegacyToolInvocationPart +} + +export function AgentGenerateImageTool({ + part +}: AgentGenerateImageToolProps): ReactElement | null { + const { t } = useTranslation('apps-journeys-admin') + + switch (part.toolInvocation.state) { + case 'call': + return ( + + {t('Generating image...')} + + ) + case 'result': + return ( + + {(Array.isArray(part.toolInvocation.result) + ? part.toolInvocation.result + : [] + ).map((image: { url: string; width?: number; height?: number; blurhash?: string }) => ( + Generated image + ))} + + ) + default: { + return null + } + } +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/agent/GenerateImageTool/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/agent/GenerateImageTool/index.ts new file mode 100644 index 00000000000..77ac6e195d9 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/agent/GenerateImageTool/index.ts @@ -0,0 +1 @@ +export { AgentGenerateImageTool } from './GenerateImageTool' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RedirectUserToEditorTool/RedirectUserToEditorTool.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RedirectUserToEditorTool/RedirectUserToEditorTool.spec.tsx new file mode 100644 index 00000000000..bf304b1dca3 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RedirectUserToEditorTool/RedirectUserToEditorTool.spec.tsx @@ -0,0 +1,82 @@ +import { ToolInvocationUIPart } from '@ai-sdk/ui-utils' +import { fireEvent, render, screen } from '@testing-library/react' + +import { ClientRedirectUserToEditorTool } from './RedirectUserToEditorTool' + +// Mock next-i18next following the established pattern +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +// Mock Next.js router +const mockPush = jest.fn() +jest.mock('next/router', () => ({ + useRouter: () => ({ + push: mockPush + }) +})) + +describe('ClientRedirectUserToEditorTool', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Call State', () => { + const callPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRedirectUserToEditor', + args: { + message: 'Click to view your journey in the editor', + journeyId: 'journey-123' + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + it('should render message and button when state is call', () => { + render() + + expect( + screen.getByText('Click to view your journey in the editor') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'See My Journey!' }) + ).toBeInTheDocument() + }) + + it('should navigate to journey when button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'See My Journey!' })) + + expect(mockPush).toHaveBeenCalledWith('/journeys/journey-123') + }) + }) + + describe('Default State', () => { + const unknownPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRedirectUserToEditor', + args: { + message: 'Click to view your journey in the editor', + journeyId: 'journey-123' + }, + state: 'unknown' as any + } + } as unknown as ToolInvocationUIPart + + it('should return null for unknown state', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RedirectUserToEditorTool/RedirectUserToEditorTool.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RedirectUserToEditorTool/RedirectUserToEditorTool.tsx new file mode 100644 index 00000000000..751e8b501a6 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RedirectUserToEditorTool/RedirectUserToEditorTool.tsx @@ -0,0 +1,49 @@ +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +import type { LegacyToolInvocationPart } from '../../../MessageList' + +interface RedirectArgs { + message?: string + journeyId?: string +} + +interface ClientRedirectUserToEditorToolProps { + part: LegacyToolInvocationPart +} + +export function ClientRedirectUserToEditorTool({ + part +}: ClientRedirectUserToEditorToolProps): ReactElement | null { + const { t } = useTranslation('apps-journeys-admin') + const router = useRouter() + const args = part.toolInvocation.args as RedirectArgs + + switch (part.toolInvocation.state) { + case 'call': + return ( + + + {args.message} + + + + + + ) + default: { + return null + } + } +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RedirectUserToEditorTool/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RedirectUserToEditorTool/index.ts new file mode 100644 index 00000000000..0adfd54bcfa --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RedirectUserToEditorTool/index.ts @@ -0,0 +1 @@ +export { ClientRedirectUserToEditorTool } from './RedirectUserToEditorTool' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RequestFormTool/RequestFormTool.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RequestFormTool/RequestFormTool.spec.tsx new file mode 100644 index 00000000000..0a875e0b9fb --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RequestFormTool/RequestFormTool.spec.tsx @@ -0,0 +1,1027 @@ +import { ToolInvocationUIPart } from '@ai-sdk/ui-utils' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { RequestFormTool } from './RequestFormTool' + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +describe('RequestFormTool', () => { + const mockAddToolResult = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Field Rendering', () => { + it('should render text field with all properties', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + placeholder: 'Enter title here', + helperText: 'This is a helpful hint', + suggestion: 'My Journey' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const textField = screen.getByLabelText('Title') + expect(textField).toBeInTheDocument() + expect(textField).toHaveAttribute('placeholder', 'Enter title here') + expect(textField).toHaveAttribute('aria-label', 'Title') + expect(textField).toHaveAttribute('tabindex', '0') + expect(screen.getByText('This is a helpful hint')).toBeInTheDocument() + + const suggestionChip = screen.getByText('My Journey') + expect(suggestionChip).toBeInTheDocument() + expect(suggestionChip).toBeInstanceOf(HTMLElement) + }) + + it('should render number field with correct type', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'number', + name: 'age', + label: 'Age', + required: false, + helperText: 'Enter your age' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const numberField = screen.getByLabelText('Age') + expect(numberField).toBeInTheDocument() + expect(numberField).toHaveAttribute('type', 'number') + expect(screen.getByText('Enter your age')).toBeInTheDocument() + }) + + it('should render textarea field with multiline', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'textarea', + name: 'description', + label: 'Description', + required: true, + helperText: 'Enter description' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const textareaField = screen.getByLabelText('Description') + expect(textareaField).toBeInTheDocument() + expect(textareaField.tagName.toLowerCase()).toBe('textarea') + }) + + it('should render email field with email type', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'email', + name: 'email', + label: 'Email', + required: true, + helperText: 'Enter your email' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const emailField = screen.getByLabelText('Email') + expect(emailField).toBeInTheDocument() + expect(emailField).toHaveAttribute('type', 'email') + }) + + it('should render telephone and URL fields with correct types', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'tel', + name: 'phone', + label: 'Phone', + required: false, + helperText: 'Enter phone number' + }, + { + type: 'url', + name: 'website', + label: 'Website', + required: false, + helperText: 'Enter website URL' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const phoneField = screen.getByLabelText('Phone') + const urlField = screen.getByLabelText('Website') + + expect(phoneField).toHaveAttribute('type', 'tel') + expect(urlField).toHaveAttribute('type', 'url') + }) + + it('should render select field with options', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'select', + name: 'category', + label: 'Category', + required: true, + helperText: 'Choose a category', + options: [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' } + ] + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + expect(screen.getByText('Category')).toBeInTheDocument() + expect(screen.getByText('Choose a category')).toBeInTheDocument() + + const selectElement = screen.getByRole('combobox', { + name: /category/i + }) + expect(selectElement).toBeInTheDocument() + expect(selectElement).toHaveAttribute('aria-label', 'Category') + }) + + it('should render checkbox field', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'checkbox', + name: 'agree', + label: 'I agree to terms', + required: true, + helperText: 'Check if you agree' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const checkboxField = screen.getByRole('checkbox', { + name: 'I agree to terms' + }) + expect(checkboxField).toBeInTheDocument() + expect(checkboxField).toHaveAttribute('aria-label', 'I agree to terms') + expect(checkboxField).toHaveAttribute('tabindex', '0') + expect(screen.getByText('Check if you agree')).toBeInTheDocument() + }) + + it('should render radio field with options', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'radio', + name: 'choice', + label: 'Choose one', + required: true, + helperText: 'Select an option', + options: [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' } + ] + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + expect(screen.getByText('Choose one')).toBeInTheDocument() + expect( + screen.getByRole('radio', { name: 'Option A' }) + ).toBeInTheDocument() + expect( + screen.getByRole('radio', { name: 'Option B' }) + ).toBeInTheDocument() + expect(screen.getByText('Select an option')).toBeInTheDocument() + }) + }) + + describe('Form Interactions', () => { + it('should fill out text field and submit form', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + helperText: 'Enter title' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const textField = screen.getByLabelText('Title') + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + await userEvent.type(textField, 'My Journey Title') + await userEvent.click(submitButton) + + await waitFor(() => { + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: { title: 'My Journey Title' } + }) + }) + }) + + it('should use suggestion when chip is clicked', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + helperText: 'Enter title', + suggestion: 'Suggested Title' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const textField = screen.getByLabelText('Title') + const suggestionChip = screen.getByText('Suggested Title') + + expect(textField).toHaveValue('') + + await userEvent.click(suggestionChip) + + expect(textField).toHaveValue('Suggested Title') + }) + + it('should handle checkbox interaction', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'checkbox', + name: 'agree', + label: 'I agree', + required: false, + helperText: 'Check to agree' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const checkboxField = screen.getByRole('checkbox', { name: 'I agree' }) + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + await userEvent.click(checkboxField) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: { agree: true } + }) + }) + }) + + it('should handle select field interaction', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'select', + name: 'category', + label: 'Category', + required: true, + helperText: 'Choose category', + options: [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' } + ] + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const selectField = screen.getByRole('combobox', { + name: /category/i + }) + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + await userEvent.click(selectField) + await userEvent.click(screen.getByRole('option', { name: 'Option 1' })) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: { category: 'opt1' } + }) + }) + }) + + it('should handle radio button interaction', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'radio', + name: 'choice', + label: 'Choose one', + required: true, + helperText: 'Select option', + options: [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' } + ] + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const radioOption = screen.getByRole('radio', { name: 'Option A' }) + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + await userEvent.click(radioOption) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: { choice: 'a' } + }) + }) + }) + + it('should cancel form and call addToolResult with cancelled result', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + helperText: 'Enter title' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const cancelButton = screen.getByRole('button', { name: 'Cancel form' }) + await userEvent.click(cancelButton) + + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: { cancelled: true } + }) + }) + + it('should handle form with multiple mixed field types', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + helperText: 'Enter title' + }, + { + type: 'checkbox', + name: 'active', + label: 'Active', + required: false, + helperText: 'Check if active' + }, + { + type: 'number', + name: 'count', + label: 'Count', + required: false, + helperText: 'Enter count' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const titleField = screen.getByLabelText('Title') + const activeField = screen.getByRole('checkbox', { name: 'Active' }) + const countField = screen.getByLabelText('Count') + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + await userEvent.type(titleField, 'Test Title') + await userEvent.click(activeField) + await userEvent.type(countField, '42') + await userEvent.click(submitButton) + + await waitFor(() => { + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: { + title: 'Test Title', + active: true, + count: 42 + } + }) + }) + }) + }) + + describe('Validation', () => { + it('should show required field error when field is empty', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + helperText: 'Enter title' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const textField = screen.getByLabelText('Title') + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + // Focus and blur to trigger validation + await userEvent.click(textField) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(screen.getByText('Required')).toBeInTheDocument() + }) + + expect(mockAddToolResult).not.toHaveBeenCalled() + }) + + it('should validate email format', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'email', + name: 'email', + label: 'Email', + required: true, + helperText: 'Enter email' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const emailField = screen.getByLabelText('Email') + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + await userEvent.type(emailField, 'invalid-email') + await userEvent.click(submitButton) + + await waitFor(() => { + expect(screen.getByText('Invalid email address')).toBeInTheDocument() + }) + + expect(mockAddToolResult).not.toHaveBeenCalled() + }) + + it('should validate URL format', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'url', + name: 'website', + label: 'Website', + required: true, + helperText: 'Enter website' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const urlField = screen.getByLabelText('Website') + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + await userEvent.type(urlField, 'not-a-url') + await userEvent.click(submitButton) + + await waitFor(() => { + expect(screen.getByText('Invalid URL')).toBeInTheDocument() + }) + + expect(mockAddToolResult).not.toHaveBeenCalled() + }) + + it('should validate phone number format', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'tel', + name: 'phone', + label: 'Phone', + required: true, + helperText: 'Enter phone' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const phoneField = screen.getByLabelText('Phone') + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + await userEvent.type(phoneField, '123') + await userEvent.click(submitButton) + + await waitFor(() => { + expect(screen.getByText('Invalid phone number')).toBeInTheDocument() + }) + + expect(mockAddToolResult).not.toHaveBeenCalled() + }) + + it('should clear error when field is corrected', async () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'email', + name: 'email', + label: 'Email', + required: true, + helperText: 'Enter email' + } + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + const emailField = screen.getByLabelText('Email') + const submitButton = screen.getByRole('button', { name: 'Submit form' }) + + // Enter invalid email + await userEvent.type(emailField, 'invalid-email') + await userEvent.click(submitButton) + + await waitFor(() => { + expect(screen.getByText('Invalid email address')).toBeInTheDocument() + }) + + // Clear and enter valid email + await userEvent.clear(emailField) + await userEvent.type(emailField, 'valid@email.com') + + // Error should be cleared and submit should work + await userEvent.click(submitButton) + + await waitFor(() => { + expect( + screen.queryByText('Invalid email address') + ).not.toBeInTheDocument() + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: { email: 'valid@email.com' } + }) + }) + }) + }) + + describe('State Management', () => { + it('should render result state with submitted values', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + helperText: 'Enter title' + }, + { + type: 'number', + name: 'count', + label: 'Count', + required: false, + helperText: 'Enter count' + } + ] + }, + state: 'result' as const, + result: { + title: 'My Journey', + count: 42 + } + } + } as ToolInvocationUIPart + + render() + + // Should display submitted values in result format + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('My Journey')).toBeInTheDocument() + expect(screen.getByText('Count')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() + + // Should display in correct order + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(2) + expect(listItems[0]).toHaveTextContent('Title') + expect(listItems[0]).toHaveTextContent('My Journey') + expect(listItems[1]).toHaveTextContent('Count') + expect(listItems[1]).toHaveTextContent('42') + + // Should not show form elements + expect( + screen.queryByRole('button', { name: 'Submit form' }) + ).not.toBeInTheDocument() + }) + + it('should display "Yes"/"No" for checkbox values in result state', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'checkbox', + name: 'agree', + label: 'I agree', + required: false, + helperText: 'Check to agree' + }, + { + type: 'checkbox', + name: 'notify', + label: 'Notify me', + required: false, + helperText: 'Check for notifications' + } + ] + }, + state: 'result' as const, + result: { + agree: true, + notify: false + } + } + } as ToolInvocationUIPart + + render() + + expect(screen.getByText('I agree')).toBeInTheDocument() + expect(screen.getByText('Yes')).toBeInTheDocument() + expect(screen.getByText('Notify me')).toBeInTheDocument() + expect(screen.getByText('No')).toBeInTheDocument() + + // Should display in correct order + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(2) + expect(listItems[0]).toHaveTextContent('I agree') + expect(listItems[0]).toHaveTextContent('Yes') + expect(listItems[1]).toHaveTextContent('Notify me') + expect(listItems[1]).toHaveTextContent('No') + }) + + it('should display "—" for empty/null values in result state', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: false, + helperText: 'Enter title' + }, + { + type: 'text', + name: 'description', + label: 'Description', + required: false, + helperText: 'Enter description' + } + ] + }, + state: 'result' as const, + result: { + title: '', + description: null + } + } + } as ToolInvocationUIPart + + render() + + const emptyValueElements = screen.getAllByText('—') + expect(emptyValueElements).toHaveLength(2) + + // Should display in correct order + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(2) + expect(listItems[0]).toHaveTextContent('Title') + expect(listItems[0]).toHaveTextContent('—') + expect(listItems[1]).toHaveTextContent('Description') + expect(listItems[1]).toHaveTextContent('—') + }) + + it('should show cancellation message when form was cancelled', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + helperText: 'Enter title' + } + ] + }, + state: 'result' as const, + result: { + cancelled: true + } + } + } as ToolInvocationUIPart + + render() + + expect(screen.getByText('Form was cancelled')).toBeInTheDocument() + expect(screen.queryByText('Title')).not.toBeInTheDocument() + }) + + it('should return null for unknown state', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [ + { + type: 'text', + name: 'title', + label: 'Title', + required: true, + helperText: 'Enter title' + } + ] + }, + state: 'unknown' as any + } + } as ToolInvocationUIPart + + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty formItems array', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: { + formItems: [] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + // Should still render submit and cancel buttons + expect( + screen.getByRole('button', { name: 'Submit form' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Cancel form' }) + ).toBeInTheDocument() + }) + + it('should handle missing formItems in args', () => { + const part = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientRequestForm', + args: {}, + state: 'call' as const + } + } as ToolInvocationUIPart + + render() + + // Should still render submit and cancel buttons + expect( + screen.getByRole('button', { name: 'Submit form' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Cancel form' }) + ).toBeInTheDocument() + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RequestFormTool/RequestFormTool.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RequestFormTool/RequestFormTool.tsx new file mode 100644 index 00000000000..bf2debe5825 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RequestFormTool/RequestFormTool.tsx @@ -0,0 +1,361 @@ +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Checkbox from '@mui/material/Checkbox' +import Chip from '@mui/material/Chip' +import FormControl from '@mui/material/FormControl' +import FormControlLabel from '@mui/material/FormControlLabel' +import FormGroup from '@mui/material/FormGroup' +import FormLabel from '@mui/material/FormLabel' +import List from '@mui/material/List' +import ListItem from '@mui/material/ListItem' +import ListItemText from '@mui/material/ListItemText' +import MenuItem from '@mui/material/MenuItem' +import Radio from '@mui/material/Radio' +import RadioGroup from '@mui/material/RadioGroup' +import Select from '@mui/material/Select' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { Field, Form, Formik } from 'formik' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' +import { z } from 'zod' +import { toFormikValidationSchema } from 'zod-formik-adapter' + +import { formItemSchema } from '../../../../../../libs/ai/tools/client/requestForm/requestForm' +import type { LegacyToolInvocationPart } from '../../../MessageList' +import type { AddToolResultChildArg } from '../../ToolInvocationPart' + +type FormItem = z.infer + +function getStringValidator( + item: FormItem +): z.ZodString | z.ZodOptional { + let validator = z.string() + switch (item.type) { + case 'email': + validator = validator.email('Invalid email address') + break + case 'url': + validator = validator.url('Invalid URL') + break + case 'tel': + validator = validator.regex(/^[+\d\s().-]{7,}$/, 'Invalid phone number') + break + } + if (item.required) { + return validator.min(1, 'Required') + } else { + return validator.optional() + } +} + +function getNumberValidator(item: FormItem): z.ZodType { + const validator = z.coerce.number() + if (!item.required) return validator.optional() + + return validator +} + +function getCheckboxValidator( + item: FormItem +): z.ZodBoolean | z.ZodOptional { + const validator = z.boolean() + if (!item.required) return validator.optional() + + return validator +} + +function getItemValidator(item: FormItem): z.ZodTypeAny { + switch (item.type) { + case 'number': + return getNumberValidator(item) + case 'checkbox': + return getCheckboxValidator(item) + case 'email': + case 'url': + case 'tel': + default: + return getStringValidator(item) + } +} + +function buildValidationSchema(formItems: any[]) { + const shape: Record = {} + const items = z.array(formItemSchema).parse(formItems) + + for (const item of items) { + shape[item.name] = getItemValidator(item) + } + return z.object(shape) +} +interface RequestFormToolProps { + part: LegacyToolInvocationPart + addToolResult: (arg: AddToolResultChildArg) => void +} + +export function RequestFormTool({ + part: { toolInvocation }, + addToolResult +}: RequestFormToolProps): ReactElement | null { + const { t } = useTranslation('apps-journeys-admin') + const formItems = z + .array(formItemSchema) + .parse(toolInvocation.args?.formItems || []) + + // Build initial values for Formik + const initialValues = formItems.reduce( + (acc: Record, item: any) => { + switch (item.type) { + case 'checkbox': + acc[item.name] = false + break + default: + acc[item.name] = '' + } + return acc + }, + {} + ) + + const validationSchema = toFormikValidationSchema( + buildValidationSchema(formItems) + ) + + const handleSubmit = (values: Record) => { + addToolResult({ + toolCallId: toolInvocation.toolCallId, + result: values + }) + } + + switch (toolInvocation.state) { + case 'call': + return ( + + {({ values, handleChange, setFieldValue, errors, touched }) => ( +
+ + {formItems.map((item: any) => { + const fieldError = errors[item.name] + const fieldTouched = touched[item.name] + const showError = fieldTouched && fieldError + const showSuggestion = + typeof item.suggestion === 'string' && + item.suggestion.length > 0 + const handleSuggestion = () => + setFieldValue(item.name, item.suggestion) + switch (item.type) { + case 'text': + case 'number': + case 'textarea': + case 'email': + case 'tel': + case 'url': + return ( + + + {showSuggestion && ( + + )} + + ) + case 'select': + return ( + + + {item.label} + + + + {showError ? fieldError : item.helperText} + + + ) + case 'checkbox': + return ( + + + ) => setFieldValue(item.name, e.target.checked)} + inputProps={{ + 'aria-label': item.label, + tabIndex: 0 + }} + /> + } + label={item.label} + /> + + {showError ? fieldError : item.helperText} + + + ) + case 'radio': + return ( + + {item.label} + + {item.options?.map((option: any) => ( + } + label={option.label} + /> + ))} + + + {showError ? fieldError : item.helperText} + + + ) + default: + return null + } + })} + + + + + +
+ )} +
+ ) + case 'result': + if (toolInvocation.result?.cancelled) { + return ( + + + + ) + } + return ( + + {formItems.map((item) => { + const value = toolInvocation.result?.[item.name] + let displayValue: React.ReactNode = '—' + if (item.type === 'checkbox') { + displayValue = value ? t('Yes') : t('No') + } else if (value !== undefined && value !== null && value !== '') { + displayValue = value + } + return ( + + + + ) + })} + + ) + default: + return null + } +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RequestFormTool/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RequestFormTool/index.ts new file mode 100644 index 00000000000..7f458f18e0b --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/RequestFormTool/index.ts @@ -0,0 +1 @@ +export { RequestFormTool } from './RequestFormTool' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectImageTool/SelectImageTool.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectImageTool/SelectImageTool.spec.tsx new file mode 100644 index 00000000000..f35e9194a23 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectImageTool/SelectImageTool.spec.tsx @@ -0,0 +1,195 @@ +import { ToolInvocationUIPart } from '@ai-sdk/ui-utils' +import { fireEvent, render, screen } from '@testing-library/react' + +import { ClientSelectImageTool } from './SelectImageTool' + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +jest.mock('next/image', () => { + return function MockedImage({ src, alt, width, height, onClick }: any) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ) + } +}) + +jest.mock('../../../../../Editor/Slider/Settings/Drawer/ImageLibrary', () => ({ + ImageLibrary: function MockedImageLibrary({ open, onClose, onChange }: any) { + return ( +
+ + +
+ ) + } +})) + +describe('ClientSelectImageTool', () => { + const mockAddToolResult = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Call State', () => { + const callPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientSelectImage', + args: { + message: 'Select an image for your block', + generatedImageUrls: [ + 'https://example.com/image1.png', + 'https://example.com/image2.png' + ] + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + it('should render message and buttons when state is call', () => { + render( + + ) + + expect( + screen.getByText('Select an image for your block') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Open Image Library' }) + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('should render generated images when provided', () => { + render( + + ) + + const images = screen.getAllByTestId('generated-image') + expect(images).toHaveLength(2) + expect(images[0]).toHaveAttribute('src', 'https://example.com/image1.png') + expect(images[1]).toHaveAttribute('src', 'https://example.com/image2.png') + }) + + it('should call addToolResult when generated image is clicked', () => { + render( + + ) + + const firstImage = screen.getAllByTestId('generated-image')[0] + fireEvent.click(firstImage) + + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: + 'update the image block to use this url: https://example.com/image1.png' + }) + }) + + it('should open image library when button is clicked', () => { + render( + + ) + + expect(screen.getByTestId('image-library')).not.toBeVisible() + + fireEvent.click( + screen.getByRole('button', { name: 'Open Image Library' }) + ) + + expect(screen.getByTestId('image-library')).toBeVisible() + }) + + it('should call addToolResult when cancel button is clicked', () => { + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: { cancelled: true } + }) + }) + + it('should call addToolResult when image is selected from library', () => { + render( + + ) + + fireEvent.click( + screen.getByRole('button', { name: 'Open Image Library' }) + ) + fireEvent.click(screen.getByText('Select Image')) + + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: + 'update the image block using this object: {"src":"selected-image.jpg"}' + }) + }) + }) + + describe('Default State', () => { + const unknownPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientSelectImage', + args: { + message: 'Select an image for your block' + }, + state: 'unknown' as any + } + } as unknown as ToolInvocationUIPart + + it('should return null for unknown state', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectImageTool/SelectImageTool.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectImageTool/SelectImageTool.tsx new file mode 100644 index 00000000000..67dffae4cc3 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectImageTool/SelectImageTool.tsx @@ -0,0 +1,97 @@ +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import Image from 'next/image' +import { useTranslation } from 'next-i18next' +import { ReactElement, useState } from 'react' + +import { ImageLibrary } from '../../../../../Editor/Slider/Settings/Drawer/ImageLibrary' +import type { LegacyToolInvocationPart } from '../../MessageList' +import type { AddToolResultChildArg } from '../../ToolInvocationPart' + +interface ClientSelectImageToolProps { + part: LegacyToolInvocationPart + addToolResult: (arg: AddToolResultChildArg) => void +} + +export function ClientSelectImageTool({ + part: { + toolInvocation: { toolCallId, args, state } + }, + addToolResult +}: ClientSelectImageToolProps): ReactElement | null { + const { t } = useTranslation('apps-journeys-admin') + const [open, setOpen] = useState(false) + + switch (state) { + case 'call': + return ( + + + {args.message} + + + + {args.generatedImageUrls?.map((url) => ( + Generated image { + addToolResult({ + toolCallId, + result: `update the image block to use this url: ${url}` + }) + }} + /> + ))} + + + + { + setOpen(false) + }} + onChange={async (selectedImage) => { + addToolResult({ + toolCallId, + result: `update the image block using this object: ${JSON.stringify( + selectedImage + )}` + }) + }} + selectedBlock={null} + /> + + + ) + default: { + return null + } + } +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectImageTool/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectImageTool/index.ts new file mode 100644 index 00000000000..464086c9234 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectImageTool/index.ts @@ -0,0 +1 @@ +export { ClientSelectImageTool } from './SelectImageTool' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectVideoTool/SelectVideoTool.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectVideoTool/SelectVideoTool.spec.tsx new file mode 100644 index 00000000000..787e024a47c --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectVideoTool/SelectVideoTool.spec.tsx @@ -0,0 +1,146 @@ +import { ToolInvocationUIPart } from '@ai-sdk/ui-utils' +import { fireEvent, render, screen } from '@testing-library/react' + +import { ClientSelectVideoTool } from './SelectVideoTool' + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +jest.mock('../../../../../Editor/Slider/Settings/Drawer/VideoLibrary', () => ({ + VideoLibrary: function MockedVideoLibrary({ open, onClose, onSelect }: any) { + return ( +
+ + +
+ ) + } +})) + +describe('ClientSelectVideoTool', () => { + const mockAddToolResult = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Call State', () => { + const callPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientSelectVideo', + args: { + message: 'Select a video for your block' + }, + state: 'call' as const + } + } as ToolInvocationUIPart + + it('should render message and buttons when state is call', () => { + render( + + ) + + expect( + screen.getByText('Select a video for your block') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Open Video Library' }) + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('should open video library when button is clicked', () => { + render( + + ) + expect(screen.getByTestId('video-library')).not.toBeVisible() + + fireEvent.click( + screen.getByRole('button', { name: 'Open Video Library' }) + ) + + expect(screen.getByTestId('video-library')).toBeVisible() + }) + + it('should call addToolResult when cancel button is clicked', () => { + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: { cancelled: true } + }) + }) + + it('should call addToolResult when video is selected from library', () => { + render( + + ) + + fireEvent.click( + screen.getByRole('button', { name: 'Open Video Library' }) + ) + fireEvent.click(screen.getByText('Select Video')) + + expect(mockAddToolResult).toHaveBeenCalledWith({ + toolCallId: 'test-id', + result: + 'here is the video: {"videoId":"selected-video","title":"Test Video"}' + }) + }) + }) + + describe('Default State', () => { + const unknownPart = { + type: 'tool-invocation' as const, + toolInvocation: { + toolCallId: 'test-id', + toolName: 'clientSelectVideo', + args: { + message: 'Select a video for your block' + }, + state: 'unknown' as any + } + } as unknown as ToolInvocationUIPart + + it('should return null for unknown state', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectVideoTool/SelectVideoTool.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectVideoTool/SelectVideoTool.tsx new file mode 100644 index 00000000000..5751f729035 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectVideoTool/SelectVideoTool.tsx @@ -0,0 +1,67 @@ +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { ReactElement, useState } from 'react' + +import { VideoLibrary } from '../../../../../Editor/Slider/Settings/Drawer/VideoLibrary' +import type { LegacyToolInvocationPart } from '../../MessageList' +import type { AddToolResultChildArg } from '../../ToolInvocationPart' + +interface ClientSelectVideoToolProps { + part: LegacyToolInvocationPart + addToolResult: (arg: AddToolResultChildArg) => void +} + +export function ClientSelectVideoTool({ + part, + addToolResult +}: ClientSelectVideoToolProps): ReactElement | null { + const { t } = useTranslation('apps-journeys-admin') + const [open, setOpen] = useState(false) + + switch (part.toolInvocation.state) { + case 'call': + return ( + + + {part.toolInvocation.args.message} + + + + + setOpen(false)} + selectedBlock={null} + onSelect={async (selectedVideo) => { + addToolResult({ + toolCallId: part.toolInvocation.toolCallId, + result: `here is the video: ${JSON.stringify(selectedVideo)}` + }) + }} + /> + + + ) + default: { + return null + } + } +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectVideoTool/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectVideoTool/index.ts new file mode 100644 index 00000000000..6d163f5cf15 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/client/SelectVideoTool/index.ts @@ -0,0 +1 @@ +export { ClientSelectVideoTool } from './SelectVideoTool' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/index.ts new file mode 100644 index 00000000000..3d58e3ef841 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/ToolInvocationPart/index.ts @@ -0,0 +1 @@ +export { ToolInvocationPart } from './ToolInvocationPart' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/UserFeedback/UserFeedback.spec.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/UserFeedback/UserFeedback.spec.tsx new file mode 100644 index 00000000000..d901811d81a --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/UserFeedback/UserFeedback.spec.tsx @@ -0,0 +1,138 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { langfuseWeb } from '../../../../libs/ai/langfuse/client' + +import { UserFeedback } from './UserFeedback' + +jest.mock('../../../../libs/ai/langfuse/client', () => ({ + langfuseWeb: { + score: jest.fn().mockResolvedValue({}) + } +})) + +const mockLangfuseWeb = langfuseWeb as jest.Mocked<{ + score: jest.MockedFunction +}> + +describe('UserFeedback', () => { + const mockTraceId = 'test-trace-id-123' + + beforeEach(() => { + jest.clearAllMocks() + jest.restoreAllMocks() + }) + + describe('Component Rendering', () => { + it('should render thumbs up/down buttons with correct styling, tooltips, and default state', () => { + render() + + const thumbsUpButton = screen.getByRole('button', { + name: /good response/i + }) + const thumbsDownButton = screen.getByRole('button', { + name: /bad response/i + }) + + expect(thumbsUpButton).toBeInTheDocument() + expect(thumbsDownButton).toBeInTheDocument() + + expect(screen.getByLabelText('Good Response')).toBeInTheDocument() + expect(screen.getByLabelText('Bad Response')).toBeInTheDocument() + + expect(thumbsUpButton).not.toHaveClass('MuiIconButton-colorPrimary') + expect(thumbsDownButton).not.toHaveClass('MuiIconButton-colorPrimary') + + expect(thumbsUpButton).toHaveClass('MuiIconButton-sizeSmall') + expect(thumbsDownButton).toHaveClass('MuiIconButton-sizeSmall') + }) + }) + + describe('Positive Feedback Interaction', () => { + it('should handle thumbs up click, update visual state, and call langfuse correctly', async () => { + render() + + const thumbsUpButton = screen.getByRole('button', { + name: /good response/i + }) + + await userEvent.click(thumbsUpButton) + + expect(thumbsUpButton).toHaveClass('MuiIconButton-colorPrimary') + + await waitFor(() => { + expect(mockLangfuseWeb.score).toHaveBeenCalledWith({ + traceId: mockTraceId, + name: 'user_feedback', + value: 1 + }) + }) + + expect(mockLangfuseWeb.score).toHaveBeenCalledTimes(1) + }) + }) + + describe('Negative Feedback Interaction', () => { + it('should handle thumbs down click, update visual state, and call langfuse correctly', async () => { + render() + + const thumbsDownButton = screen.getByRole('button', { + name: /bad response/i + }) + + await userEvent.click(thumbsDownButton) + + expect(thumbsDownButton).toHaveClass('MuiIconButton-colorPrimary') + + await waitFor(() => { + expect(mockLangfuseWeb.score).toHaveBeenCalledWith({ + traceId: mockTraceId, + name: 'user_feedback', + value: 0 + }) + }) + + expect(mockLangfuseWeb.score).toHaveBeenCalledTimes(1) + }) + }) + + describe('State Management', () => { + it('should allow switching between positive and negative feedback', async () => { + render() + + const thumbsUpButton = screen.getByRole('button', { + name: /good response/i + }) + const thumbsDownButton = screen.getByRole('button', { + name: /bad response/i + }) + + await userEvent.click(thumbsUpButton) + expect(thumbsUpButton).toHaveClass('MuiIconButton-colorPrimary') + expect(thumbsDownButton).not.toHaveClass('MuiIconButton-colorPrimary') + + await userEvent.click(thumbsDownButton) + expect(thumbsDownButton).toHaveClass('MuiIconButton-colorPrimary') + expect(thumbsUpButton).not.toHaveClass('MuiIconButton-colorPrimary') + + await waitFor(() => { + expect(mockLangfuseWeb.score).toHaveBeenCalledTimes(2) + }) + }) + + it('should persist feedback state after selection', async () => { + render() + + const thumbsUpButton = screen.getByRole('button', { + name: /good response/i + }) + + await userEvent.click(thumbsUpButton) + + expect(thumbsUpButton).toHaveClass('MuiIconButton-colorPrimary') + + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(thumbsUpButton).toHaveClass('MuiIconButton-colorPrimary') + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/UserFeedback/UserFeedback.tsx b/apps/journeys-admin/src/components/AiChat/MessageList/UserFeedback/UserFeedback.tsx new file mode 100644 index 00000000000..25e58b9abc0 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/UserFeedback/UserFeedback.tsx @@ -0,0 +1,49 @@ +import IconButton from '@mui/material/IconButton' +import Stack from '@mui/material/Stack' +import Tooltip from '@mui/material/Tooltip' +import { useState } from 'react' + +import ThumbsDown from '@core/shared/ui/icons/ThumbsDown' +import ThumbsUp from '@core/shared/ui/icons/ThumbsUp' + +import { langfuseWeb } from '../../../../libs/ai/langfuse/client' + +interface UserFeedbackProps { + traceId: string +} + +export function UserFeedback({ traceId }: UserFeedbackProps) { + const [feedback, setFeedback] = useState(null) + + function handleUserFeedback(value: number) { + setFeedback(value) + void langfuseWeb.score({ + traceId, + name: 'user_feedback', + value + }) + } + + return ( + + + handleUserFeedback(1)} + color={feedback === 1 ? 'primary' : 'default'} + size="small" + > + + + + + handleUserFeedback(0)} + color={feedback === 0 ? 'primary' : 'default'} + size="small" + > + + + + + ) +} diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/UserFeedback/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/UserFeedback/index.ts new file mode 100644 index 00000000000..bb0f37577b3 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/UserFeedback/index.ts @@ -0,0 +1 @@ +export { UserFeedback } from './UserFeedback' diff --git a/apps/journeys-admin/src/components/AiChat/MessageList/index.ts b/apps/journeys-admin/src/components/AiChat/MessageList/index.ts new file mode 100644 index 00000000000..d70731dde4a --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/MessageList/index.ts @@ -0,0 +1 @@ +export { MessageList } from './MessageList' diff --git a/apps/journeys-admin/src/components/AiChat/State/Empty/Empty.spec.tsx b/apps/journeys-admin/src/components/AiChat/State/Empty/Empty.spec.tsx new file mode 100644 index 00000000000..e3898522747 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/Empty/Empty.spec.tsx @@ -0,0 +1,103 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +import { StateEmpty } from './Empty' + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +describe('StateEmpty', () => { + const mockAppend = jest.fn() + const defaultProps = { + messages: [], + append: mockAppend + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Conditional Rendering', () => { + it('should render when messages array is empty', () => { + render() + + expect(screen.getByText('Customize my journey')).toBeInTheDocument() + expect( + screen.getByText('Translate to another language') + ).toBeInTheDocument() + expect(screen.getByText('Tell me about my journey')).toBeInTheDocument() + expect( + screen.getByText('What can I do to improve my journey?') + ).toBeInTheDocument() + + expect( + screen.getByText( + 'NextSteps AI can help you make your journey more effective!Ask it anything.' + ) + ).toBeInTheDocument() + + const chips = screen.getAllByRole('button') + expect(chips).toHaveLength(4) + }) + + it('should return null when messages array has content', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + expect(screen.queryByText('Customize my journey')).not.toBeInTheDocument() + }) + }) + + describe('Chip Button Interactions', () => { + it('should call append with customize journey message when clicked', () => { + render() + + fireEvent.click(screen.getByText('Customize my journey')) + + expect(mockAppend).toHaveBeenCalledWith({ + role: 'user', + content: 'Help me customize my journey.' + }) + }) + + it('should call append with translate message when clicked', () => { + render() + + fireEvent.click(screen.getByText('Translate to another language')) + + expect(mockAppend).toHaveBeenCalledWith({ + role: 'user', + content: 'Help me to translate my journey to another language.' + }) + }) + + it('should call append with tell me about journey message when clicked', () => { + render() + + fireEvent.click(screen.getByText('Tell me about my journey')) + + expect(mockAppend).toHaveBeenCalledWith({ + role: 'user', + content: 'Tell me about my journey.' + }) + }) + + it('should call append with improvement message when clicked', () => { + render() + + fireEvent.click(screen.getByText('What can I do to improve my journey?')) + + expect(mockAppend).toHaveBeenCalledWith({ + role: 'user', + content: 'What can I do to improve my journey?' + }) + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/State/Empty/Empty.tsx b/apps/journeys-admin/src/components/AiChat/State/Empty/Empty.tsx new file mode 100644 index 00000000000..0549643eaef --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/Empty/Empty.tsx @@ -0,0 +1,78 @@ +import Box from '@mui/material/Box' +import Chip from '@mui/material/Chip' +import Typography from '@mui/material/Typography' +import { UIMessage } from 'ai' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +interface StateEmptyProps { + messages: UIMessage[] + onSendMessage: (text: string) => void +} + +export function StateEmpty({ + messages, + onSendMessage +}: StateEmptyProps): ReactElement | null { + const { t } = useTranslation('apps-journeys-admin') + + return messages.length === 0 ? ( + <> + + onSendMessage('Help me customize my journey.')} + /> + + onSendMessage( + 'Help me to translate my journey to another language.' + ) + } + /> + onSendMessage('Tell me about my journey.')} + /> + + onSendMessage('What can I do to improve my journey?') + } + /> + + + {t('NextSteps AI can help you make your journey more effective!')} +
+ {t('Ask it anything.')} +
+ + ) : null +} diff --git a/apps/journeys-admin/src/components/AiChat/State/Empty/index.ts b/apps/journeys-admin/src/components/AiChat/State/Empty/index.ts new file mode 100644 index 00000000000..a3323fceee4 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/Empty/index.ts @@ -0,0 +1 @@ +export { StateEmpty } from './Empty' diff --git a/apps/journeys-admin/src/components/AiChat/State/Error/Error.spec.tsx b/apps/journeys-admin/src/components/AiChat/State/Error/Error.spec.tsx new file mode 100644 index 00000000000..fde16828b1f --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/Error/Error.spec.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +import { StateError } from './Error' + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (str: string) => str + }) +})) + +describe('StateError', () => { + const mockReload = jest.fn() + const mockError = new Error('Test error message') + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render error message and retry button when error exists', () => { + render() + + expect( + screen.getByText('An error occurred. Please try again.') + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + it('should return null when error is null', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + expect( + screen.queryByText('An error occurred. Please try again.') + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Retry' }) + ).not.toBeInTheDocument() + }) + + it('should return null when error is undefined', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + expect( + screen.queryByText('An error occurred. Please try again.') + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Retry' }) + ).not.toBeInTheDocument() + }) + + it('should call reload when retry button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + + expect(mockReload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/State/Error/Error.tsx b/apps/journeys-admin/src/components/AiChat/State/Error/Error.tsx new file mode 100644 index 00000000000..c1822e83c28 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/Error/Error.tsx @@ -0,0 +1,24 @@ +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +interface StateErrorProps { + error: Error | undefined + onRetry: () => void +} + +export function StateError({ + error, + onRetry +}: StateErrorProps): ReactElement | null { + const { t } = useTranslation('apps-journeys-admin') + + return error != null ? ( + + {t('An error occurred. Please try again.')} + + + ) : null +} diff --git a/apps/journeys-admin/src/components/AiChat/State/Error/index.ts b/apps/journeys-admin/src/components/AiChat/State/Error/index.ts new file mode 100644 index 00000000000..a62275017e0 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/Error/index.ts @@ -0,0 +1 @@ +export { StateError } from './Error' diff --git a/apps/journeys-admin/src/components/AiChat/State/Loading/Loading.spec.tsx b/apps/journeys-admin/src/components/AiChat/State/Loading/Loading.spec.tsx new file mode 100644 index 00000000000..5fe7fe082a0 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/Loading/Loading.spec.tsx @@ -0,0 +1,45 @@ +import { render, screen, waitFor } from '@testing-library/react' + +import { StateLoading } from './Loading' + +describe('StateLoading', () => { + describe('Conditional Rendering', () => { + it('should render loading spinner when status is submitted', () => { + render() + + expect(screen.getByRole('progressbar')).toBeInTheDocument() + }) + + it('should not render loading spinner when status is streaming', () => { + render() + + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + }) + + it('should not render loading spinner when status is ready', () => { + render() + + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + }) + + it('should not render loading spinner when status is error', () => { + render() + + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + }) + }) + + describe('Collapse Animation', () => { + it('should not render progress bar when status changes from submitted', async () => { + const { rerender } = render() + + expect(screen.getByRole('progressbar')).toBeInTheDocument() + + rerender() + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/apps/journeys-admin/src/components/AiChat/State/Loading/Loading.tsx b/apps/journeys-admin/src/components/AiChat/State/Loading/Loading.tsx new file mode 100644 index 00000000000..b687c87f9ef --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/Loading/Loading.tsx @@ -0,0 +1,22 @@ +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import Collapse from '@mui/material/Collapse' +import { ReactElement } from 'react' + +interface StateLoadingProps { + status: 'ready' | 'submitted' | 'streaming' | 'error' +} + +export function StateLoading({ + status +}: StateLoadingProps): ReactElement | null { + return ( + + + + + + + + ) +} diff --git a/apps/journeys-admin/src/components/AiChat/State/Loading/index.ts b/apps/journeys-admin/src/components/AiChat/State/Loading/index.ts new file mode 100644 index 00000000000..2b98dcee52f --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/Loading/index.ts @@ -0,0 +1 @@ +export { StateLoading } from './Loading' diff --git a/apps/journeys-admin/src/components/AiChat/State/index.ts b/apps/journeys-admin/src/components/AiChat/State/index.ts new file mode 100644 index 00000000000..24479779609 --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/State/index.ts @@ -0,0 +1,3 @@ +export { StateEmpty } from './Empty' +export { StateError } from './Error' +export { StateLoading } from './Loading' diff --git a/apps/journeys-admin/src/components/AiChat/index.ts b/apps/journeys-admin/src/components/AiChat/index.ts new file mode 100644 index 00000000000..c7d87cf757d --- /dev/null +++ b/apps/journeys-admin/src/components/AiChat/index.ts @@ -0,0 +1 @@ +export { AiChat } from './AiChat' diff --git a/apps/journeys-admin/src/components/Editor/AiEditButton/AiEditButton.spec.tsx b/apps/journeys-admin/src/components/Editor/AiEditButton/AiEditButton.spec.tsx new file mode 100644 index 00000000000..1cda08956f8 --- /dev/null +++ b/apps/journeys-admin/src/components/Editor/AiEditButton/AiEditButton.spec.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +import { AiEditButton } from './AiEditButton' + +jest.mock('../../AiChat', () => ({ + AiChat: () =>
Mocked AiChat
+})) + +describe('AiEditButton', () => { + it('should render button and ai chat', () => { + render() + + const fabButton = screen.getByTestId('AiEditButton') + expect(fabButton).toBeInTheDocument() + + const aiChat = screen.getByTestId('mocked-aichat') + expect(aiChat).toBeInTheDocument() + + // AiChat should not be visible initially + expect(aiChat).not.toBeVisible() + }) + + it('should open chat when button is clicked', () => { + render() + + const fabButton = screen.getByTestId('AiEditButton') + const aiChat = screen.getByTestId('mocked-aichat') + expect(aiChat).not.toBeVisible() + + fireEvent.click(fabButton) + expect(aiChat).toBeVisible() + }) + + it('should close chat when button is clicked again', () => { + render() + + const fabButton = screen.getByTestId('AiEditButton') + const aiChat = screen.getByTestId('mocked-aichat') + expect(aiChat).not.toBeVisible() + + fireEvent.click(fabButton) + expect(aiChat).toBeVisible() + + fireEvent.click(fabButton) + expect(aiChat).not.toBeVisible() + }) +}) diff --git a/apps/journeys-admin/src/components/Editor/AiEditButton/AiEditButton.tsx b/apps/journeys-admin/src/components/Editor/AiEditButton/AiEditButton.tsx new file mode 100644 index 00000000000..e3c38aceaaf --- /dev/null +++ b/apps/journeys-admin/src/components/Editor/AiEditButton/AiEditButton.tsx @@ -0,0 +1,46 @@ +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import Card from '@mui/material/Card' +import Fab from '@mui/material/Fab' +import Grow from '@mui/material/Grow' +import { ReactElement, useState } from 'react' + +import { AiChat } from '../../AiChat' + +export function AiEditButton(): ReactElement { + const [open, setOpen] = useState(false) + + const handleClick = () => { + setOpen(!open) + } + + return ( + <> + + + + + + + + + + ) +} diff --git a/apps/journeys-admin/src/components/Editor/AiEditButton/index.ts b/apps/journeys-admin/src/components/Editor/AiEditButton/index.ts new file mode 100644 index 00000000000..46ad3776477 --- /dev/null +++ b/apps/journeys-admin/src/components/Editor/AiEditButton/index.ts @@ -0,0 +1 @@ +export { AiEditButton } from './AiEditButton' diff --git a/apps/journeys-admin/src/components/Editor/Editor.spec.tsx b/apps/journeys-admin/src/components/Editor/Editor.spec.tsx index 347434d4c00..7faabf8eb31 100644 --- a/apps/journeys-admin/src/components/Editor/Editor.spec.tsx +++ b/apps/journeys-admin/src/components/Editor/Editor.spec.tsx @@ -1,11 +1,20 @@ import { MockedProvider, type MockedResponse } from '@apollo/client/testing' import useMediaQuery from '@mui/material/useMediaQuery' import { render, screen, waitFor } from '@testing-library/react' +import { InfiniteHitsRenderState } from 'instantsearch.js/es/connectors/infinite-hits/connectInfiniteHits' +import { SearchBoxRenderState } from 'instantsearch.js/es/connectors/search-box/connectSearchBox' import { useRouter } from 'next/compat/router' import { NextRouter } from 'next/router' import { SnackbarProvider } from 'notistack' +import { + InstantSearchApi, + useInfiniteHits, + useInstantSearch, + useSearchBox +} from 'react-instantsearch' import type { TreeBlock } from '@core/journeys/ui/block' +import { FlagsProvider } from '@core/shared/ui/FlagsProvider' import type { GetJourney_journey as Journey } from '../../../__generated__/GetJourney' import type { GetStepBlocksWithPosition } from '../../../__generated__/GetStepBlocksWithPosition' @@ -18,6 +27,7 @@ import { mockReactFlow } from '../../../test/mockReactFlow' import { ThemeProvider } from '../ThemeProvider' import { GET_STEP_BLOCKS_WITH_POSITION } from './Slider/JourneyFlow/JourneyFlow' +import { videoItems } from './Slider/Settings/Drawer/VideoLibrary/data' import { Editor } from '.' @@ -31,6 +41,50 @@ jest.mock('@mui/material/useMediaQuery', () => ({ default: jest.fn() })) +jest.mock('next-firebase-auth', () => ({ + useUser: jest.fn().mockReturnValue({ + user: { + id: '1', + email: 'test@test.com', + name: 'Test User' + } + }) +})) + +jest.mock('react-instantsearch') + +jest.mock('../AiChat', () => ({ + AiChat: jest.fn() +})) + +const mockUseSearchBox = useSearchBox as jest.MockedFunction< + typeof useSearchBox +> +const mockUseInstantSearch = useInstantSearch as jest.MockedFunction< + typeof useInstantSearch +> +const mockUseInfiniteHits = useInfiniteHits as jest.MockedFunction< + typeof useInfiniteHits +> + +const searchBox = { + refine: jest.fn() +} as unknown as SearchBoxRenderState + +const infiniteHits = { + items: videoItems, + showMore: jest.fn(), + isLastPage: false +} as unknown as InfiniteHitsRenderState + +const instantSearch = { + status: 'idle', + results: { + __isArtificial: false, + nbHits: videoItems.length + } +} as unknown as InstantSearchApi + const mockedUseRouter = useRouter as jest.MockedFunction describe('Editor', () => { @@ -109,6 +163,10 @@ describe('Editor', () => { beforeEach(() => { mockReactFlow() + mockUseSearchBox.mockReturnValue(searchBox) + mockUseInstantSearch.mockReturnValue(instantSearch) + mockUseInfiniteHits.mockReturnValue(infiniteHits) + mockedUseRouter.mockReturnValue({ query: { journeyId: journey.id } } as unknown as NextRouter) @@ -156,6 +214,22 @@ describe('Editor', () => { expect(screen.getByTestId('Fab')).toBeInTheDocument() }) + it('should render the AiEditButton', () => { + render( + + + + + + + + + + ) + + expect(screen.getByTestId('AiEditButton')).toBeInTheDocument() + }) + it('should set the selected step', async () => { const withTypographyBlock: Journey = { ...journey, diff --git a/apps/journeys-admin/src/components/Editor/Editor.tsx b/apps/journeys-admin/src/components/Editor/Editor.tsx index e51055ea99b..f9ef73f4789 100644 --- a/apps/journeys-admin/src/components/Editor/Editor.tsx +++ b/apps/journeys-admin/src/components/Editor/Editor.tsx @@ -6,11 +6,13 @@ import { TreeBlock } from '@core/journeys/ui/block' import { EditorProvider, EditorState } from '@core/journeys/ui/EditorProvider' import { JourneyProvider } from '@core/journeys/ui/JourneyProvider' import { transformer } from '@core/journeys/ui/transformer' +import { useFlags } from '@core/shared/ui/FlagsProvider' import { BlockFields_StepBlock as StepBlock } from '../../../__generated__/BlockFields' import { GetJourney_journey as Journey } from '../../../__generated__/GetJourney' import { MuxVideoUploadProvider } from '../MuxVideoUploadProvider' +import { AiEditButton } from './AiEditButton' import { Fab } from './Fab' import { FontLoader } from './FontLoader/FontLoader' import { Hotkeys } from './Hotkeys' @@ -35,6 +37,7 @@ export function Editor({ initialState, user }: EditorProps): ReactElement { + const { aiEditButton } = useFlags() const steps = journey != null ? (transformer(journey.blocks ?? []) as Array>) @@ -66,6 +69,7 @@ export function Editor({ + {aiEditButton && } diff --git a/apps/journeys-admin/src/libs/ai/chatRouteUtils.ts b/apps/journeys-admin/src/libs/ai/chatRouteUtils.ts new file mode 100644 index 00000000000..5c213a28864 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/chatRouteUtils.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' + +export function errorHandler(error: unknown): string { + if (error == null) { + return 'unknown error' + } + + if (typeof error === 'string') { + return error + } + + if (error instanceof Error) { + return error.message + } + + return JSON.stringify(error) +} + +export const messagesSchema = z.array( + z.object({ + role: z.enum(['system', 'user', 'assistant']), + content: z.string() + }) +) + +export type Messages = z.infer diff --git a/apps/journeys-admin/src/libs/ai/langfuse/client.ts b/apps/journeys-admin/src/libs/ai/langfuse/client.ts new file mode 100644 index 00000000000..a39051b8b6b --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/langfuse/client.ts @@ -0,0 +1,6 @@ +import { LangfuseWeb } from 'langfuse' + +export const langfuseWeb = new LangfuseWeb({ + publicKey: process.env.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY ?? '', + baseUrl: process.env.NEXT_PUBLIC_LANGFUSE_BASE_URL ?? '' +}) diff --git a/apps/journeys-admin/src/libs/ai/langfuse/server.ts b/apps/journeys-admin/src/libs/ai/langfuse/server.ts new file mode 100644 index 00000000000..93d2d46f423 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/langfuse/server.ts @@ -0,0 +1,16 @@ +import { Langfuse } from 'langfuse' +import { LangfuseExporter } from 'langfuse-vercel' + +export const langfuseEnvironment = + process.env.VERCEL_ENV ?? + process.env.DD_ENV ?? + process.env.NODE_ENV ?? + 'development' + +export const langfuseExporter = new LangfuseExporter({ + environment: langfuseEnvironment +}) + +export const langfuse = new Langfuse({ + environment: langfuseEnvironment +}) diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/generateImage.ts b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/generateImage.ts new file mode 100644 index 00000000000..f0d021484fa --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/generateImage.ts @@ -0,0 +1,109 @@ +import { createVertex } from '@ai-sdk/google-vertex' +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { experimental_generateImage as generateImage, tool } from 'ai' +import { encode } from 'blurhash' +import sharp from 'sharp' +import { z } from 'zod' + +import { ToolOptions } from '../..' + +import { upload } from './upload' + +const vertex = createVertex({ + project: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!, + googleAuthOptions: { + credentials: { + client_email: process.env.PRIVATE_FIREBASE_CLIENT_EMAIL!, + private_key: process.env.PRIVATE_FIREBASE_PRIVATE_KEY! + } + } +}) + +export function agentGenerateImage( + client: ApolloClient, + _options: ToolOptions +) { + return tool({ + description: + 'Generate an image or collection of images. It returns an array of images URLs', + parameters: z.object({ + prompt: z.string().describe('The prompt to generate the image from'), + n: z + .number() + .optional() + .default(1) + .describe( + 'The number of images to generate. Should be 1 unless you want to provide an array of images for the user to select from.' + ), + aspectRatio: z + .string() + .optional() + .default('9:16') + .describe( + 'The aspect ratio of the image. Should be in the format "width:height" in lowest common denominator format. If generating a background image, use 9:16.' + ) + }), + execute: async ({ prompt, n, aspectRatio }) => { + try { + const { images } = await generateImage({ + model: vertex.image('imagen-3.0-fast-generate-001'), + prompt: `Do not put any text on the image.\n${prompt}`, + n, + aspectRatio: aspectRatio as `${number}:${number}` + }) + + const result = await Promise.all( + images.map(async (image) => { + const sharpImage = sharp(image.uint8Array) + const metadata = await sharpImage.metadata() + const { width, height, format } = metadata + if (width == null || height == null || !format) + throw new Error('Invalid image dimensions or format') + + let outputBuffer: Buffer + switch (format) { + case 'jpeg': + outputBuffer = await sharpImage.jpeg().toBuffer() + break + case 'png': + outputBuffer = await sharpImage.png().toBuffer() + break + case 'webp': + outputBuffer = await sharpImage.webp().toBuffer() + break + default: + // fallback to PNG if format is unknown + outputBuffer = await sharpImage.png().toBuffer() + break + } + + const imageResponse = await upload( + client, + new Uint8Array(outputBuffer) + ) + if (imageResponse.success) { + const rawBuffer = await sharpImage.raw().ensureAlpha().toBuffer() + return { + url: imageResponse.src, + width, + height, + blurHash: encode( + new Uint8ClampedArray(rawBuffer), + width, + height, + 4, + 4 + ) + } + } + return null + }) + ) + + return result.filter((image) => image != null) + } catch (error) { + return `Error generating image: ${error}` + } + } + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/index.ts b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/index.ts new file mode 100644 index 00000000000..fc6fa6dc6af --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/index.ts @@ -0,0 +1 @@ +export { agentGenerateImage } from './generateImage' diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/upload/index.ts b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/upload/index.ts new file mode 100644 index 00000000000..48a220b6039 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/upload/index.ts @@ -0,0 +1 @@ +export { upload } from './upload' diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/upload/upload.spec.ts b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/upload/upload.spec.ts new file mode 100644 index 00000000000..64decfcb18b --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/upload/upload.spec.ts @@ -0,0 +1,357 @@ +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' + +import { AiCreateCloudflareUploadByFileMutation } from '../../../../../../../__generated__/AiCreateCloudflareUploadByFileMutation' + +import { AI_CREATE_CLOUDFLARE_UPLOAD_BY_FILE, upload } from './upload' + +describe('upload', () => { + let mockClient: ApolloClient + const originalFetch = global.fetch + const originalEnv = process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY + + beforeEach(() => { + mockClient = { + mutate: jest.fn() + } as unknown as ApolloClient + + global.fetch = jest.fn() + process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY = 'test-cloudflare-key' + }) + + afterEach(() => { + jest.clearAllMocks() + jest.restoreAllMocks() + }) + + afterAll(() => { + global.fetch = originalFetch + process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY = originalEnv + }) + + describe('Successful Upload Flow Tests', () => { + it('should successfully upload Uint8Array and return success response', async () => { + const mockMutationResponse: { + data: AiCreateCloudflareUploadByFileMutation + } = { + data: { + createCloudflareUploadByFile: { + __typename: 'CloudflareImage', + id: 'test-upload-id', + uploadUrl: 'https://api.cloudflare.com/test-upload-url' + } + } + } + + const mockFetchResponse = { + ok: true + } + + ;(mockClient.mutate as jest.Mock).mockResolvedValue(mockMutationResponse) + ;(global.fetch as jest.Mock).mockResolvedValue(mockFetchResponse) + + const testUint8Array = new Uint8Array([1, 2, 3, 4]) + const result = await upload(mockClient, testUint8Array) + + expect(mockClient.mutate).toHaveBeenCalledWith({ + mutation: AI_CREATE_CLOUDFLARE_UPLOAD_BY_FILE + }) + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.cloudflare.com/test-upload-url', + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData) + }) + ) + + expect(result).toEqual({ + src: 'https://imagedelivery.net/test-cloudflare-key/test-upload-id/public', + success: true + }) + }) + + it('should call Apollo Client mutation with correct parameters', async () => { + const mockMutationResponse: { + data: AiCreateCloudflareUploadByFileMutation + } = { + data: { + createCloudflareUploadByFile: { + __typename: 'CloudflareImage', + id: 'test-upload-id-2', + uploadUrl: 'https://api.cloudflare.com/test-upload-url-2' + } + } + } + + const mockFetchResponse = { + ok: true + } + + ;(mockClient.mutate as jest.Mock).mockResolvedValue(mockMutationResponse) + ;(global.fetch as jest.Mock).mockResolvedValue(mockFetchResponse) + + const testUint8Array = new Uint8Array([5, 6, 7, 8]) + await upload(mockClient, testUint8Array) + + expect(mockClient.mutate).toHaveBeenCalledTimes(1) + expect(mockClient.mutate).toHaveBeenCalledWith({ + mutation: AI_CREATE_CLOUDFLARE_UPLOAD_BY_FILE + }) + }) + + it('should create FormData with Uint8Array blob', async () => { + const mockMutationResponse: { + data: AiCreateCloudflareUploadByFileMutation + } = { + data: { + createCloudflareUploadByFile: { + __typename: 'CloudflareImage', + id: 'test-upload-id-3', + uploadUrl: 'https://api.cloudflare.com/test-upload-url-3' + } + } + } + + const mockFetchResponse = { + ok: true + } + + ;(mockClient.mutate as jest.Mock).mockResolvedValue(mockMutationResponse) + ;(global.fetch as jest.Mock).mockResolvedValue(mockFetchResponse) + + const testUint8Array = new Uint8Array([9, 10, 11, 12]) + await upload(mockClient, testUint8Array) + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0] + const [url, options] = fetchCall + const formData = options.body + + expect(formData).toBeInstanceOf(FormData) + expect(url).toBe('https://api.cloudflare.com/test-upload-url-3') + expect(options.method).toBe('POST') + + const fileEntry = formData.get('file') + expect(fileEntry).toBeInstanceOf(Blob) + + if (fileEntry instanceof Blob) { + expect(fileEntry.size).toBe(testUint8Array.length) + + // Verify actual byte content matches input + const arrayBuffer = await fileEntry.arrayBuffer() + const resultUint8Array = new Uint8Array(arrayBuffer) + expect(resultUint8Array).toEqual(testUint8Array) + } + }) + + it('should make fetch request with correct upload URL and method', async () => { + const testUploadUrl = + 'https://custom.cloudflare.com/unique-upload-endpoint' + const mockMutationResponse: { + data: AiCreateCloudflareUploadByFileMutation + } = { + data: { + createCloudflareUploadByFile: { + __typename: 'CloudflareImage', + id: 'test-upload-id-4', + uploadUrl: testUploadUrl + } + } + } + + const mockFetchResponse = { + ok: true + } + + ;(mockClient.mutate as jest.Mock).mockResolvedValue(mockMutationResponse) + ;(global.fetch as jest.Mock).mockResolvedValue(mockFetchResponse) + + const testUint8Array = new Uint8Array([13, 14, 15, 16]) + await upload(mockClient, testUint8Array) + + expect(global.fetch).toHaveBeenCalledWith( + testUploadUrl, + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData) + }) + ) + }) + + it('should return correct src using environment variable and id', async () => { + const testId = 'unique-cloudflare-id' + const mockMutationResponse: { + data: AiCreateCloudflareUploadByFileMutation + } = { + data: { + createCloudflareUploadByFile: { + __typename: 'CloudflareImage', + id: testId, + uploadUrl: 'https://api.cloudflare.com/test-upload-url-5' + } + } + } + + const mockFetchResponse = { + ok: true + } + + ;(mockClient.mutate as jest.Mock).mockResolvedValue(mockMutationResponse) + ;(global.fetch as jest.Mock).mockResolvedValue(mockFetchResponse) + + const testUint8Array = new Uint8Array([17, 18, 19, 20]) + const result = await upload(mockClient, testUint8Array) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.src).toBe( + `https://imagedelivery.net/${process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY}/${testId}/public` + ) + } + }) + }) + + describe('Apollo Client Error Tests', () => { + it('should handle Apollo Client mutation failure', async () => { + const mockError = new Error('GraphQL network error') + ;(mockClient.mutate as jest.Mock).mockRejectedValue(mockError) + + const testUint8Array = new Uint8Array([1, 2, 3, 4]) + const result = await upload(mockClient, testUint8Array) + expect(result).toEqual({ + errorMessage: mockError.message, + success: false + }) + + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('should handle missing/null uploadUrl in response', async () => { + const mockMutationResponse: { + data: AiCreateCloudflareUploadByFileMutation + } = { + data: { + createCloudflareUploadByFile: { + __typename: 'CloudflareImage', + id: 'test-id', + uploadUrl: null + } + } + } + + const mockError = new Error('Failed to get upload URL') + + ;(mockClient.mutate as jest.Mock).mockResolvedValue(mockMutationResponse) + + const testUint8Array = new Uint8Array([5, 6, 7, 8]) + const result = await upload(mockClient, testUint8Array) + + expect(mockClient.mutate).toHaveBeenCalledWith({ + mutation: AI_CREATE_CLOUDFLARE_UPLOAD_BY_FILE + }) + + expect(result).toEqual({ + errorMessage: mockError.message, + success: false + }) + + expect(global.fetch).not.toHaveBeenCalled() + }) + }) + + describe('Fetch/Upload Error Tests', () => { + it('should handle fetch network errors', async () => { + const mockMutationResponse: { + data: AiCreateCloudflareUploadByFileMutation + } = { + data: { + createCloudflareUploadByFile: { + __typename: 'CloudflareImage', + id: 'test-upload-id', + uploadUrl: 'https://api.cloudflare.com/test-upload-url' + } + } + } + + const mockError = new Error('Network connection failed') + + ;(mockClient.mutate as jest.Mock).mockResolvedValue(mockMutationResponse) + ;(global.fetch as jest.Mock).mockRejectedValue(mockError) + + const testUint8Array = new Uint8Array([1, 2, 3, 4]) + const result = await upload(mockClient, testUint8Array) + + expect(mockClient.mutate).toHaveBeenCalledWith({ + mutation: AI_CREATE_CLOUDFLARE_UPLOAD_BY_FILE + }) + + expect(result).toEqual({ + errorMessage: mockError.message, + success: false + }) + }) + + it('should handle non-ok fetch responses (404, 500, etc.)', async () => { + const mockMutationResponse: { + data: AiCreateCloudflareUploadByFileMutation + } = { + data: { + createCloudflareUploadByFile: { + __typename: 'CloudflareImage', + id: 'test-upload-id-2', + uploadUrl: 'https://api.cloudflare.com/test-upload-url-2' + } + } + } + + const mockFetchResponse = { + ok: false, + status: 500, + statusText: 'Internal Server Error' + } + + const mockError = new Error('Failed to upload image to Cloudflare') + + ;(mockClient.mutate as jest.Mock).mockResolvedValue(mockMutationResponse) + ;(global.fetch as jest.Mock).mockResolvedValue(mockFetchResponse) + + const testUint8Array = new Uint8Array([5, 6, 7, 8]) + const result = await upload(mockClient, testUint8Array) + + expect(mockClient.mutate).toHaveBeenCalledWith({ + mutation: AI_CREATE_CLOUDFLARE_UPLOAD_BY_FILE + }) + + expect(result).toEqual({ + errorMessage: mockError.message, + success: false + }) + }) + + it('should handle non-Error exceptions and use fallback error message', async () => { + const mockMutationResponse: { + data: AiCreateCloudflareUploadByFileMutation + } = { + data: { + createCloudflareUploadByFile: { + __typename: 'CloudflareImage', + id: 'test-upload-id-3', + uploadUrl: 'https://api.cloudflare.com/test-upload-url-3' + } + } + } + + const mockNonErrorException = 'Network timeout' + + ;(mockClient.mutate as jest.Mock).mockResolvedValue(mockMutationResponse) + ;(global.fetch as jest.Mock).mockRejectedValue(mockNonErrorException) + + const testUint8Array = new Uint8Array([9, 10, 11, 12]) + const result = await upload(mockClient, testUint8Array) + + expect(result).toEqual({ + errorMessage: 'Failed to upload image to Cloudflare', + success: false + }) + }) + }) +}) diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/upload/upload.ts b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/upload/upload.ts new file mode 100644 index 00000000000..e8247e36818 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/generateImage/upload/upload.ts @@ -0,0 +1,88 @@ +import { gql } from '@apollo/client' +import type { ApolloClient, NormalizedCacheObject } from '@apollo/client' + +import { AiCreateCloudflareUploadByFileMutation } from '../../../../../../../__generated__/AiCreateCloudflareUploadByFileMutation' + +// Reuse the same mutation as in the ImageUpload component +export const AI_CREATE_CLOUDFLARE_UPLOAD_BY_FILE = gql` + mutation AiCreateCloudflareUploadByFileMutation { + createCloudflareUploadByFile { + id + uploadUrl + } + } +` + +interface UploadGeneratedImageResponseSuccess { + src: string + success: true +} + +interface UploadGeneratedImageResponseError { + errorMessage: string + success: false +} + +type UploadGeneratedImageResponse = + | UploadGeneratedImageResponseSuccess + | UploadGeneratedImageResponseError + +/** + * Uploads a Uint8Array image to Cloudflare Images via the API + * + * @param client - Apollo client instance + * @param uint8Array - Uint8Array of the image + * @returns Object with src URL and success status + */ +export async function upload( + client: ApolloClient, + uint8Array: Uint8Array +): Promise { + try { + // Get upload URL from Cloudflare + const { data } = + await client.mutate({ + mutation: AI_CREATE_CLOUDFLARE_UPLOAD_BY_FILE + }) + + if (!data?.createCloudflareUploadByFile?.uploadUrl) { + throw new Error('Failed to get upload URL') + } + + // // Create form data + const formData = new FormData() + formData.append('file', new Blob([uint8Array])) + + // // Upload the file to Cloudflare + const response = await fetch(data.createCloudflareUploadByFile.uploadUrl, { + method: 'POST', + body: formData + }) + + if (!response.ok) { + throw new Error('Failed to upload image to Cloudflare') + } + + // Construct the image URL + const uploadId = data.createCloudflareUploadByFile.id + const src = `https://imagedelivery.net/${ + process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY ?? '' + }/${uploadId}/public` + + return { + src, + success: true + } + } catch (error) { + if (error instanceof Error) { + return { + errorMessage: error.message, + success: false + } + } + return { + errorMessage: 'Failed to upload image to Cloudflare', + success: false + } + } +} diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/index.ts b/apps/journeys-admin/src/libs/ai/tools/agent/index.ts new file mode 100644 index 00000000000..9cf94c8af27 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/index.ts @@ -0,0 +1,9 @@ +import { agentGenerateImage } from './generateImage' +import { agentInternalVideoSearch } from './internalVideoSearch' +import { agentWebSearch } from './webSearch' + +export const tools = { + agentGenerateImage, + agentInternalVideoSearch, + agentWebSearch +} diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/internalVideoSearch/index.ts b/apps/journeys-admin/src/libs/ai/tools/agent/internalVideoSearch/index.ts new file mode 100644 index 00000000000..2cee88074d8 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/internalVideoSearch/index.ts @@ -0,0 +1 @@ +export { agentInternalVideoSearch } from './internalVideoSearch' diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/internalVideoSearch/internalVideoSearch.ts b/apps/journeys-admin/src/libs/ai/tools/agent/internalVideoSearch/internalVideoSearch.ts new file mode 100644 index 00000000000..6970b875705 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/internalVideoSearch/internalVideoSearch.ts @@ -0,0 +1,136 @@ +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { Tool, tool } from 'ai' +import { SearchClient, algoliasearch } from 'algoliasearch' +import { z } from 'zod' + +import { ToolOptions } from '../..' + +interface AlgoliaVideoHit { + videoId: string + titles: string[] + description: string[] + duration: number + languageId: string + languageEnglishName: string + languagePrimaryName: string + subtitles: string[] + slug: string + label: string + image: string + imageAlt: string + childrenCount: number + objectID: string +} + +interface AlgoliaSearchResponse { + hits: T[] +} + +async function initAlgoliaClient(): Promise { + const appID = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID + + // NOTE: For local development use the new api key instead of the default doppler key + // Lookup the key value in algolia with description: "Dev key for video-variant-stg - AI tools and Docker testing with open referer policy" + // Update NEXT_PUBLIC_ALGOLIA_API_KEY in .env to the new key value + const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_API_KEY + + if (!appID || !apiKey) { + throw new Error('Algolia environment variables are not set') + } + + return algoliasearch(appID, apiKey) +} + +async function searchVideosAlgolia( + term: string, + client: SearchClient, + limit: number = 20 +): Promise { + const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX + if (!indexName) { + throw new Error('Algolia videos index environment variable is not set') + } + + const response = (await client.searchSingleIndex({ + indexName, + searchParams: { + query: term, + hitsPerPage: limit + } + })) as AlgoliaSearchResponse + + if (!response.hits) { + return [] + } + + return response.hits +} + +function transformVideoHits(hits: AlgoliaVideoHit[]) { + return hits.map((hit) => ({ + videoId: hit.videoId, + title: hit.titles[0] || 'Untitled', + description: hit.description[0] || '', + duration: hit.duration, + language: { + id: hit.languageId, + englishName: hit.languageEnglishName, + primaryName: hit.languagePrimaryName + }, + slug: hit.slug, + label: hit.label, + image: hit.image, + imageAlt: hit.imageAlt, + childrenCount: hit.childrenCount, + subtitles: hit.subtitles + })) +} + +export function agentInternalVideoSearch( + _client: ApolloClient, + _options: ToolOptions +): Tool { + return tool({ + description: + 'Search for internal videos using Algolia. Returns video metadata with videoId for card block references.', + parameters: z.object({ + searchTerm: z.string().describe('The search term to find internal videos. Can be a title, description, or any text to search for.'), + limit: z + .number() + .optional() + .default(20) + .describe('Maximum number of results to return (default: 20)') + }), + execute: async ({ searchTerm, limit = 20 }) => { + try { + const normalizedLimit = + typeof limit === 'number' && Number.isFinite(limit) + ? Math.floor(limit) + : 20 + const validatedLimit = Math.max(normalizedLimit, 1) + const client = await initAlgoliaClient() + const hits = await searchVideosAlgolia( + searchTerm, + client, + validatedLimit + ) + const results = transformVideoHits(hits) + + return { + success: true, + count: results.length, + searchTerm, + results + } + } catch (error) { + console.log('Error searching internal videos:', error) + return { + success: false, + error: + error instanceof Error ? error.message : 'Unknown error occurred', + searchTerm + } + } + } + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/webSearch/index.ts b/apps/journeys-admin/src/libs/ai/tools/agent/webSearch/index.ts new file mode 100644 index 00000000000..0dead98119a --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/webSearch/index.ts @@ -0,0 +1 @@ +export { agentWebSearch } from './webSearch' diff --git a/apps/journeys-admin/src/libs/ai/tools/agent/webSearch/webSearch.ts b/apps/journeys-admin/src/libs/ai/tools/agent/webSearch/webSearch.ts new file mode 100644 index 00000000000..2e4b001b269 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/agent/webSearch/webSearch.ts @@ -0,0 +1,119 @@ +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import FirecrawlApp from '@mendable/firecrawl-js' +import { Tool, tool } from 'ai' +import { z } from 'zod' + +import { ToolOptions } from '../..' + +const imageSchema = z.object({ + url: z.string().describe('The URL of the image.'), + alt: z.string().describe('The alt text of the image.'), + width: z.number().describe('The width of the image.'), + height: z.number().describe('The height of the image.') +}) + +const schema = z.object({ + title: z.string().describe('The title of the website.'), + description: z.string().describe('The description of the website.'), + url: z.string().describe('The main URL of the website.'), + colorPalette: z + .array( + z.object({ + name: z + .string() + .describe( + 'The name of the color and its purpose on the website. E.g. "primary" or "background".' + ), + hex: z.string().describe('The hex code of the color.') + }) + ) + .describe( + 'The color palette of the website. Should be a number of colors that are used on the website.' + ), + keyLinks: z + .array( + z.object({ + url: z.string().describe('The URL of the key link.'), + title: z.string().describe('The title of the key link.') + }) + ) + .describe('The key links of the website.'), + content: z + .array(z.string().describe('The content of the page in markdown format.')) + .describe('A number of scraped pages from the website in markdown format.'), + logo: imageSchema.describe( + 'The logo of the website. Is often found in the header of the website.' + ), + images: z.array(imageSchema).describe('The images available on the website.') +}) + +export function agentWebSearch( + _client: ApolloClient, + _options: ToolOptions +): Tool { + return tool({ + parameters: z.object({ + searchQuery: z + .string() + .describe( + 'The query to search the web for. Will find a number of websites to scrape. Only provide searchQuery or url, not both.' + ) + .optional(), + url: z + .string() + .describe( + 'If you already have a URL to scrape, you can provide it here. Otherwise, the agent will search the web for a number of websites to scrape. Only provide url or searchQuery, not both.' + ) + .optional(), + prompt: z + .string() + .describe( + 'The prompt to use to scrape the website. Should direct the agent on what elements to focus on.' + ) + }), + execute: async ({ searchQuery, url, prompt }) => { + if (searchQuery && url) { + throw new Error('Only provide searchQuery or url, not both.') + } + + if (!searchQuery && !url) { + throw new Error('Either searchQuery or url must be provided.') + } + + const app = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY }) + + let urlToScrape = url + + if (urlToScrape == null && searchQuery != null) { + const searchResult = await app.search(searchQuery, { + limit: 1, + maxAge: 3600000 // 1 hour in milliseconds + }) + + if (!searchResult.success) { + throw new Error(`Failed to search: ${searchResult.error}`) + } + + urlToScrape = searchResult.data[0]?.url + } + + if (urlToScrape == null) { + throw new Error('No results found') + } + + const extractResult = await app.extract( + [`${urlToScrape.replace(/\/$/, '')}/*`], + { + prompt, + schema + } + ) + + if (!extractResult.success) { + throw new Error(`Failed to extract: ${extractResult.error}`) + } + + return extractResult.data + } + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/client/index.ts b/apps/journeys-admin/src/libs/ai/tools/client/index.ts new file mode 100644 index 00000000000..19bc3a4f80e --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/index.ts @@ -0,0 +1,14 @@ +// import { generateImage } from './generateImage' +import { clientRedirectUserToEditor } from './redirectUserToEditor' +import { clientRequestForm } from './requestForm' +import { clientSelectImage } from './selectImage' +import { clientSelectVideo } from './selectVideo' + +export const tools = { + clientSelectImage, + clientSelectVideo, + // TODO: Uncomment this when we have solved image upload issues + // generateImage, + clientRedirectUserToEditor, + clientRequestForm +} diff --git a/apps/journeys-admin/src/libs/ai/tools/client/redirectUserToEditor/index.ts b/apps/journeys-admin/src/libs/ai/tools/client/redirectUserToEditor/index.ts new file mode 100644 index 00000000000..1ccb03b193d --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/redirectUserToEditor/index.ts @@ -0,0 +1 @@ +export { clientRedirectUserToEditor } from './redirectUserToEditor' diff --git a/apps/journeys-admin/src/libs/ai/tools/client/redirectUserToEditor/redirectUserToEditor.spec.ts b/apps/journeys-admin/src/libs/ai/tools/client/redirectUserToEditor/redirectUserToEditor.spec.ts new file mode 100644 index 00000000000..23ab5e09219 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/redirectUserToEditor/redirectUserToEditor.spec.ts @@ -0,0 +1,87 @@ +import { z } from 'zod' + +import { clientRedirectUserToEditor } from './redirectUserToEditor' + +describe('clientRedirectUserToEditor', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return a tool with correct description, parameters, and descriptions', () => { + const tool = clientRedirectUserToEditor() + + expect(tool.description).toBe('Redirect the user to the editor.') + expect(tool.parameters).toBeInstanceOf(z.ZodObject) + + const parametersShape = tool.parameters.shape as { + message: z.ZodTypeAny + journeyId: z.ZodTypeAny + } + + // Test parameter types + expect(parametersShape.message).toBeInstanceOf(z.ZodString) + expect(parametersShape.journeyId).toBeInstanceOf(z.ZodString) + + // Test parameter descriptions + expect(parametersShape.message.description).toBe( + 'The message to let the user know they can see their journey by clicking the button below and inform them it takes them to the editor.' + ) + expect(parametersShape.journeyId.description).toBe( + 'The id of the journey to redirect to.' + ) + }) + + it('should validate correct input successfully', () => { + const tool = clientRedirectUserToEditor() + const input = { + message: 'Click below to see your journey in the editor', + journeyId: 'journey-456' + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(true) + }) + + it('should fail validation if required fields are missing', () => { + const tool = clientRedirectUserToEditor() + const input = {} + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('message') + expect(issues).toContain('journeyId') + } + }) + + it('should fail validation when message is not a string', () => { + const tool = clientRedirectUserToEditor() + const input = { + message: true, + journeyId: 'journey-456' + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('message') + } + }) + + it('should fail validation when journeyId is not a string', () => { + const tool = clientRedirectUserToEditor() + const input = { + message: 'Click below to see your journey', + journeyId: null + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('journeyId') + } + }) +}) diff --git a/apps/journeys-admin/src/libs/ai/tools/client/redirectUserToEditor/redirectUserToEditor.ts b/apps/journeys-admin/src/libs/ai/tools/client/redirectUserToEditor/redirectUserToEditor.ts new file mode 100644 index 00000000000..4067ccdf5aa --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/redirectUserToEditor/redirectUserToEditor.ts @@ -0,0 +1,16 @@ +import { Tool, tool } from 'ai' +import { z } from 'zod' + +export function clientRedirectUserToEditor(): Tool { + return tool({ + description: 'Redirect the user to the editor.', + parameters: z.object({ + message: z + .string() + .describe( + 'The message to let the user know they can see their journey by clicking the button below and inform them it takes them to the editor.' + ), + journeyId: z.string().describe('The id of the journey to redirect to.') + }) + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/client/requestForm/index.ts b/apps/journeys-admin/src/libs/ai/tools/client/requestForm/index.ts new file mode 100644 index 00000000000..a0862a02079 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/requestForm/index.ts @@ -0,0 +1 @@ +export { clientRequestForm } from './requestForm' diff --git a/apps/journeys-admin/src/libs/ai/tools/client/requestForm/requestForm.spec.ts b/apps/journeys-admin/src/libs/ai/tools/client/requestForm/requestForm.spec.ts new file mode 100644 index 00000000000..6da6404a8fe --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/requestForm/requestForm.spec.ts @@ -0,0 +1,175 @@ +import { z } from 'zod' + +import { clientRequestForm, formItemSchema } from './requestForm' + +describe('RequestForm', () => { + describe('clientRequestForm', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return a tool with correct description, parameters, and descriptions', () => { + const tool = clientRequestForm() + + expect(tool.description).toBe('Ask the user to fill out a form.') + expect(tool.parameters).toBeInstanceOf(z.ZodObject) + + const parametersShape = tool.parameters.shape + + expect(parametersShape.formItems).toBeInstanceOf(z.ZodArray) + expect(parametersShape.formItems._def.type).toBe(formItemSchema) + + expect(parametersShape.formItems.description).toBe( + 'Array of form items to be filled out by the user.' + ) + }) + + it('should validate correct input successfully', () => { + const tool = clientRequestForm() + const input = { + formItems: [ + { + type: 'text', + name: 'organizationName', + label: 'Organization Name', + required: true, + placeholder: 'Enter your organization name', + helperText: 'The name of your church or ministry' + }, + { + type: 'select', + name: 'eventType', + label: 'Event Type', + required: false, + helperText: 'What type of event is this?', + options: [ + { label: 'Conference', value: 'conference' }, + { label: 'Workshop', value: 'workshop' } + ] + } + ] + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(true) + }) + + it('should fail validation if formItems is missing', () => { + const tool = clientRequestForm() + const input = {} + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('formItems') + } + }) + + it('should fail validation when formItems is not an array', () => { + const tool = clientRequestForm() + const input = { + formItems: 'not-an-array' + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('formItems') + } + }) + + it('should fail validation for invalid form item structure', () => { + const tool = clientRequestForm() + const input = { + formItems: [ + { + // Missing required fields: type, name, label, helperText + placeholder: 'Some placeholder' + } + ] + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path.join('.')) + expect(issues.some((issue) => issue.includes('type'))).toBe(true) + expect(issues.some((issue) => issue.includes('name'))).toBe(true) + expect(issues.some((issue) => issue.includes('label'))).toBe(true) + expect(issues.some((issue) => issue.includes('helperText'))).toBe(true) + } + }) + + it('should validate form item with all optional fields', () => { + const tool = clientRequestForm() + const input = { + formItems: [ + { + type: 'select', + name: 'contactEmail', + label: 'Contact Email', + required: true, + placeholder: 'contact@example.com', + suggestion: 'admin@church.org', + helperText: 'Primary contact email for your organization', + options: [ + { label: 'Work Email', value: 'work@church.org' }, + { label: 'Admin Email', value: 'admin@church.org' } + ] + } + ] + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(true) + }) + }) + + describe('formItemSchema', () => { + it('should validate a minimal form item', () => { + const input = { + type: 'text', + name: 'testField', + label: 'Test Field', + helperText: 'This is a test field' + } + + const result = formItemSchema.safeParse(input) + expect(result.success).toBe(true) + }) + + it('should fail validation for invalid form item type', () => { + const input = { + type: 'invalid-type', + name: 'testField', + label: 'Test Field', + helperText: 'This is a test field' + } + + const result = formItemSchema.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('type') + } + }) + + it('should validate form item with options for select type', () => { + const input = { + type: 'select', + name: 'category', + label: 'Category', + helperText: 'Select a category', + options: [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2' } + ] + } + + const result = formItemSchema.safeParse(input) + expect(result.success).toBe(true) + }) + }) +}) diff --git a/apps/journeys-admin/src/libs/ai/tools/client/requestForm/requestForm.ts b/apps/journeys-admin/src/libs/ai/tools/client/requestForm/requestForm.ts new file mode 100644 index 00000000000..806d54e24fd --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/requestForm/requestForm.ts @@ -0,0 +1,62 @@ +import { Tool, tool } from 'ai' +import { z } from 'zod' + +export const formItemSchema = z.object({ + type: z + .enum([ + 'text', + 'number', + 'select', + 'checkbox', + 'radio', + 'textarea', + 'email', + 'tel', + 'url' + ]) + .describe('The type of the form field.'), + name: z.string().describe('The unique name/key for the form field.'), + label: z + .string() + .describe( + 'The label to display for the form field. Label should be a short name for the field.' + ), + required: z.boolean().optional().describe('Whether the field is required.'), + placeholder: z + .string() + .optional() + .describe( + 'Placeholder text for the field. Placeholder must be less than 80 characters.' + ), + suggestion: z + .string() + .optional() + .describe( + 'A suggested value for the field, if the AI thinks it might know the answer.' + ), + helperText: z + .string() + .describe('Helper text to show below the field for additional guidance.'), + options: z + .array( + z.object({ + label: z.string().describe('The label for the option.'), + value: z.string().describe('The value for the option.') + }) + ) + .optional() + .describe('Options for select, radio, or checkbox fields.') + // Validation rules for specific types (for documentation, not enforced here) + // Actual validation will be handled in the UI using Zod +}) + +export function clientRequestForm(): Tool { + return tool({ + description: 'Ask the user to fill out a form.', + parameters: z.object({ + formItems: z + .array(formItemSchema) + .describe('Array of form items to be filled out by the user.') + }) + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/client/selectImage/index.ts b/apps/journeys-admin/src/libs/ai/tools/client/selectImage/index.ts new file mode 100644 index 00000000000..e0eed0da205 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/selectImage/index.ts @@ -0,0 +1 @@ +export { clientSelectImage } from './selectImage' diff --git a/apps/journeys-admin/src/libs/ai/tools/client/selectImage/selectImage.spec.ts b/apps/journeys-admin/src/libs/ai/tools/client/selectImage/selectImage.spec.ts new file mode 100644 index 00000000000..ea84d4e7091 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/selectImage/selectImage.spec.ts @@ -0,0 +1,90 @@ +import { z } from 'zod' + +import { clientSelectImage } from './selectImage' + +describe('clientSelectImage', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return a tool with correct description, parameters, and descriptions', () => { + const tool = clientSelectImage() + + expect(tool.description).toBe('Ask the user for confirmation on an image.') + expect(tool.parameters).toBeInstanceOf(z.ZodObject) + + const parametersShape = tool.parameters.shape as { + message: z.ZodTypeAny + imageId: z.ZodTypeAny + generatedImageUrls: z.ZodTypeAny + } + + expect(parametersShape.message).toBeInstanceOf(z.ZodString) + expect(parametersShape.imageId).toBeInstanceOf(z.ZodString) + expect(parametersShape.generatedImageUrls).toBeInstanceOf(z.ZodOptional) + + expect(parametersShape.message.description).toBe( + 'The message to ask for confirmation.' + ) + expect(parametersShape.imageId.description).toBe( + 'The id of the image to select.' + ) + expect(parametersShape.generatedImageUrls.description).toBe( + 'The urls of the generated images. Pass result from AgentGenerateImage tool.' + ) + }) + + it('should validate correct input successfully', () => { + const tool = clientSelectImage() + const input = { + message: 'Do you want this image?', + imageId: 'img-123', + generatedImageUrls: ['https://example.com/image.png'] + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(true) + }) + + it('should fail validation if required fields are missing', () => { + const tool = clientSelectImage() + const input = { + generatedImageUrls: ['https://example.com/image.png'] + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('message') + expect(issues).toContain('imageId') + } + }) + + it('should allow undefined or omitted generatedImageUrls', () => { + const tool = clientSelectImage() + const input = { + message: 'Select this image?', + imageId: 'img-abc' + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(true) + }) + + it('should fail validation when generatedImageUrls is not an array', () => { + const tool = clientSelectImage() + const input = { + message: 'Select this image?', + imageId: 'img-123', + generatedImageUrls: 'not-an-array' + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('generatedImageUrls') + } + }) +}) diff --git a/apps/journeys-admin/src/libs/ai/tools/client/selectImage/selectImage.ts b/apps/journeys-admin/src/libs/ai/tools/client/selectImage/selectImage.ts new file mode 100644 index 00000000000..c8f2d7809fa --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/selectImage/selectImage.ts @@ -0,0 +1,18 @@ +import { Tool, tool } from 'ai' +import { z } from 'zod' + +export function clientSelectImage(): Tool { + return tool({ + description: 'Ask the user for confirmation on an image.', + parameters: z.object({ + message: z.string().describe('The message to ask for confirmation.'), + imageId: z.string().describe('The id of the image to select.'), + generatedImageUrls: z + .array(z.string()) + .optional() + .describe( + 'The urls of the generated images. Pass result from AgentGenerateImage tool.' + ) + }) + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/client/selectVideo/index.ts b/apps/journeys-admin/src/libs/ai/tools/client/selectVideo/index.ts new file mode 100644 index 00000000000..e1d80e2e297 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/selectVideo/index.ts @@ -0,0 +1 @@ +export { clientSelectVideo } from './selectVideo' diff --git a/apps/journeys-admin/src/libs/ai/tools/client/selectVideo/selectVideo.spec.ts b/apps/journeys-admin/src/libs/ai/tools/client/selectVideo/selectVideo.spec.ts new file mode 100644 index 00000000000..a63f57d4150 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/selectVideo/selectVideo.spec.ts @@ -0,0 +1,87 @@ +import { z } from 'zod' + +import { clientSelectVideo } from './selectVideo' + +describe('clientSelectVideo', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return a tool with correct description, parameters, and descriptions', () => { + const tool = clientSelectVideo() + + expect(tool.description).toBe('Ask the user for confirmation on a video.') + expect(tool.parameters).toBeInstanceOf(z.ZodObject) + + const parametersShape = tool.parameters.shape as { + message: z.ZodTypeAny + videoId: z.ZodTypeAny + } + + // Test parameter types + expect(parametersShape.message).toBeInstanceOf(z.ZodString) + expect(parametersShape.videoId).toBeInstanceOf(z.ZodString) + + // Test parameter descriptions + expect(parametersShape.message.description).toBe( + 'The message to ask for confirmation.' + ) + expect(parametersShape.videoId.description).toBe( + 'The id of the video to select.' + ) + }) + + it('should validate correct input successfully', () => { + const tool = clientSelectVideo() + const input = { + message: 'Do you want this video?', + videoId: 'video-123' + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(true) + }) + + it('should fail validation if required fields are missing', () => { + const tool = clientSelectVideo() + const input = {} + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('message') + expect(issues).toContain('videoId') + } + }) + + it('should fail validation when message is not a string', () => { + const tool = clientSelectVideo() + const input = { + message: 123, + videoId: 'video-123' + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('message') + } + }) + + it('should fail validation when videoId is not a string', () => { + const tool = clientSelectVideo() + const input = { + message: 'Select this video?', + videoId: 456 + } + + const result = tool.parameters.safeParse(input) + expect(result.success).toBe(false) + if (!result.success) { + const issues = result.error.issues.map((i) => i.path[0]) + expect(issues).toContain('videoId') + } + }) +}) diff --git a/apps/journeys-admin/src/libs/ai/tools/client/selectVideo/selectVideo.ts b/apps/journeys-admin/src/libs/ai/tools/client/selectVideo/selectVideo.ts new file mode 100644 index 00000000000..1e6cf3219b8 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/client/selectVideo/selectVideo.ts @@ -0,0 +1,12 @@ +import { Tool, tool } from 'ai' +import { z } from 'zod' + +export function clientSelectVideo(): Tool { + return tool({ + description: 'Ask the user for confirmation on a video.', + parameters: z.object({ + message: z.string().describe('The message to ask for confirmation.'), + videoId: z.string().describe('The id of the video to select.') + }) + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/index.ts b/apps/journeys-admin/src/libs/ai/tools/index.ts new file mode 100644 index 00000000000..900271894e3 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/index.ts @@ -0,0 +1,40 @@ +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { ToolSet } from 'ai' + +import { tools as agentTools } from './agent' +// import { tools as clientTools } from './client' +import { tools as journeyTools } from './journey' +import { tools as languageTools } from './language' +import { tools as videoSubtitleTools } from './videoSubtitle' +import { + // youTubeTranscriptTool, + youtubeAnalyzerTool +} from './youtube' + +export interface ToolOptions { + langfuseTraceId: string +} + +export function tools( + client: ApolloClient, + { langfuseTraceId }: ToolOptions +): ToolSet { + const tools = { + ...agentTools, + // ...clientTools, + ...journeyTools, + ...languageTools, + ...videoSubtitleTools, + // youTubeTranscriptTool, + youtubeAnalyzerTool + } + + return { + ...Object.fromEntries( + Object.entries(tools).map(([key, tool]) => [ + key, + tool(client, { langfuseTraceId }) + ]) + ) + } +} diff --git a/apps/journeys-admin/src/libs/ai/tools/journey/index.ts b/apps/journeys-admin/src/libs/ai/tools/journey/index.ts new file mode 100644 index 00000000000..2927124da6d --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/journey/index.ts @@ -0,0 +1,6 @@ +import { journeySimpleGet, journeySimpleUpdate } from './simple' + +export const tools = { + journeySimpleGet, + journeySimpleUpdate +} diff --git a/apps/journeys-admin/src/libs/ai/tools/journey/simple/get.spec.ts b/apps/journeys-admin/src/libs/ai/tools/journey/simple/get.spec.ts new file mode 100644 index 00000000000..61e7239fa85 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/journey/simple/get.spec.ts @@ -0,0 +1,74 @@ +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { z } from 'zod' + +import { journeySimpleSchema } from '@core/shared/ai/journeySimpleTypes' + +import { JOURNEY_SIMPLE_GET, journeySimpleGet } from './get' + +jest.mock('@apollo/client') + +describe('journeySimpleGet', () => { + let mockClient: ApolloClient + + beforeEach(() => { + mockClient = { + query: jest.fn() + } as unknown as ApolloClient + }) + + afterEach(() => { + jest.clearAllMocks() + jest.restoreAllMocks() + }) + + it('should return a tool with correct description and parameters', () => { + const tool = journeySimpleGet(mockClient, { langfuseTraceId: 'test' }) + expect(typeof tool.description).toBe('string') + expect(tool.parameters).toBeInstanceOf(z.ZodObject) + const parametersShape = tool.parameters.shape as { journeyId: z.ZodTypeAny } + expect(parametersShape.journeyId).toBeInstanceOf(z.ZodString) + }) + + it('should execute the query and return validated data on success', async () => { + const mockJourney = { + title: 'Test Journey', + description: 'A test journey', + cards: [] + } + ;(mockClient.query as jest.Mock).mockResolvedValue({ + data: { journeySimpleGet: mockJourney } + }) + const tool = journeySimpleGet(mockClient, { langfuseTraceId: 'test' }) + const result = await tool.execute!( + { journeyId: 'jid' }, + { toolCallId: 'tid', messages: [] } + ) + expect(mockClient.query).toHaveBeenCalledWith({ + query: JOURNEY_SIMPLE_GET, + variables: { id: 'jid' } + }) + expect(result).toEqual(mockJourney) + // Validate output with Zod + expect(journeySimpleSchema.safeParse(result).success).toBe(true) + }) + + it('should throw an error if the returned journey is invalid', async () => { + const invalidJourney = { foo: 'bar' } + ;(mockClient.query as jest.Mock).mockResolvedValue({ + data: { journeySimpleGet: invalidJourney } + }) + const tool = journeySimpleGet(mockClient, { langfuseTraceId: 'test' }) + await expect( + tool.execute!({ journeyId: 'jid' }, { toolCallId: 'tid', messages: [] }) + ).rejects.toThrow('Returned journey is invalid') + }) + + it('should throw an error if the query fails', async () => { + const mockError = new Error('Network error') + ;(mockClient.query as jest.Mock).mockRejectedValue(mockError) + const tool = journeySimpleGet(mockClient, { langfuseTraceId: 'test' }) + await expect( + tool.execute!({ journeyId: 'jid' }, { toolCallId: 'tid', messages: [] }) + ).rejects.toThrow('Network error') + }) +}) diff --git a/apps/journeys-admin/src/libs/ai/tools/journey/simple/get.ts b/apps/journeys-admin/src/libs/ai/tools/journey/simple/get.ts new file mode 100644 index 00000000000..0eb4def8c39 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/journey/simple/get.ts @@ -0,0 +1,73 @@ +import { ApolloClient, NormalizedCacheObject, gql } from '@apollo/client' +import { Tool, tool } from 'ai' +import { z } from 'zod' +import { zodToJsonSchema } from 'zod-to-json-schema' + +import { journeySimpleSchema } from '@core/shared/ai/journeySimpleTypes' + +import { ToolOptions } from '../..' +import { + JourneySimpleGet, + JourneySimpleGetVariables +} from '../../../../../../__generated__/JourneySimpleGet' + +/** + * Helper to generate a JSON schema description from a Zod schema + */ +function getSchemaDescription(schema: typeof journeySimpleSchema): string { + const jsonSchema = zodToJsonSchema(schema) + return JSON.stringify(jsonSchema, null, 2) +} + +// GraphQL query declaration +export const JOURNEY_SIMPLE_GET = gql` + query JourneySimpleGet($id: ID!) { + journeySimpleGet(id: $id) + } +` + +// Tool factory function for the AI tools system +export function journeySimpleGet( + client: ApolloClient, + _options: ToolOptions +): Tool { + return tool({ + description: + 'Fetches a simplified journey by ID and returns a validated JSON structure.' + + '\n\nOutput schema (auto-generated from Zod):\n' + + getSchemaDescription(journeySimpleSchema), + parameters: z.object({ + journeyId: z.string().describe('The ID of the journey to fetch.') + }), + + execute: async ({ journeyId }) => { + try { + // Call the real backend GraphQL query + const { data } = await client.query< + JourneySimpleGet, + JourneySimpleGetVariables + >({ + query: JOURNEY_SIMPLE_GET, + variables: { id: journeyId } + }) + // Validate the returned data with the Zod schema + const result = journeySimpleSchema.safeParse(data.journeySimpleGet) + if (!result.success) { + throw new Error( + 'Returned journey is invalid: ' + + JSON.stringify(result.error.format()) + ) + } + return result.data + } catch (error) { + return { + success: false, + errors: + error instanceof Error + ? [{ message: error.message }] + : [{ message: 'Invalid journey object' }] + } + } + } + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/journey/simple/index.ts b/apps/journeys-admin/src/libs/ai/tools/journey/simple/index.ts new file mode 100644 index 00000000000..4513335cb39 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/journey/simple/index.ts @@ -0,0 +1,2 @@ +export { journeySimpleGet } from './get' +export { journeySimpleUpdate } from './update' diff --git a/apps/journeys-admin/src/libs/ai/tools/journey/simple/update.spec.ts b/apps/journeys-admin/src/libs/ai/tools/journey/simple/update.spec.ts new file mode 100644 index 00000000000..e03122aa642 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/journey/simple/update.spec.ts @@ -0,0 +1,87 @@ +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { z } from 'zod' + +import { journeySimpleSchema } from '@core/shared/ai/journeySimpleTypes' + +import { JOURNEY_SIMPLE_UPDATE, journeySimpleUpdate } from './update' + +jest.mock('@apollo/client') + +describe('journeySimpleUpdate', () => { + let mockClient: ApolloClient + + beforeEach(() => { + mockClient = { + mutate: jest.fn() + } as unknown as ApolloClient + }) + + afterEach(() => { + jest.clearAllMocks() + jest.restoreAllMocks() + }) + + it('should return a tool with correct description and parameters', () => { + const tool = journeySimpleUpdate(mockClient, { langfuseTraceId: 'test' }) + expect(typeof tool.description).toBe('string') + expect(tool.parameters).toBeInstanceOf(z.ZodObject) + const parametersShape = tool.parameters.shape as { + journeyId: z.ZodTypeAny + journey: z.ZodTypeAny + } + expect(parametersShape.journeyId).toBeInstanceOf(z.ZodString) + expect(parametersShape.journey).toBeInstanceOf(z.ZodObject) + const journeySchema = parametersShape.journey as z.ZodObject + expect(journeySchema.shape).toEqual(journeySimpleSchema.shape) + }) + + it('should execute the mutation and return success true with data', async () => { + const mockJourney = { + title: 'Test Journey', + description: 'A test journey', + cards: [] + } + ;(mockClient.mutate as jest.Mock).mockResolvedValue({ + data: { journeySimpleUpdate: mockJourney }, + errors: undefined + }) + const tool = journeySimpleUpdate(mockClient, { langfuseTraceId: 'test' }) + const result = await tool.execute!( + { journeyId: 'jid', journey: mockJourney }, + { toolCallId: 'tid', messages: [] } + ) + expect(mockClient.mutate).toHaveBeenCalledWith({ + mutation: JOURNEY_SIMPLE_UPDATE, + variables: { id: 'jid', journey: mockJourney } + }) + expect(result).toEqual({ success: true, data: mockJourney }) + }) + + it('should return success false with errors if mutation returns null', async () => { + ;(mockClient.mutate as jest.Mock).mockResolvedValue({ + data: { journeySimpleUpdate: null }, + errors: [{ message: 'Some error' }] + }) + const tool = journeySimpleUpdate(mockClient, { langfuseTraceId: 'test' }) + const result = await tool.execute!( + { journeyId: 'jid', journey: {} }, + { toolCallId: 'tid', messages: [] } + ) + expect(result).toEqual({ + success: false, + errors: [{ message: 'Some error' }] + }) + }) + + it('should throw an error if the mutation fails', async () => { + const mockError = new Error('Network error') + ;(mockClient.mutate as jest.Mock).mockRejectedValue(mockError) + const tool = journeySimpleUpdate(mockClient, { langfuseTraceId: 'test' }) + await expect( + tool.execute!( + { journeyId: 'jid', journey: {} }, + { toolCallId: 'tid', messages: [] } + ) + ).rejects.toThrow('Network error') + }) +}) diff --git a/apps/journeys-admin/src/libs/ai/tools/journey/simple/update.ts b/apps/journeys-admin/src/libs/ai/tools/journey/simple/update.ts new file mode 100644 index 00000000000..b9340cfdc02 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/journey/simple/update.ts @@ -0,0 +1,58 @@ +import { ApolloClient, NormalizedCacheObject, gql } from '@apollo/client' +import { Tool, tool } from 'ai' +import { z } from 'zod' +import { zodToJsonSchema } from 'zod-to-json-schema' + +import { journeySimpleSchema } from '@core/shared/ai/journeySimpleTypes' + +import { ToolOptions } from '../..' +import { + JourneySimpleUpdate, + JourneySimpleUpdateVariables +} from '../../../../../../__generated__/JourneySimpleUpdate' + +// GraphQL mutation declaration +export const JOURNEY_SIMPLE_UPDATE = gql` + mutation JourneySimpleUpdate($id: ID!, $journey: Json!) { + journeySimpleUpdate(id: $id, journey: $journey) + } +` + +function getSchemaDescription(schema: typeof journeySimpleSchema): string { + const jsonSchema = zodToJsonSchema(schema) + return JSON.stringify(jsonSchema, null, 2) +} + +export function journeySimpleUpdate( + client: ApolloClient, + _options: ToolOptions +): Tool { + return tool({ + description: + 'Updates a journey by ID and returns the validated result.' + + '\n\nOutput schema (auto-generated from Zod):\n' + + getSchemaDescription(journeySimpleSchema), + parameters: z.object({ + journeyId: z.string().describe('The ID of the journey to update.'), + journey: journeySimpleSchema.describe( + 'The new journey object to replace the existing journey.' + ) + }), + execute: async ({ journeyId, journey }) => { + const { data, errors } = await client.mutate< + JourneySimpleUpdate, + JourneySimpleUpdateVariables + >({ + mutation: JOURNEY_SIMPLE_UPDATE, + variables: { id: journeyId, journey } + }) + if (data?.journeySimpleUpdate == null) { + return { success: false, errors } + } + return { + success: true, + data: data.journeySimpleUpdate + } + } + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/language/get.spec.tsx b/apps/journeys-admin/src/libs/ai/tools/language/get.spec.tsx new file mode 100644 index 00000000000..4a31701c141 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/language/get.spec.tsx @@ -0,0 +1,129 @@ +import { MockedResponse } from '@apollo/client/testing' +import { renderHook } from '@testing-library/react' + +import { ApolloLoadingProvider } from '@core/shared/ui/ApolloLoadingProvider' + +import { LOAD_LANGUAGES, loadLanguages } from './get' + +const mockLanguages = [ + { + id: '529', + slug: 'english' + }, + { + id: '496', + slug: 'spanish' + } +] + +const mocks: MockedResponse[] = [ + { + request: { + query: LOAD_LANGUAGES, + variables: { subtitles: ['529', '496'] } + }, + result: { + data: { + languages: mockLanguages + } + } + } +] + +describe('loadLanguages', () => { + it('should return language data with id and slug', async () => { + const { result } = renderHook( + () => ({ + client: ApolloLoadingProvider.client + }), + { + wrapper: ({ children }) => ( + + {children} + + ) + } + ) + + const tool = loadLanguages(result.current.client, { langfuseTraceId: 'test' }) + + const response = await tool.execute({ subtitles: ['529', '496'] }) + + expect(response).toEqual(mockLanguages) + }) + + it('should handle empty subtitles array', async () => { + const emptyMocks: MockedResponse[] = [ + { + request: { + query: LOAD_LANGUAGES, + variables: { subtitles: [] } + }, + result: { + data: { + languages: [] + } + } + } + ] + + const { result } = renderHook( + () => ({ + client: ApolloLoadingProvider.client + }), + { + wrapper: ({ children }) => ( + + {children} + + ) + } + ) + + const tool = loadLanguages(result.current.client, { langfuseTraceId: 'test' }) + + const response = await tool.execute({ subtitles: [] }) + + expect(response).toEqual([]) + }) + + it('should validate language data structure', async () => { + const invalidMocks: MockedResponse[] = [ + { + request: { + query: LOAD_LANGUAGES, + variables: { subtitles: ['invalid'] } + }, + result: { + data: { + languages: [ + { + id: 'valid-id', + // missing slug field + } + ] + } + } + } + ] + + const { result } = renderHook( + () => ({ + client: ApolloLoadingProvider.client + }), + { + wrapper: ({ children }) => ( + + {children} + + ) + } + ) + + const tool = loadLanguages(result.current.client, { langfuseTraceId: 'test' }) + + await expect(tool.execute({ subtitles: ['invalid'] })).rejects.toThrow( + 'Invalid language data for ID valid-id' + ) + }) +}) diff --git a/apps/journeys-admin/src/libs/ai/tools/language/get.ts b/apps/journeys-admin/src/libs/ai/tools/language/get.ts new file mode 100644 index 00000000000..f71e8360ccb --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/language/get.ts @@ -0,0 +1,68 @@ +import { ApolloClient, NormalizedCacheObject, gql } from '@apollo/client' +import { Tool, tool } from 'ai' +import { z } from 'zod' +import { zodToJsonSchema } from 'zod-to-json-schema' + +import { languageSchema } from '@core/shared/ai/languageTypes' + +import { ToolOptions } from '../index' + +function getSchemaDescription(schema: typeof languageSchema): string { + const jsonSchema = zodToJsonSchema(schema) + return JSON.stringify(jsonSchema, null, 2) +} + +export const LOAD_LANGUAGES = gql` + query LoadLanguages($subtitles: [ID!]!) { + languages(where: { ids: $subtitles }) { + id + slug + } + } +` + +export function loadLanguages( + client: ApolloClient, + _options: ToolOptions +): Tool { + return tool({ + description: + 'Fetches language records by subtitle IDs and returns an array of language objects with id and slug fields.' + + '\n\nOutput schema (auto-generated from Zod):\n' + + getSchemaDescription(languageSchema), + parameters: z.object({ + subtitles: z.array(z.string()).describe('Array of subtitle IDs to look up languages for.') + }), + + execute: async ({ subtitles }) => { + try { + const result = await client.query({ + query: LOAD_LANGUAGES, + variables: { subtitles }, + errorPolicy: 'all', + fetchPolicy: 'no-cache' + }) + + const data = result.data + const languages = data.languages || [] + + // Validate each language object + const validatedLanguages = languages.map((language: any) => { + const validationResult = languageSchema.safeParse(language) + if (!validationResult.success) { + throw new Error( + `Invalid language data for ID ${language?.id}: ` + + JSON.stringify(validationResult.error.format()) + ) + } + return validationResult.data + }) + + return validatedLanguages + } catch (error) { + console.error('Error in loadLanguages:', error.message) + throw error + } + } + }) +} \ No newline at end of file diff --git a/apps/journeys-admin/src/libs/ai/tools/language/index.ts b/apps/journeys-admin/src/libs/ai/tools/language/index.ts new file mode 100644 index 00000000000..60caabe2e61 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/language/index.ts @@ -0,0 +1,5 @@ +import { loadLanguages } from './get' + +export const tools = { + loadLanguages +} \ No newline at end of file diff --git a/apps/journeys-admin/src/libs/ai/tools/videoSubtitle/get.spec.ts b/apps/journeys-admin/src/libs/ai/tools/videoSubtitle/get.spec.ts new file mode 100644 index 00000000000..f1611cc3696 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/videoSubtitle/get.spec.ts @@ -0,0 +1,167 @@ +import { ApolloClient, InMemoryCache } from '@apollo/client' + +import { loadVideoSubtitleContent } from './get' + +// Mock fetch for testing +global.fetch = jest.fn() + +describe('loadVideoSubtitleContent', () => { + let mockClient: ApolloClient + + beforeEach(() => { + mockClient = new ApolloClient({ + cache: new InMemoryCache() + }) + + // Reset fetch mock + ;(global.fetch as jest.Mock).mockClear() + }) + + it('should load subtitle data with SRT content when srtSrc is available', async () => { + // Mock Apollo query response + jest.spyOn(mockClient, 'query').mockResolvedValue({ + data: { + video: { + variant: { + subtitle: [ + { + id: 'subtitle-123', + languageId: 'en', + edition: 'default', + primary: true, + srtSrc: 'https://example.com/subtitle.srt' + } + ] + } + } + } + } as any) + + // Mock successful fetch response + ;(global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: () => Promise.resolve('1\n00:00:01,000 --> 00:00:05,000\nHello world\n\n2\n00:00:06,000 --> 00:00:10,000\nThis is a test') + }) + + const tool = loadVideoSubtitleContent(mockClient, { langfuseTraceId: 'test-trace-id' }) + + const result = await tool.execute!({ + videoId: 'video-123', + languageId: 'en' + }) + + expect(result).toEqual({ + id: 'subtitle-123', + languageId: 'en', + edition: 'default', + primary: true, + srtSrc: 'https://example.com/subtitle.srt', + srtContent: '1\n00:00:01,000 --> 00:00:05,000\nHello world\n\n2\n00:00:06,000 --> 00:00:10,000\nThis is a test' + }) + + // Verify fetch was called to get SRT content + expect(global.fetch).toHaveBeenCalledWith('https://example.com/subtitle.srt') + }) + + it('should handle fetch errors gracefully when SRT content fetch fails', async () => { + // Mock Apollo query response + jest.spyOn(mockClient, 'query').mockResolvedValue({ + data: { + video: { + variant: { + subtitle: [ + { + id: 'subtitle-123', + languageId: 'en', + edition: 'default', + primary: true, + srtSrc: 'https://example.com/subtitle.srt' + } + ] + } + } + } + } as any) + + // Mock fetch error + ;(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')) + + const tool = loadVideoSubtitleContent(mockClient, { langfuseTraceId: 'test-trace-id' }) + + const result = await tool.execute({ + videoId: 'video-123', + languageId: 'en' + }) + + // Should still return the subtitle data without srtContent when fetch fails + expect(result).toEqual({ + id: 'subtitle-123', + languageId: 'en', + edition: 'default', + primary: true, + srtSrc: 'https://example.com/subtitle.srt' + }) + + // Verify fetch was called but failed + expect(global.fetch).toHaveBeenCalledWith('https://example.com/subtitle.srt') + }) + + it('should not fetch SRT content when srtSrc is not available', async () => { + // Mock Apollo query response with no srtSrc + jest.spyOn(mockClient, 'query').mockResolvedValue({ + data: { + video: { + variant: { + subtitle: [ + { + id: 'subtitle-123', + languageId: 'en', + edition: 'default', + primary: true, + srtSrc: null + } + ] + } + } + } + } as any) + + const tool = loadVideoSubtitleContent(mockClient, { langfuseTraceId: 'test-trace-id' }) + + const result = await tool.execute({ + videoId: 'video-123', + languageId: 'en' + }) + + expect(result).toEqual({ + id: 'subtitle-123', + languageId: 'en', + edition: 'default', + primary: true, + srtSrc: null + }) + + // Verify fetch was not called since srtSrc is null + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('should handle missing subtitle gracefully', async () => { + // Mock Apollo query response with no subtitles + jest.spyOn(mockClient, 'query').mockResolvedValue({ + data: { + video: { + variant: { + subtitle: [] + } + } + } + } as any) + + const tool = loadVideoSubtitleContent(mockClient, { langfuseTraceId: 'test-trace-id' }) + + await expect(tool.execute({ + videoId: 'video-123', + languageId: 'en' + })).rejects.toThrow('No subtitle found for video video-123 and language en') + }) +}) \ No newline at end of file diff --git a/apps/journeys-admin/src/libs/ai/tools/videoSubtitle/get.ts b/apps/journeys-admin/src/libs/ai/tools/videoSubtitle/get.ts new file mode 100644 index 00000000000..483acd95f6a --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/videoSubtitle/get.ts @@ -0,0 +1,121 @@ +import { ApolloClient, NormalizedCacheObject, gql } from '@apollo/client' +import { Tool, tool } from 'ai' +import { z } from 'zod' +import { zodToJsonSchema } from 'zod-to-json-schema' + +import { videoSubtitleContentSchema } from '@core/shared/ai/videoSubtitleTypes' + +import { ToolOptions } from '../index' + +function getSchemaDescription(schema: typeof videoSubtitleContentSchema): string { + const jsonSchema = zodToJsonSchema(schema) + return JSON.stringify(jsonSchema, null, 2) +} + +export const LOAD_VIDEO_SUBTITLE_CONTENT = gql` + query LoadVideoSubtitleContent($videoId: ID!, $languageId: ID!) { + video(id: $videoId) { + id + variant(languageId: $languageId) { + id + subtitle { + id + languageId + edition + primary + srtSrc + } + } + } + } +` + +/** + * Fetches the content of an SRT file from a given URL + * @param srtSrc The URL of the SRT file + * @returns The content of the SRT file as a string + */ +async function fetchSrtContent(srtSrc: string): Promise { + try { + const response = await fetch(srtSrc) + + if (!response.ok) { + throw new Error(`Failed to fetch SRT content: ${response.status} ${response.statusText}`) + } + + const content = await response.text() + return content + } catch (error) { + throw new Error(`Error fetching SRT content from ${srtSrc}: ${error.message}`) + } +} + +export function loadVideoSubtitleContent( + client: ApolloClient, + _options: ToolOptions +): Tool { + return tool({ + description: + 'Fetches video subtitle data by video ID and language ID and returns a validated JSON structure including srtSrc content URL.' + + '\n\nOutput schema (auto-generated from Zod):\n' + + getSchemaDescription(videoSubtitleContentSchema), + parameters: z.object({ + videoId: z.string().describe('The ID of the video to fetch subtitle for.'), + languageId: z.string().describe('The language ID of the subtitle to fetch.') + }), + + execute: async ({ videoId, languageId }) => { + try { + const result = await client.query({ + query: LOAD_VIDEO_SUBTITLE_CONTENT, + variables: { videoId, languageId }, + errorPolicy: 'all', + fetchPolicy: 'no-cache' + }) + + const data = result.data + const subtitles = data.video?.variant?.subtitle || [] + + const validSubtitles = subtitles.filter((sub: any) => { + return sub && + typeof sub === 'object' && + sub.id && + typeof sub.id === 'string' && + sub.languageId && + typeof sub.languageId === 'string' && + sub.languageId === languageId + }) + + const subtitle = validSubtitles[0] + + if (!subtitle) { + throw new Error(`No subtitle found for video ${videoId} and language ${languageId}`) + } + + // Always fetch SRT content if srtSrc exists + if (subtitle.srtSrc) { + try { + const srtContent = await fetchSrtContent(subtitle.srtSrc) + subtitle.srtContent = srtContent + } catch (error) { + console.warn(`Failed to fetch SRT content: ${error.message}`) + // Continue without srtContent if fetch fails + } + } + + const validationResult = videoSubtitleContentSchema.safeParse(subtitle) + if (!validationResult.success) { + throw new Error( + 'Returned subtitle is invalid: ' + + JSON.stringify(validationResult.error.format()) + ) + } + + return validationResult.data + } catch (error) { + console.error('Error in loadVideoSubtitleContent:', error.message) + throw error + } + } + }) +} \ No newline at end of file diff --git a/apps/journeys-admin/src/libs/ai/tools/videoSubtitle/index.ts b/apps/journeys-admin/src/libs/ai/tools/videoSubtitle/index.ts new file mode 100644 index 00000000000..edeaa6a3790 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/videoSubtitle/index.ts @@ -0,0 +1,5 @@ +import { loadVideoSubtitleContent } from './get' + +export const tools = { + loadVideoSubtitleContent +} \ No newline at end of file diff --git a/apps/journeys-admin/src/libs/ai/tools/youtube/getTranscript.ts b/apps/journeys-admin/src/libs/ai/tools/youtube/getTranscript.ts new file mode 100644 index 00000000000..49ffdfff374 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/youtube/getTranscript.ts @@ -0,0 +1,85 @@ +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { YoutubeTranscript } from '@danielxceron/youtube-transcript' +import { Tool, tool } from 'ai' +import { z } from 'zod' + +import { ToolOptions } from '..' + +// Type for a single transcript segment +export type YouTubeTranscriptSegment = { + text: string + start: number // start time in seconds + duration: number // duration in seconds +} + +// Type for the transcript result +export type YouTubeTranscript = YouTubeTranscriptSegment[] + +// Zod schema for a single transcript segment +export const youTubeTranscriptSegmentSchema = z.object({ + text: z.string(), + start: z.number(), + duration: z.number() +}) + +// Zod schema for the transcript result +export const youTubeTranscriptSchema = z.array(youTubeTranscriptSegmentSchema) + +function extractYouTubeVideoId(input: string): string | null { + // If input is already an 11-char video ID, return as-is + if (/^[\w-]{11}$/.test(input)) return input + // Otherwise, try to extract from URL + const match = input.match( + /(?:v=|vi=|youtu\.be\/|\/v\/|embed\/|shorts\/|\/watch\?v=|\/watch\?.+&v=)([\w-]{11})/ + ) + if (match) return match[1] + // Fallback: try generic 11-char match + const generic = input.match(/([\w-]{11})/) + return generic ? generic[1] : null +} + +/** + * Tool factory for fetching YouTube video transcripts. Note: this is a third-party + * tool that is not used in the AI tools system, it was for prototyping. It ONLY works locally + * @param client ApolloClient instance (not used in stub) + * @param _options ToolOptions (not used in stub) + * @returns Tool for the AI tools system + */ +export function youTubeTranscriptTool( + client: ApolloClient, + _options: ToolOptions +): Tool { + return tool({ + description: + 'Fetches the transcript for a YouTube video by ID or URL and returns an array of segments (text, start, duration in seconds).', + parameters: z.object({ + videoIdOrUrl: z.string().describe('The YouTube video ID or URL.') + }), + execute: async ({ videoIdOrUrl }) => { + const videoId = extractYouTubeVideoId(videoIdOrUrl) + if (!videoId) { + throw new Error('Invalid YouTube video ID or URL') + } + let transcript + try { + transcript = await YoutubeTranscript.fetchTranscript(videoId, { + lang: 'en' + }) + } catch (err) { + throw new Error('Could not fetch transcript for this video') + } + // Map {text, duration, offset} to {text, duration, start} + const mapped = transcript.map(({ text, duration, offset }) => ({ + text, + duration, + start: offset + })) + // Validate and map to our type + const result = youTubeTranscriptSchema.safeParse(mapped) + if (!result.success) { + throw new Error('Transcript format is invalid') + } + return result.data + } + }) +} diff --git a/apps/journeys-admin/src/libs/ai/tools/youtube/index.ts b/apps/journeys-admin/src/libs/ai/tools/youtube/index.ts new file mode 100644 index 00000000000..9030568bc31 --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/youtube/index.ts @@ -0,0 +1,2 @@ +export { youTubeTranscriptTool } from './getTranscript' +export { youtubeAnalyzerTool } from './youtubeAnalyzer' diff --git a/apps/journeys-admin/src/libs/ai/tools/youtube/youtubeAnalyzer.ts b/apps/journeys-admin/src/libs/ai/tools/youtube/youtubeAnalyzer.ts new file mode 100644 index 00000000000..6ae7786879a --- /dev/null +++ b/apps/journeys-admin/src/libs/ai/tools/youtube/youtubeAnalyzer.ts @@ -0,0 +1,81 @@ +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { GoogleGenAI } from '@google/genai' +import { Tool, tool } from 'ai' +import { z } from 'zod' + +import { ToolOptions } from '..' + +const googleClient = new GoogleGenAI({ + apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY +}) + +/** + * Analyzes a YouTube video using Gemini 2.0 Flash and returns the transcript. + * @param client - The Apollo client. + * @param _options - The tool options. + * @returns The tool. + */ +export function youtubeAnalyzerTool( + client: ApolloClient, + _options: ToolOptions +): Tool { + return tool({ + description: 'Analyzes a YouTube video.', + parameters: z.object({ + url: z.string().describe('The full URL of the YouTube video to analyze.') + }), + execute: async ({ url }) => { + try { + return await googleClient.models.generateContent({ + model: 'gemini-2.5-flash', + contents: [ + { + text: `Analyze the video and divide it into meaningful sections based on content or topic changes. + + For each section, provide: + - A summary that captures the **core message or essence** of the section. + - One or more **reflective questions** that can be used following the video to get user input. + - A start and end timestamp in "MM:SS" or "HH:MM:SS" format. + - The timestamps of each section do not need to be contiguous. + - The end timestamp should not be greater than the video duration. + + Output your response as **JSON** using this exact structure: + + [ + { + "section": "1", + "start": "00:00", + "end": "01:10", + "summary": "The speaker introduces the idea of identity being shaped by community and relationships.", + "questions": [ + "Who has influenced how I see myself?", + "What communities have shaped my identity most?" + ] + }, + { + "section": "2", + "start": "02:45", + "end": "04:00", + "summary": "This part explores the tension between personal ambition and collective responsibility.", + "questions": [ + "Where in my life do I prioritize self over others?", + "How can I better balance personal goals with service to others?" + ] + } + ] + ` + }, + { + fileData: { + fileUri: url + } + } + ] + }) + } catch (error) { + // Optionally log the error here + return { error: 'Failed to analyze YouTube video.' } + } + } + }) +} diff --git a/apps/journeys-admin/tsconfig.json b/apps/journeys-admin/tsconfig.json index e0bcca97f12..4bfdf028a4b 100644 --- a/apps/journeys-admin/tsconfig.json +++ b/apps/journeys-admin/tsconfig.json @@ -5,22 +5,35 @@ "jsxImportSource": "@emotion/react", "allowJs": true, "allowSyntheticDefaultImports": true, - "types": ["node", "jest"], + "types": [ + "node", + "jest" + ], "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "resolveJsonModule": true, "isolatedModules": true, "incremental": true, - "tsBuildInfoFile": "../../.cache/journeys-admin/tsc/.tsbuildinfo" + "tsBuildInfoFile": "../../.cache/journeys-admin/tsc/.tsbuildinfo", + "plugins": [ + { + "name": "next" + } + ] }, "include": [ - "**/*.ts", - "**/*.tsx", "**/*.js", "**/*.jsx", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "eslint.config.mjs", "next-env.d.ts", - "eslint.config.mjs" + "../../dist/apps/journeys-admin/.next/types/**/*.ts" ], - "exclude": ["node_modules", "jest.config.ts"] + "exclude": [ + "node_modules", + "jest.config.ts" + ] } diff --git a/libs/journeys/ui/src/libs/blurImage/blurImage.spec.ts b/libs/journeys/ui/src/libs/blurImage/blurImage.spec.ts index d74c21e5217..0d1b0c49d98 100644 --- a/libs/journeys/ui/src/libs/blurImage/blurImage.spec.ts +++ b/libs/journeys/ui/src/libs/blurImage/blurImage.spec.ts @@ -2,7 +2,7 @@ import { blurImage } from './blurImage' // Mock blurhash decode function jest.mock('blurhash', () => ({ - decode: () => new Uint8ClampedArray(32 * 32 * 4).fill(128) + decode: jest.fn() })) describe('blurImage', () => { @@ -48,6 +48,9 @@ describe('blurImage', () => { } it('returns url of blurred image', () => { + const { decode } = require('blurhash') + decode.mockReturnValue(new Uint8ClampedArray(32 * 32 * 4).fill(128)) + expect( blurImage(image.blurhash, '#000000')?.startsWith('data:image/png;base64,') ).toBeTruthy() @@ -65,10 +68,52 @@ describe('blurImage', () => { return {} as HTMLElement }) + const { decode } = require('blurhash') + decode.mockReturnValue(new Uint8ClampedArray(32 * 32 * 4).fill(128)) + expect(blurImage(image.blurhash, '#000000')).toBeUndefined() }) it('returns undefined if blurhash is empty string', () => { expect(blurImage('', '#00000088')).toBeUndefined() }) + + it('handles decode errors gracefully', () => { + const { decode } = require('blurhash') + decode.mockImplementation(() => { + throw new Error('Invalid blurhash') + }) + + expect(blurImage(image.blurhash, '#000000')).toBeUndefined() + }) + + it('pads small pixel arrays with background color', () => { + const { decode } = require('blurhash') + // Return array smaller than expected + decode.mockReturnValue(new Uint8ClampedArray(16 * 16 * 4).fill(128)) + + expect( + blurImage(image.blurhash, '#FF0000')?.startsWith('data:image/png;base64,') + ).toBeTruthy() + }) + + it('crops large pixel arrays to fit', () => { + const { decode } = require('blurhash') + // Return array larger than expected + decode.mockReturnValue(new Uint8ClampedArray(64 * 64 * 4).fill(128)) + + expect( + blurImage(image.blurhash, '#00FF00')?.startsWith('data:image/png;base64,') + ).toBeTruthy() + }) + + it('handles exact size pixel arrays', () => { + const { decode } = require('blurhash') + // Return array of exact expected size + decode.mockReturnValue(new Uint8ClampedArray(32 * 32 * 4).fill(128)) + + expect( + blurImage(image.blurhash, '#0000FF')?.startsWith('data:image/png;base64,') + ).toBeTruthy() + }) }) diff --git a/libs/journeys/ui/src/libs/blurImage/blurImage.ts b/libs/journeys/ui/src/libs/blurImage/blurImage.ts index a9f91b114b7..e68a3286806 100644 --- a/libs/journeys/ui/src/libs/blurImage/blurImage.ts +++ b/libs/journeys/ui/src/libs/blurImage/blurImage.ts @@ -6,22 +6,68 @@ export const blurImage = ( ): string | undefined => { if (blurhash === '' || typeof document === 'undefined') return undefined - const pixels = decode(blurhash, 32, 32, 1) + let pixels: Uint8ClampedArray + try { + pixels = decode(blurhash, 32, 32, 1) + } catch (error) { + console.warn('Failed to decode blurhash:', error) + return undefined + } + + // Validate pixel array size + const expectedSize = 32 * 32 * 4 // width * height * 4 (RGBA) + let processedPixels: Uint8ClampedArray + + if (pixels.length < expectedSize) { + // Pad with background color if array is too small + processedPixels = new Uint8ClampedArray(expectedSize) + processedPixels.set(pixels) + + // Fill remaining pixels with background color + const backgroundColor = hexToRgba(hexBackground) + for (let i = pixels.length; i < expectedSize; i += 4) { + processedPixels[i] = backgroundColor.r // R + processedPixels[i + 1] = backgroundColor.g // G + processedPixels[i + 2] = backgroundColor.b // B + processedPixels[i + 3] = backgroundColor.a // A + } + } else if (pixels.length > expectedSize) { + // Crop if array is too large + processedPixels = pixels.slice(0, expectedSize) + } else { + // Array size is correct + processedPixels = pixels + } const canvas = document.createElement('canvas') - canvas.setAttribute('width', '32px') - canvas.setAttribute('height', '32px') + canvas.width = 32 + canvas.height = 32 const context = canvas.getContext('2d') if (context != null) { const imageData = context.createImageData(32, 32) - imageData.data.set(pixels) + imageData.data.set(processedPixels) context.putImageData(imageData, 0, 0) + + // Apply background overlay context.fillStyle = `${hexBackground}88` context.fillRect(0, 0, 32, 32) + const blurUrl = canvas.toDataURL('image/webp') - return blurUrl } return undefined } + +// Helper function to convert hex color to RGBA +const hexToRgba = (hex: string): { r: number; g: number; b: number; a: number } => { + // Remove # if present + const cleanHex = hex.replace('#', '') + + // Parse hex values + const r = parseInt(cleanHex.substring(0, 2), 16) + const g = parseInt(cleanHex.substring(2, 4), 16) + const b = parseInt(cleanHex.substring(4, 6), 16) + + return { r, g, b, a: 255 } +} diff --git a/libs/locales/en/apps-journeys-admin.json b/libs/locales/en/apps-journeys-admin.json index fedfef2828a..7f540513612 100644 --- a/libs/locales/en/apps-journeys-admin.json +++ b/libs/locales/en/apps-journeys-admin.json @@ -87,6 +87,34 @@ "Owner": "Owner", "Editor": "Editor", "Pending": "Pending", + "Ask Anything": "Ask Anything", + "Message": "Message", + "Generating image...": "Generating image...", + "See My Journey!": "See My Journey!", + "Submit form": "Submit form", + "Submit": "Submit", + "Cancel form": "Cancel form", + "Cancel": "Cancel", + "Form was cancelled": "Form was cancelled", + "Yes": "Yes", + "No": "No", + "Open Image Library": "Open Image Library", + "Open Video Library": "Open Video Library", + "Searching the web...": "Searching the web...", + "Getting journey...": "Getting journey...", + "Journey retrieved": "Journey retrieved", + "Updating journey...": "Updating journey...", + "Journey updated": "Journey updated", + "Searching Internal Videos...": "Searching Internal Videos...", + "Videos Search Completed!": "Videos Search Completed!", + "Customize my journey": "Customize my journey", + "Translate to another language": "Translate to another language", + "Tell me about my journey": "Tell me about my journey", + "What can I do to improve my journey?": "What can I do to improve my journey?", + "NextSteps AI can help you make your journey more effective!": "NextSteps AI can help you make your journey more effective!", + "Ask it anything.": "Ask it anything.", + "An error occurred. Please try again.": "An error occurred. Please try again.", + "Retry": "Retry", "User with Requested Access": "User with Requested Access", "Error loading report": "Error loading report", "There was an error loading the report": "There was an error loading the report", @@ -138,7 +166,6 @@ "other sources": "other sources", "Social Media Preview": "Social Media Preview", "Video": "Video", - "Submit": "Submit", "Button": "Button", "Option": "Option", "Subscribe": "Subscribe", diff --git a/libs/shared/ai/src/index.ts b/libs/shared/ai/src/index.ts index ba85b76eb3b..96c294c875f 100644 --- a/libs/shared/ai/src/index.ts +++ b/libs/shared/ai/src/index.ts @@ -1 +1,3 @@ export * from './prompts' +export * from './languageTypes' +export * from './videoSubtitleTypes' diff --git a/libs/shared/ai/src/journeySimpleTypes.ts b/libs/shared/ai/src/journeySimpleTypes.ts index 89b0004b8cd..c9f4232e6e1 100644 --- a/libs/shared/ai/src/journeySimpleTypes.ts +++ b/libs/shared/ai/src/journeySimpleTypes.ts @@ -84,7 +84,20 @@ export type JourneySimpleImage = z.infer // --- Video Schema --- export const journeySimpleVideoSchema = z.object({ - url: z.string().describe('The YouTube video URL.'), + src: z.string().describe('The YouTube video URL or internal video ID.'), + source: z.enum(['youTube', 'internal']).describe('The type of video source.'), + summary: z + .string() + .optional() + .describe( + 'A summary of the section of the video. Used as context for the next logical and relevant next card.' + ), + questions: z + .array(z.string()) + .optional() + .describe( + 'An array of reflective questions to ask the user after the video. Used as context for the next logical and relevant next card.' + ), startAt: z .int() .nonnegative() @@ -122,24 +135,32 @@ export type JourneySimpleVideoUpdate = z.infer< export const journeySimpleCardSchema = z.object({ id: z .string() - .describe('The id of the card. Something like card-1, card-2, etc.'), + .describe( + 'The id of the card. Something like card-1, card-2, card-2a, card-2b, etc.' + ), x: z.number().describe('The x coordinate for the card layout position.'), y: z.number().describe('The y coordinate for the card layout position.'), heading: z .string() .optional() - .describe('A heading for the card, if present.'), + .describe( + 'A heading for the card, if present. Not required for video cards.' + ), text: z.string().optional().describe('The main text content of the card.'), button: journeySimpleButtonSchema .optional() - .describe('A button object for this card, if present.'), + .describe( + 'A button object for this card, if present. Not required for video cards.' + ), poll: z .array(journeySimplePollOptionSchema) .optional() - .describe('An array of poll options for this card, if present.'), + .describe( + 'An array of poll options for this card, if present. Not required for video cards.' + ), image: journeySimpleImageSchema .optional() - .describe('Image object for the card.'), + .describe('Image object for the card. Not required for video cards.'), backgroundImage: journeySimpleImageSchema .optional() .describe('Background image object for the card.'), @@ -152,7 +173,7 @@ export const journeySimpleCardSchema = z.object({ .string() .optional() .describe( - 'The id of the card to navigate to after this card by default. Something like card-1, card-2, etc.' + 'The id of the card to navigate to after this card by default. Something like card-1, card-2, card-2a, card-2b, etc.' ) }) diff --git a/libs/shared/ai/src/languageTypes.ts b/libs/shared/ai/src/languageTypes.ts new file mode 100644 index 00000000000..22276ccdfed --- /dev/null +++ b/libs/shared/ai/src/languageTypes.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +// --- Language Schema --- +export const languageSchema = z.object({ + id: z.string().describe('The unique identifier for the language.'), + slug: z.string().describe('The slug identifier for the language.') +}) + +export type Language = z.infer + +// --- Language Response Schema --- +export const languageResponseSchema = z.object({ + success: z.boolean().describe('Whether the operation was successful.'), + data: z.array(languageSchema).optional().describe('Array of language data if successful.'), + errors: z.array(z.object({ + message: z.string().describe('Error message.') + })).optional().describe('Array of error messages if the operation failed.') +}) + +export type LanguageResponse = z.infer \ No newline at end of file diff --git a/libs/shared/ai/src/videoSubtitleTypes.ts b/libs/shared/ai/src/videoSubtitleTypes.ts new file mode 100644 index 00000000000..5ceb6303a34 --- /dev/null +++ b/libs/shared/ai/src/videoSubtitleTypes.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' + +// --- Video Subtitle Schema --- +export const videoSubtitleContentSchema = z.object({ + id: z.string().describe('The unique identifier for the subtitle.'), + languageId: z.string().describe('The language ID of the subtitle.'), + edition: z.string().describe('The edition of the video this subtitle belongs to.'), + primary: z.boolean().describe('Whether this is the primary subtitle for the video.'), + srtSrc: z.string().optional().describe('The SRT subtitle source URL.'), + srtContent: z.string().optional().describe('The actual content of the SRT file when includeSrtContent is true.') +}) + +export type VideoSubtitleContent = z.infer + +// --- Video Subtitle Response Schema --- +export const videoSubtitleContentResponseSchema = z.object({ + success: z.boolean().describe('Whether the operation was successful.'), + data: videoSubtitleContentSchema.optional().describe('The subtitle data if successful.'), + errors: z.array(z.object({ + message: z.string().describe('Error message.') + })).optional().describe('Array of error messages if the operation failed.') +}) + +export type VideoSubtitleContentResponse = z.infer \ No newline at end of file diff --git a/package.json b/package.json index 086050b2269..1dbabd606f4 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,14 @@ "help": "nx help", "ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\"", "prepare": "husky install" - }, + }, "private": true, "dependencies": { "@adobe/apollo-link-mutation-queue": "^1.1.0", "@ai-sdk/google": "^2.0.26", + "@ai-sdk/google-vertex": "^2.2.27", + "@ai-sdk/openai": "^1.3.22", + "@ai-sdk/react": "^2.0.0", "@algolia/client-search": "^5.0.0", "@apollo/client": "^3.8.3", "@apollo/client-integration-nextjs": "^0.12.0", @@ -52,6 +55,7 @@ "@crowdin/ota-client": "^2.0.0", "@datadog/browser-rum": "^6.14.0", "@datadog/browser-rum-react": "^6.14.0", + "@danielxceron/youtube-transcript": "^1.2.3", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^10.0.0", "@docusaurus/core": "^3.9.2", @@ -60,6 +64,7 @@ "@emotion/react": "^11.13.3", "@emotion/server": "^11.11.0", "@emotion/styled": "^11.13.0", + "@google/genai": "^1.9.0", "@graphql-hive/gateway": "^1.0.7", "@graphql-tools/executor": "1.4.9", "@graphql-typed-document-node/core": "^3.2.0", @@ -70,6 +75,7 @@ "@launchdarkly/node-server-sdk": "^9.10.2", "@mailchimp/mailchimp_marketing": "^3.0.80", "@mdx-js/react": "^3.0.0", + "@mendable/firecrawl-js": "^1.29.1", "@mui/icons-material": "^7.1.1", "@mui/lab": "^7.0.0-beta.13", "@mui/material": "^7.1.1", @@ -93,10 +99,12 @@ "@next/eslint-plugin-next": "^15.5.2", "@next/third-parties": "^15.1.7", "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.57.2", "@opentelemetry/exporter-trace-otlp-grpc": "^0.200.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/instrumentation-http": "^0.200.0", "@opentelemetry/resources": "^2.0.1", + "@opentelemetry/sdk-logs": "^0.57.2", "@opentelemetry/sdk-trace-base": "^2.0.1", "@opentelemetry/sdk-trace-node": "^2.0.1", "@opentelemetry/semantic-conventions": "^1.27.0", @@ -132,6 +140,7 @@ "@types/mailchimp__mailchimp_marketing": "^3.0.19", "@types/node-fetch": "^2.6.13", "@upstash/redis": "^1.35.3", + "@vercel/otel": "^1.13.0", "adal-node": "^0.2.3", "ai": "^5.0.86", "algoliasearch": "^5.0.0", @@ -160,6 +169,7 @@ "form-data": "^4.0.0", "formik": "^2.4.4", "fscreen": "^1.2.0", + "googleapis": "^152.0.0", "gql.tada": "^1.8.6", "graphql": "16.10.0", "graphql-request": "^6.1.0", @@ -175,7 +185,10 @@ "ioredis": "^5.3.2", "isomorphic-fetch": "^3.0.0", "jiti": "^2.4.2", + "jwt-decode": "^4.0.0", "keyv": "^5.3.2", + "langfuse": "^3.37.4", + "langfuse-vercel": "^3.37.4", "lodash": "^4.17.21", "lucide-react": "^0.513.0", "nanoid": "^3.3.7", @@ -203,6 +216,7 @@ "prop-types": "^15.8.1", "qrcode.react": "^4.2.0", "qs": "^6.13.1", + "raw-loader": "^4.0.2", "react": "^19.0.0", "react-colorful": "^5.5.1", "react-div-100vh": "^0.7.0", @@ -215,6 +229,7 @@ "react-instantsearch-router-nextjs": "^7.12.3", "react-is": "^19.0.0", "react-loading-hook": "^1.1.2", + "react-markdown": "^6.0.3", "react-simple-timefield": "^3.3.1", "react-swipeable": "^7.0.1", "react-transition-group": "^4.4.5", @@ -249,7 +264,9 @@ "xliff": "^6.2.1", "yup": "^1.0.0", "zen-observable-ts": "1.2.5", - "zod": "^4.1.12" + "zod": "^4.1.12", + "zod-formik-adapter": "^1.3.0", + "zod-to-json-schema": "^3.24.6" }, "devDependencies": { "@babel/core": "^7.22.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 819fb7264a0..d366fe3f620 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,15 @@ importers: '@ai-sdk/google': specifier: ^2.0.26 version: 2.0.31(zod@4.3.5) + '@ai-sdk/google-vertex': + specifier: ^2.2.27 + version: 2.2.27(encoding@0.1.13)(zod@4.3.5) + '@ai-sdk/openai': + specifier: ^1.3.22 + version: 1.3.24(zod@4.3.5) + '@ai-sdk/react': + specifier: ^2.0.0 + version: 2.0.120(react@19.2.0)(zod@4.3.5) '@algolia/client-search': specifier: ^5.0.0 version: 5.21.0 @@ -56,6 +65,9 @@ importers: '@crowdin/ota-client': specifier: ^2.0.0 version: 2.0.2 + '@danielxceron/youtube-transcript': + specifier: ^1.2.3 + version: 1.2.6 '@datadog/browser-rum': specifier: ^6.14.0 version: 6.14.0 @@ -86,6 +98,9 @@ importers: '@emotion/styled': specifier: ^11.13.0 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) + '@google/genai': + specifier: ^1.9.0 + version: 1.42.0 '@graphql-hive/gateway': specifier: ^1.0.7 version: 1.16.5(@types/ioredis-mock@8.2.5)(@types/node@22.18.8)(graphql@16.10.0)(ioredis@5.6.0)(prom-client@15.1.3) @@ -116,6 +131,9 @@ importers: '@mdx-js/react': specifier: ^3.0.0 version: 3.1.1(@types/react@19.2.2)(react@19.2.0) + '@mendable/firecrawl-js': + specifier: ^1.29.1 + version: 1.29.3 '@mui/icons-material': specifier: ^7.1.1 version: 7.1.1(@mui/material@7.1.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) @@ -185,6 +203,9 @@ importers: '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 + '@opentelemetry/api-logs': + specifier: ^0.57.2 + version: 0.57.2 '@opentelemetry/exporter-trace-otlp-grpc': specifier: ^0.200.0 version: 0.200.0(@opentelemetry/api@1.9.0) @@ -197,6 +218,9 @@ importers: '@opentelemetry/resources': specifier: ^2.0.1 version: 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': + specifier: ^0.57.2 + version: 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': specifier: ^2.0.1 version: 2.1.0(@opentelemetry/api@1.9.0) @@ -302,6 +326,9 @@ importers: '@upstash/redis': specifier: ^1.35.3 version: 1.35.5 + '@vercel/otel': + specifier: ^1.13.0 + version: 1.14.0(@opentelemetry/api-logs@0.57.2)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0)) adal-node: specifier: ^0.2.3 version: 0.2.4 @@ -386,6 +413,9 @@ importers: fscreen: specifier: ^1.2.0 version: 1.2.0 + googleapis: + specifier: ^152.0.0 + version: 152.0.0 gql.tada: specifier: ^1.8.6 version: 1.8.13(graphql@16.10.0)(typescript@5.9.3) @@ -431,9 +461,18 @@ importers: jiti: specifier: ^2.4.2 version: 2.5.1 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 keyv: specifier: ^5.3.2 version: 5.5.0 + langfuse: + specifier: ^3.37.4 + version: 3.38.6 + langfuse-vercel: + specifier: ^3.37.4 + version: 3.38.6(ai@5.0.86(zod@4.3.5)) lodash: specifier: ^4.17.21 version: 4.17.21 @@ -515,6 +554,9 @@ importers: qs: specifier: ^6.13.1 version: 6.14.0 + raw-loader: + specifier: ^4.0.2 + version: 4.0.2(webpack@5.101.3(@swc/core@1.5.29(@swc/helpers@0.5.17))(esbuild@0.19.12)) react: specifier: ^19.0.0 version: 19.2.0 @@ -551,6 +593,9 @@ importers: react-loading-hook: specifier: ^1.1.2 version: 1.1.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-markdown: + specifier: ^6.0.3 + version: 6.0.3(@types/react@19.2.2)(react@19.2.0) react-simple-timefield: specifier: ^3.3.1 version: 3.3.1(prop-types@15.8.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -656,6 +701,12 @@ importers: zod: specifier: ^4.1.12 version: 4.3.5 + zod-formik-adapter: + specifier: ^1.3.0 + version: 1.3.0(formik@2.4.6(react@19.2.0))(zod@4.3.5) + zod-to-json-schema: + specifier: ^3.24.6 + version: 3.25.1(zod@4.3.5) devDependencies: '@babel/core': specifier: ^7.22.15 @@ -1233,6 +1284,12 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/anthropic@1.2.12': + resolution: {integrity: sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + '@ai-sdk/gateway@1.0.29': resolution: {integrity: sha512-o9LtmBiG2WAgs3GAmL79F8idan/UupxHG8Tyr2gP4aUSOzflM0bsvfzozBp8x6WatQnOx+Pio7YNw45Y6I16iw==} engines: {node: '>=18'} @@ -1251,11 +1308,17 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@2.0.6': - resolution: {integrity: sha512-FmhR6Tle09I/RUda8WSPpJ57mjPWzhiVVlB50D+k+Qf/PBW0CBtnbAUxlNSR5v+NIZNLTK3C56lhb23ntEdxhQ==} + '@ai-sdk/google-vertex@2.2.27': + resolution: {integrity: sha512-iDGX/2yrU4OOL1p/ENpfl3MWxuqp9/bE22Z8Ip4DtLCUx6ismUNtrKO357igM1/3jrM6t9C6egCPniHqBsHOJA==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4.1.8 + zod: ^3.0.0 + + '@ai-sdk/google@1.2.22': + resolution: {integrity: sha512-Ppxu3DIieF1G9pyQ5O1Z646GYR0gkC57YdBqXJ82qvCdhEhZHu0TWhmnOoeIWe2olSbuDeoOY+MfJrW8dzS3Hw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 '@ai-sdk/google@2.0.31': resolution: {integrity: sha512-wOlUkrXHuL73sXPZd251+30BQ378zn2Zo8pW+Hq+8d9FmSJpZXOSK8cmYle1SE5ZWe/TS+eSj3eBCZ6AW+q2EA==} @@ -1263,14 +1326,20 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@3.0.15': - resolution: {integrity: sha512-kOc6Pxb7CsRlNt+sLZKL7/VGQUd7ccl3/tIK+Bqf5/QhHR0Qm3qRBMz1IwU1RmjJEZA73x+KB5cUckbDl2WF7Q==} + '@ai-sdk/openai@1.3.24': + resolution: {integrity: sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==} engines: {node: '>=18'} peerDependencies: - zod: ^3.25.76 || ^4.1.8 + zod: ^3.0.0 - '@ai-sdk/provider-utils@3.0.16': - resolution: {integrity: sha512-lsWQY9aDXHitw7C1QRYIbVGmgwyT98TF3MfM8alNIXKpdJdi+W782Rzd9f1RyOfgRmZ08gJ2EYNDhWNK7RqpEA==} + '@ai-sdk/provider-utils@2.2.8': + resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.23.8 + + '@ai-sdk/provider-utils@3.0.15': + resolution: {integrity: sha512-kOc6Pxb7CsRlNt+sLZKL7/VGQUd7ccl3/tIK+Bqf5/QhHR0Qm3qRBMz1IwU1RmjJEZA73x+KB5cUckbDl2WF7Q==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -1293,6 +1362,10 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + '@ai-sdk/provider@1.1.3': + resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} + engines: {node: '>=18'} + '@ai-sdk/provider@2.0.0': resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} @@ -1311,16 +1384,6 @@ packages: zod: optional: true - '@ai-sdk/react@2.0.87': - resolution: {integrity: sha512-uuM/FU2bT+DDQzL6YcwdQWZ5aKdT0QYsZzCNwM4jag4UQkryYJJ+CBpo2u3hZr4PaIIuL7TZzGMCzDN/UigQ9Q==} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.25.76 || ^4.1.8 - peerDependenciesMeta: - zod: - optional: true - '@algolia/abtesting@1.8.0': resolution: {integrity: sha512-Hb4BkGNnvgCj3F9XzqjiFTpA5IGkjOXwGAOV13qtc27l2qNF8X9rzSp1H5hu8XewlC0DzYtQtZZIOYzRZDyuXg==} engines: {node: '>= 14.0.0'} @@ -3460,6 +3523,10 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@danielxceron/youtube-transcript@1.2.6': + resolution: {integrity: sha512-NGqXFJE2+9ChS1xWrLM1bath6AWl2Q3im9PvD+tIpZROOnmp18MclzuUEGPCEFl1W67RNKLiLDGk2qrBxyuOLA==} + engines: {node: '>=18.0.0'} + '@datadog/browser-core@6.14.0': resolution: {integrity: sha512-ZQhLSw+mXxjyWoB0iWDJWncHIJZ4YGr0JMlPw/klK6SiFiqAwZNQu9Wkncch1m3zppgk36KiHKKOK/ThLBZDbg==} @@ -5483,6 +5550,15 @@ packages: resolution: {integrity: sha512-78nNAY7iiZ4O/BouWMWTD/oSF2YtYgYB3GZirn0To6eBOugjXVoK+GXgUXOl+HlqbAOyHxAVXOlsj3snfbQ1dw==} engines: {node: '>=12'} + '@google/genai@1.42.0': + resolution: {integrity: sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@gql.tada/cli-utils@1.7.1': resolution: {integrity: sha512-wg5ysZNQxtNQm67T3laVWmZzLpGb7QfyYWZdaUD2r1OjDj5Bgftq7eQlplmH+hsdffjuUyhJw/b5XAjeE2mJtg==} peerDependencies: @@ -7085,6 +7161,10 @@ packages: '@types/react': '>=16' react: '>=16' + '@mendable/firecrawl-js@1.29.3': + resolution: {integrity: sha512-+uvDktesJmVtiwxMtimq+3f5bKlsan4T7TokxOI7DbxFkApwrRNss5GYEXbInveMTz8LpGth/9Ch5BTwCqrpfA==} + engines: {node: '>=22.0.0'} + '@mermaid-js/mermaid-cli@11.4.2': resolution: {integrity: sha512-nBsEW1AxHsjsjTBrqFInkh91Vvb5vNPmnN7UGWkutExcQQZev6XzMlEZp0i6HYFSoGTHZT2tOT0l/KLzvDyPfg==} engines: {node: ^18.19 || >=20.0} @@ -11800,6 +11880,9 @@ packages: '@types/gtag.js@0.0.12': resolution: {integrity: sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==} + '@types/hast@2.3.10': + resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -11899,6 +11982,9 @@ packages: '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@3.0.15': + resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -12040,6 +12126,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} @@ -12442,6 +12531,18 @@ packages: resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} + '@vercel/otel@1.14.0': + resolution: {integrity: sha512-4FXrYvjmHh3ia2v/TN/iiz0oVIw1xSnkW/MzRtsUGpu4jlcfu1qXJIfugQ1iAZnRolyTKn8FxhoWA6EhiAIZBg==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': '>=1.7.0 <2.0.0' + '@opentelemetry/api-logs': '>=0.46.0 <0.200.0' + '@opentelemetry/instrumentation': '>=0.46.0 <0.200.0' + '@opentelemetry/resources': '>=1.19.0 <2.0.0' + '@opentelemetry/sdk-logs': '>=0.46.0 <0.200.0' + '@opentelemetry/sdk-metrics': '>=1.19.0 <2.0.0' + '@opentelemetry/sdk-trace-base': '>=1.19.0 <2.0.0' + '@vercel/python@5.0.0': resolution: {integrity: sha512-JHpYKQ8d478REzmF7NcJTJcncFziJhVOwzan8wW4F1RJOHGDBTPkATAgi4CPQIijToRamPCkgeECzNOvLUDR+w==} @@ -12871,12 +12972,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - ai@5.0.87: - resolution: {integrity: sha512-9Cjx7o8IY9zAczigX0Tk/BaQwjPe/M6DpEjejKSBNrf8mOPIvyM+pJLqJSC10IsKci3FPsnaizJeJhoetU1Wfw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -13363,6 +13458,9 @@ packages: backo2@1.0.2: resolution: {integrity: sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==} + bail@1.0.5: + resolution: {integrity: sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -13845,12 +13943,21 @@ packages: character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + character-entities-legacy@1.1.4: + resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} + character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-entities@1.2.4: + resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + character-reference-invalid@1.1.4: + resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==} + character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} @@ -14198,6 +14305,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@1.0.8: + resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -16830,10 +16940,26 @@ packages: resolution: {integrity: sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==} engines: {node: '>=12'} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + gcp-metadata@5.3.0: resolution: {integrity: sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==} engines: {node: '>=12'} + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} @@ -17066,21 +17192,45 @@ packages: peerDependencies: csstype: ^3.0.10 + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + google-auth-library@8.9.0: resolution: {integrity: sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg==} engines: {node: '>=12'} + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + google-gax@3.6.1: resolution: {integrity: sha512-g/lcUjGcB6DSw2HxgEmCDOrI/CByOwqRvsuUvNalHUK2iPPPlmAIpbMbl62u0YufGMr8zgE3JL7th6dCb1Ry+w==} engines: {node: '>=12'} hasBin: true + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + google-p12-pem@4.0.1: resolution: {integrity: sha512-WPkN4yGtz05WZ5EhtlxNDWPhC4JIic6G8ePitwUWy4l+XPVYec+a0j0Ts47PDtW59y3RwAhUd9/h9ZZ63px6RQ==} engines: {node: '>=12.0.0'} deprecated: Package is no longer maintained hasBin: true + googleapis-common@8.0.1: + resolution: {integrity: sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==} + engines: {node: '>=18.0.0'} + + googleapis@152.0.0: + resolution: {integrity: sha512-i/eo7ytwdWv9vONIY/MdUkSYOnGTtTMLP1y4eaTazGVArlmkH47Th07HLz8EYgIwsixqTR48bxAcdsGd9yoZqg==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -17238,6 +17388,14 @@ packages: resolution: {integrity: sha512-4ccGpzz7YAr7lxrT2neugmXQ3hP9ho2gcaityLVkiUecAiwiy60Ii8gRbZeOsXV19fYaRjgBSshs8kXw+NKCPQ==} engines: {node: '>=12.0.0'} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + gulp-sort@2.0.0: resolution: {integrity: sha512-MyTel3FXOdh1qhw1yKhpimQrAmur9q1X0ZigLmCOxouQD+BD3za9/89O+HfbgBQvvh4igEbp0/PUWO+VqGYG1g==} @@ -17776,6 +17934,9 @@ packages: resolution: {integrity: sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-style-parser@0.1.1: + resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} + inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} @@ -17872,9 +18033,15 @@ packages: resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} engines: {node: '>=0.10.0'} + is-alphabetical@1.0.4: + resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + is-alphanumerical@1.0.4: + resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==} + is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} @@ -17942,6 +18109,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@1.0.4: + resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==} + is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -17986,6 +18156,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@1.0.4: + resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -18070,6 +18243,10 @@ packages: resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} engines: {node: '>=0.10.0'} + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + is-plain-obj@3.0.0: resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} engines: {node: '>=10'} @@ -18833,6 +19010,10 @@ packages: jwt-decode@3.1.2: resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + katex@0.16.11: resolution: {integrity: sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==} hasBin: true @@ -18986,6 +19167,20 @@ packages: kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + langfuse-core@3.38.6: + resolution: {integrity: sha512-EcZXa+DK9FJdi1I30+u19eKjuBJ04du6j2Nybk19KKCuraLczg/ppkTQcGvc4QOk//OAi3qUHrajUuV74RXsBQ==} + engines: {node: '>=18'} + + langfuse-vercel@3.38.6: + resolution: {integrity: sha512-QlKZC1RhZRUlI0zNvxP5B6P2Xtt1+1ADTQfC/3hlnXFT3BMJZMynI/eUN3VX7WbNRGK0yDpqA9PyylXQskr92Q==} + engines: {node: '>=18'} + peerDependencies: + ai: '>=3.2.44' + + langfuse@3.38.6: + resolution: {integrity: sha512-mtwfsNGIYvObRh+NYNGlJQJDiBN+Wr3Hnr++wN25mxuOpSTdXX+JQqVCyAqGL5GD2TAXRZ7COsN42Vmp9krYmg==} + engines: {node: '>=18'} + langium@3.0.0: resolution: {integrity: sha512-+Ez9EoiByeoTu/2BXmEaZ06iPNXM6thWJp02KfBO/raSMyCJ4jw7AkWWa+zBCTm0+Tw1Fj9FOxdqSskyN5nAwg==} engines: {node: '>=16.0.0'} @@ -19591,12 +19786,18 @@ packages: md5.js@1.3.5: resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + mdast-util-definitions@4.0.0: + resolution: {integrity: sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==} + mdast-util-directive@3.1.0: resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@0.8.5: + resolution: {integrity: sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==} + mdast-util-from-markdown@2.0.2: resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} @@ -19636,12 +19837,18 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + mdast-util-to-hast@10.2.0: + resolution: {integrity: sha512-JoPBfJ3gBnHZ18icCwHR50orC9kNH81tiR1gs01D8Q5YpV6adHNO9nKNuFBCJQ941/32PT1a63UF/DitmS3amQ==} + mdast-util-to-hast@13.2.0: resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + mdast-util-to-string@2.0.0: + resolution: {integrity: sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==} + mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} @@ -19859,6 +20066,9 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromark@2.11.4: + resolution: {integrity: sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==} + micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} @@ -20984,6 +21194,10 @@ packages: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} engines: {node: '>=8'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + p-retry@6.2.0: resolution: {integrity: sha512-JA6nkq6hKyWLLasXQXUrO4z8BUZGUt/LjlJxx8Gb2+2ntodU/SS63YZ8b0LUTbQ8ZB9iwOfhEPhg4ykKnn2KsA==} engines: {node: '>=16.17'} @@ -21041,6 +21255,9 @@ packages: parse-diff@0.7.1: resolution: {integrity: sha512-1j3l8IKcy4yRK2W4o9EYvJLSzpAVwz4DXqCewYyx2vEwk2gcf3DBPqc8Fj4XV3K33OYJ08A8fWwyu/ykD/HUSg==} + parse-entities@2.0.0: + resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -22335,6 +22552,9 @@ packages: property-expr@2.0.5: resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==} + property-information@5.6.0: + resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==} + property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} @@ -22534,6 +22754,12 @@ packages: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} + raw-loader@4.0.2: + resolution: {integrity: sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -22697,6 +22923,12 @@ packages: react: ^18.0.0 || ^19.0.0-rc-f994737d14-20240522 react-dom: ^18.0.0 || ^19.0.0-rc-f994737d14-20240522 + react-markdown@6.0.3: + resolution: {integrity: sha512-kQbpWiMoBHnj9myLlmZG9T1JdoT/OEyHK7hqM6CqFT14MAkgWiWBUYijLyBmxbntaN6dCDicPcUhWhci1QYodg==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + react-markdown@9.1.0: resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} peerDependencies: @@ -23075,9 +23307,15 @@ packages: remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + remark-parse@9.0.0: + resolution: {integrity: sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw==} + remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-rehype@8.1.0: + resolution: {integrity: sha512-EbCu9kHgAxKmW1yEYjx3QafMyGY3q8noUbNUI5xyKbaFP89wbhDrKxyIQNukNYthzjNHZu6J7hwFg7hRm1svYA==} + remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} @@ -23975,6 +24213,9 @@ packages: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} deprecated: Please use @jridgewell/sourcemap-codec instead + space-separated-tokens@1.1.5: + resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -24305,6 +24546,9 @@ packages: style-to-js@1.1.16: resolution: {integrity: sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==} + style-to-object@0.3.0: + resolution: {integrity: sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==} + style-to-object@1.0.8: resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==} @@ -24800,6 +25044,9 @@ packages: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} + trough@1.0.5: + resolution: {integrity: sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -25063,6 +25310,9 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + typescript-event-target@1.1.2: + resolution: {integrity: sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==} + typescript@4.9.5: resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} engines: {node: '>=4.2.0'} @@ -25204,6 +25454,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unified@9.2.2: + resolution: {integrity: sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==} + union@0.5.0: resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} engines: {node: '>= 0.8.0'} @@ -25216,21 +25469,42 @@ packages: resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} engines: {node: '>=12'} + unist-builder@2.0.3: + resolution: {integrity: sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==} + + unist-util-generated@1.1.6: + resolution: {integrity: sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==} + + unist-util-is@4.1.0: + resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==} + unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} unist-util-position-from-estree@2.0.0: resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + unist-util-position@3.1.0: + resolution: {integrity: sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==} + unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-stringify-position@2.0.3: + resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + unist-util-visit-parents@3.1.1: + resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==} + unist-util-visit-parents@6.0.1: resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + unist-util-visit@2.0.3: + resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==} + unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} @@ -25326,6 +25600,9 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + url@0.11.4: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} @@ -25478,9 +25755,15 @@ packages: vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@2.0.4: + resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==} + vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + vfile@4.2.1: + resolution: {integrity: sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==} + vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} @@ -26341,6 +26624,17 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-formik-adapter@1.3.0: + resolution: {integrity: sha512-qWsVwRYqpRod5BL35pRXHD6UOugiyaEyLPO04rCN/uKTCFCR+VElPEG26+3wNrxGP7y5XmJM+4/0MrABnRYZrw==} + peerDependencies: + formik: ^2.2.9 + zod: ^3.22.4 + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -26405,6 +26699,12 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ai-sdk/anthropic@1.2.12(zod@4.3.5)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@4.3.5) + zod: 4.3.5 + '@ai-sdk/gateway@1.0.29(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -26418,6 +26718,13 @@ snapshots: '@vercel/oidc': 3.0.5 zod: 3.25.67 + '@ai-sdk/gateway@2.0.24(zod@4.3.5)': + dependencies: + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.20(zod@4.3.5) + '@vercel/oidc': 3.0.5 + zod: 4.3.5 + '@ai-sdk/gateway@2.0.5(zod@4.3.5)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -26425,11 +26732,22 @@ snapshots: '@vercel/oidc': 3.0.3 zod: 4.3.5 - '@ai-sdk/gateway@2.0.6(zod@4.3.5)': + '@ai-sdk/google-vertex@2.2.27(encoding@0.1.13)(zod@4.3.5)': dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.16(zod@4.3.5) - '@vercel/oidc': 3.0.3 + '@ai-sdk/anthropic': 1.2.12(zod@4.3.5) + '@ai-sdk/google': 1.2.22(zod@4.3.5) + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@4.3.5) + google-auth-library: 9.15.1(encoding@0.1.13) + zod: 4.3.5 + transitivePeerDependencies: + - encoding + - supports-color + + '@ai-sdk/google@1.2.22(zod@4.3.5)': + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@4.3.5) zod: 4.3.5 '@ai-sdk/google@2.0.31(zod@4.3.5)': @@ -26438,14 +26756,20 @@ snapshots: '@ai-sdk/provider-utils': 3.0.17(zod@4.3.5) zod: 4.3.5 - '@ai-sdk/provider-utils@3.0.15(zod@4.3.5)': + '@ai-sdk/openai@1.3.24(zod@4.3.5)': dependencies: - '@ai-sdk/provider': 2.0.0 - '@standard-schema/spec': 1.0.0 - eventsource-parser: 3.0.6 + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.8(zod@4.3.5) zod: 4.3.5 - '@ai-sdk/provider-utils@3.0.16(zod@4.3.5)': + '@ai-sdk/provider-utils@2.2.8(zod@4.3.5)': + dependencies: + '@ai-sdk/provider': 1.1.3 + nanoid: 3.3.11 + secure-json-parse: 2.7.0 + zod: 4.3.5 + + '@ai-sdk/provider-utils@3.0.15(zod@4.3.5)': dependencies: '@ai-sdk/provider': 2.0.0 '@standard-schema/spec': 1.0.0 @@ -26466,6 +26790,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.67 + '@ai-sdk/provider-utils@3.0.20(zod@4.3.5)': + dependencies: + '@ai-sdk/provider': 2.0.1 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 4.3.5 + '@ai-sdk/provider-utils@3.0.9(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -26473,6 +26804,10 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.67 + '@ai-sdk/provider@1.1.3': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@2.0.0': dependencies: json-schema: 0.4.0 @@ -26491,10 +26826,10 @@ snapshots: optionalDependencies: zod: 3.25.67 - '@ai-sdk/react@2.0.87(react@19.2.0)(zod@4.3.5)': + '@ai-sdk/react@2.0.120(react@19.2.0)(zod@4.3.5)': dependencies: - '@ai-sdk/provider-utils': 3.0.16(zod@4.3.5) - ai: 5.0.87(zod@4.3.5) + '@ai-sdk/provider-utils': 3.0.20(zod@4.3.5) + ai: 5.0.118(zod@4.3.5) react: 19.2.0 swr: 2.3.6(react@19.2.0) throttleit: 2.1.0 @@ -28243,7 +28578,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.200.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.200.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.1.0(@opentelemetry/api@1.9.0) @@ -30613,6 +30948,8 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 + '@danielxceron/youtube-transcript@1.2.6': {} + '@datadog/browser-core@6.14.0': {} '@datadog/browser-rum-core@6.14.0': @@ -30726,10 +31063,10 @@ snapshots: '@docsearch/react@4.2.0(@algolia/client-search@5.21.0)(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.2)': dependencies: - '@ai-sdk/react': 2.0.87(react@19.2.0)(zod@4.3.5) + '@ai-sdk/react': 2.0.120(react@19.2.0)(zod@4.3.5) '@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.21.0)(algoliasearch@5.42.0)(search-insights@2.17.2) '@docsearch/css': 4.2.0 - ai: 5.0.87(zod@4.3.5) + ai: 5.0.118(zod@4.3.5) algoliasearch: 5.42.0 marked: 16.4.1 zod: 4.3.5 @@ -32912,18 +33249,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@formatjs/intl@2.10.0(typescript@5.4.4)': - dependencies: - '@formatjs/ecma402-abstract': 1.18.2 - '@formatjs/fast-memoize': 2.2.0 - '@formatjs/icu-messageformat-parser': 2.7.6 - '@formatjs/intl-displaynames': 6.6.6 - '@formatjs/intl-listformat': 7.5.5 - intl-messageformat: 10.5.11 - tslib: 2.8.1 - optionalDependencies: - typescript: 5.4.4 - '@formatjs/intl@2.10.0(typescript@5.9.3)': dependencies: '@formatjs/ecma402-abstract': 1.18.2 @@ -32969,7 +33294,7 @@ snapshots: fast-deep-equal: 3.1.3 functional-red-black-tree: 1.0.1 google-gax: 3.6.1(encoding@0.1.13) - protobufjs: 7.4.0 + protobufjs: 7.5.4 transitivePeerDependencies: - encoding - supports-color @@ -33012,6 +33337,17 @@ snapshots: - supports-color optional: true + '@google/genai@1.42.0': + dependencies: + google-auth-library: 10.5.0 + p-retry: 4.6.2 + protobufjs: 7.5.4 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@gql.tada/cli-utils@1.7.1(@0no-co/graphqlsp@1.12.13(graphql@16.10.0)(typescript@5.9.3))(graphql@16.10.0)(typescript@5.9.3)': dependencies: '@0no-co/graphqlsp': 1.12.13(graphql@16.10.0)(typescript@5.9.3) @@ -33756,9 +34092,9 @@ snapshots: '@graphql-tools/executor-graphql-ws': 2.0.7(graphql@16.10.0) '@graphql-tools/utils': 10.10.3(graphql@16.10.0) graphql: 16.10.0 - graphql-ws: 6.0.6(graphql@16.10.0)(ws@8.18.3) + graphql-ws: 6.0.6(graphql@16.10.0)(ws@8.19.0) tslib: 2.8.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - '@fastify/websocket' - '@nats-io/nats-core' @@ -33942,9 +34278,9 @@ snapshots: '@types/ws': 8.5.12 graphql: 16.10.0 graphql-ws: 5.16.2(graphql@16.10.0) - isomorphic-ws: 5.0.0(ws@8.18.3) + isomorphic-ws: 5.0.0(ws@8.19.0) tslib: 2.8.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -33955,10 +34291,10 @@ snapshots: '@graphql-tools/utils': 10.10.3(graphql@16.10.0) '@whatwg-node/disposablestack': 0.0.6 graphql: 16.10.0 - graphql-ws: 6.0.6(graphql@16.10.0)(ws@8.18.3) - isomorphic-ws: 5.0.0(ws@8.18.3) + graphql-ws: 6.0.6(graphql@16.10.0)(ws@8.19.0) + isomorphic-ws: 5.0.0(ws@8.19.0) tslib: 2.8.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - '@fastify/websocket' - bufferutil @@ -34001,9 +34337,9 @@ snapshots: '@graphql-tools/utils': 10.10.3(graphql@16.10.0) '@types/ws': 8.5.12 graphql: 16.10.0 - isomorphic-ws: 5.0.0(ws@8.18.3) + isomorphic-ws: 5.0.0(ws@8.19.0) tslib: 2.8.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -34515,7 +34851,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.2.3 - protobufjs: 7.4.0 + protobufjs: 7.5.4 yargs: 17.7.2 '@grpc/proto-loader@0.8.0': @@ -35540,6 +35876,15 @@ snapshots: '@types/react': 19.2.2 react: 19.2.0 + '@mendable/firecrawl-js@1.29.3': + dependencies: + axios: 1.13.2 + typescript-event-target: 1.1.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - debug + '@mermaid-js/mermaid-cli@11.4.2(puppeteer@23.7.0(typescript@5.9.3))(ts-node@10.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@types/node@22.18.8)(typescript@5.9.3))(typescript@5.9.3)(vue-class-component@7.2.6(vue@3.5.13(typescript@5.9.3)))': dependencies: '@mermaid-js/mermaid-zenuml': 0.2.0(mermaid@11.4.0)(ts-node@10.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@types/node@22.18.8)(typescript@5.9.3))(typescript@5.9.3)(vue-class-component@7.2.6(vue@3.5.13(typescript@5.9.3))) @@ -37473,7 +37818,7 @@ snapshots: '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.30.0 + '@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/exporter-trace-otlp-grpc@0.200.0(@opentelemetry/api@1.9.0)': dependencies: @@ -37584,7 +37929,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.200.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.0.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.0.0(@opentelemetry/api@1.9.0) - protobufjs: 7.4.0 + protobufjs: 7.5.4 '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.0)': dependencies: @@ -37619,7 +37964,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.30.0 + '@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/sdk-logs@0.200.0(@opentelemetry/api@1.9.0)': dependencies: @@ -42460,6 +42805,10 @@ snapshots: '@types/gtag.js@0.0.12': {} + '@types/hast@2.3.10': + dependencies: + '@types/unist': 2.0.11 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -42578,6 +42927,10 @@ snapshots: '@types/mdurl': 2.0.0 optional: true + '@types/mdast@3.0.15': + dependencies: + '@types/unist': 2.0.11 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -42733,6 +43086,8 @@ snapshots: dependencies: '@types/node': 20.5.1 + '@types/retry@0.12.0': {} + '@types/retry@0.12.2': {} '@types/rimraf@3.0.2': @@ -43260,6 +43615,16 @@ snapshots: '@vercel/oidc@3.0.5': {} + '@vercel/otel@1.14.0(@opentelemetry/api-logs@0.57.2)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/instrumentation': 0.200.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.1.0(@opentelemetry/api@1.9.0) + '@vercel/python@5.0.0': {} '@vercel/redwood@2.3.5(encoding@0.1.13)(rollup@4.34.8)': @@ -43863,6 +44228,14 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.67 + ai@5.0.118(zod@4.3.5): + dependencies: + '@ai-sdk/gateway': 2.0.24(zod@4.3.5) + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.20(zod@4.3.5) + '@opentelemetry/api': 1.9.0 + zod: 4.3.5 + ai@5.0.52(zod@3.25.67): dependencies: '@ai-sdk/gateway': 1.0.29(zod@3.25.67) @@ -43879,14 +44252,6 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.3.5 - ai@5.0.87(zod@4.3.5): - dependencies: - '@ai-sdk/gateway': 2.0.6(zod@4.3.5) - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.16(zod@4.3.5) - '@opentelemetry/api': 1.9.0 - zod: 4.3.5 - ajv-draft-04@1.0.0(ajv@8.13.0): optionalDependencies: ajv: 8.13.0 @@ -44509,6 +44874,8 @@ snapshots: backo2@1.0.2: {} + bail@1.0.5: {} + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -44584,8 +44951,7 @@ snapshots: big.js@5.2.2: {} - bignumber.js@9.1.2: - optional: true + bignumber.js@9.1.2: {} binary-extensions@2.3.0: {} @@ -45117,10 +45483,16 @@ snapshots: character-entities-html4@2.1.0: {} + character-entities-legacy@1.1.4: {} + character-entities-legacy@3.0.0: {} + character-entities@1.2.4: {} + character-entities@2.0.2: {} + character-reference-invalid@1.1.4: {} + character-reference-invalid@2.0.1: {} chardet@0.7.0: {} @@ -45504,6 +45876,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@1.0.8: {} + comma-separated-tokens@2.0.3: {} commander@10.0.1: {} @@ -48862,6 +49236,26 @@ snapshots: - supports-color optional: true + gaxios@6.7.1(encoding@0.1.13): + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0(encoding@0.1.13) + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.5 + transitivePeerDependencies: + - supports-color + gcp-metadata@5.3.0(encoding@0.1.13): dependencies: gaxios: 5.1.3(encoding@0.1.13) @@ -48871,6 +49265,23 @@ snapshots: - supports-color optional: true + gcp-metadata@6.1.1(encoding@0.1.13): + dependencies: + gaxios: 6.7.1(encoding@0.1.13) + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generate-function@2.3.1: dependencies: is-property: 1.0.2 @@ -49188,6 +49599,18 @@ snapshots: dependencies: csstype: 3.2.3 + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.0 + transitivePeerDependencies: + - supports-color + google-auth-library@8.9.0(encoding@0.1.13): dependencies: arrify: 2.0.1 @@ -49204,6 +49627,18 @@ snapshots: - supports-color optional: true + google-auth-library@9.15.1(encoding@0.1.13): + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1(encoding@0.1.13) + gcp-metadata: 6.1.1(encoding@0.1.13) + gtoken: 7.1.0(encoding@0.1.13) + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + google-gax@3.6.1(encoding@0.1.13): dependencies: '@grpc/grpc-js': 1.8.22 @@ -49226,11 +49661,32 @@ snapshots: - supports-color optional: true + google-logging-utils@0.0.2: {} + + google-logging-utils@1.1.3: {} + google-p12-pem@4.0.1: dependencies: node-forge: 1.3.1 optional: true + googleapis-common@8.0.1: + dependencies: + extend: 3.0.2 + gaxios: 7.1.3 + google-auth-library: 10.5.0 + qs: 6.14.1 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + + googleapis@152.0.0: + dependencies: + google-auth-library: 10.5.0 + googleapis-common: 8.0.1 + transitivePeerDependencies: + - supports-color + gopd@1.2.0: {} got@11.8.6: @@ -49405,6 +49861,12 @@ snapshots: optionalDependencies: ws: 8.18.3 + graphql-ws@6.0.6(graphql@16.10.0)(ws@8.19.0): + dependencies: + graphql: 16.10.0 + optionalDependencies: + ws: 8.19.0 + graphql-yoga@5.15.1(graphql@16.10.0): dependencies: '@envelop/core': 5.3.0 @@ -49441,6 +49903,21 @@ snapshots: - supports-color optional: true + gtoken@7.1.0(encoding@0.1.13): + dependencies: + gaxios: 6.7.1(encoding@0.1.13) + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.0 + transitivePeerDependencies: + - supports-color + gulp-sort@2.0.0: dependencies: through2: 2.0.5 @@ -50115,6 +50592,8 @@ snapshots: ini@4.1.2: {} + inline-style-parser@0.1.1: {} + inline-style-parser@0.2.4: {} inquirer@8.2.5: @@ -50273,8 +50752,15 @@ snapshots: is-relative: 1.0.0 is-windows: 1.0.2 + is-alphabetical@1.0.4: {} + is-alphabetical@2.0.1: {} + is-alphanumerical@1.0.4: + dependencies: + is-alphabetical: 1.0.4 + is-decimal: 1.0.4 + is-alphanumerical@2.0.1: dependencies: is-alphabetical: 2.0.1 @@ -50348,6 +50834,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@1.0.4: {} + is-decimal@2.0.1: {} is-docker@2.2.1: {} @@ -50376,6 +50864,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@1.0.4: {} + is-hexadecimal@2.0.1: {} is-hotkey@0.1.8: {} @@ -50433,6 +50923,8 @@ snapshots: is-plain-obj@1.1.0: {} + is-plain-obj@2.1.0: {} + is-plain-obj@3.0.0: {} is-plain-obj@4.1.0: {} @@ -50600,9 +51092,9 @@ snapshots: dependencies: ws: 8.18.1 - isomorphic-ws@5.0.0(ws@8.18.3): + isomorphic-ws@5.0.0(ws@8.19.0): dependencies: - ws: 8.18.3 + ws: 8.19.0 isstream@0.1.2: {} @@ -51523,7 +52015,6 @@ snapshots: json-bigint@1.0.0: dependencies: bignumber.js: 9.1.2 - optional: true json-buffer@3.0.1: {} @@ -51657,7 +52148,6 @@ snapshots: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - optional: true jwk-to-pem@2.0.5: dependencies: @@ -51703,10 +52193,11 @@ snapshots: dependencies: jwa: 2.0.0 safe-buffer: 5.2.1 - optional: true jwt-decode@3.1.2: {} + jwt-decode@4.0.0: {} + katex@0.16.11: dependencies: commander: 8.3.0 @@ -51900,6 +52391,20 @@ snapshots: kuler@2.0.0: {} + langfuse-core@3.38.6: + dependencies: + mustache: 4.2.0 + + langfuse-vercel@3.38.6(ai@5.0.86(zod@4.3.5)): + dependencies: + ai: 5.0.86(zod@4.3.5) + langfuse: 3.38.6 + langfuse-core: 3.38.6 + + langfuse@3.38.6: + dependencies: + langfuse-core: 3.38.6 + langium@3.0.0: dependencies: chevrotain: 11.0.3 @@ -52491,6 +52996,10 @@ snapshots: inherits: 2.0.4 safe-buffer: 5.2.1 + mdast-util-definitions@4.0.0: + dependencies: + unist-util-visit: 2.0.3 + mdast-util-directive@3.1.0: dependencies: '@types/mdast': 4.0.4 @@ -52512,6 +53021,16 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + mdast-util-from-markdown@0.8.5: + dependencies: + '@types/mdast': 3.0.15 + mdast-util-to-string: 2.0.0 + micromark: 2.11.4 + parse-entities: 2.0.0 + unist-util-stringify-position: 2.0.3 + transitivePeerDependencies: + - supports-color + mdast-util-from-markdown@2.0.2: dependencies: '@types/mdast': 4.0.4 @@ -52651,6 +53170,17 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.0 + mdast-util-to-hast@10.2.0: + dependencies: + '@types/mdast': 3.0.15 + '@types/unist': 2.0.11 + mdast-util-definitions: 4.0.0 + mdurl: 1.0.1 + unist-builder: 2.0.3 + unist-util-generated: 1.1.6 + unist-util-position: 3.1.0 + unist-util-visit: 2.0.3 + mdast-util-to-hast@13.2.0: dependencies: '@types/hast': 3.0.4 @@ -52675,6 +53205,8 @@ snapshots: unist-util-visit: 5.0.0 zwitch: 2.0.4 + mdast-util-to-string@2.0.0: {} + mdast-util-to-string@4.0.0: dependencies: '@types/mdast': 4.0.4 @@ -53068,6 +53600,13 @@ snapshots: micromark-util-types@2.0.2: {} + micromark@2.11.4: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + parse-entities: 2.0.0 + transitivePeerDependencies: + - supports-color + micromark@4.0.2: dependencies: '@types/debug': 4.1.12 @@ -54319,6 +54858,11 @@ snapshots: eventemitter3: 4.0.7 p-timeout: 3.2.0 + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + p-retry@6.2.0: dependencies: '@types/retry': 0.12.2 @@ -54393,6 +54937,15 @@ snapshots: parse-diff@0.7.1: {} + parse-entities@2.0.0: + dependencies: + character-entities: 1.2.4 + character-entities-legacy: 1.1.4 + character-reference-invalid: 1.1.4 + is-alphanumerical: 1.0.4 + is-decimal: 1.0.4 + is-hexadecimal: 1.0.4 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -55682,6 +56235,10 @@ snapshots: property-expr@2.0.5: {} + property-information@5.6.0: + dependencies: + xtend: 4.0.2 + property-information@6.5.0: {} property-information@7.0.0: {} @@ -55690,7 +56247,7 @@ snapshots: proto3-json-serializer@1.1.1: dependencies: - protobufjs: 7.4.0 + protobufjs: 7.5.4 optional: true protobufjs-cli@1.1.1(protobufjs@7.2.4): @@ -55737,7 +56294,7 @@ snapshots: '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 '@types/node': 20.5.1 - long: 5.2.3 + long: 5.3.2 optional: true protobufjs@7.4.0: @@ -55974,6 +56531,12 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 + raw-loader@4.0.2(webpack@5.101.3(@swc/core@1.5.29(@swc/helpers@0.5.17))(esbuild@0.19.12)): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.101.3(@swc/core@1.5.29(@swc/helpers@0.5.17))(esbuild@0.19.12) + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -56142,7 +56705,7 @@ snapshots: dependencies: '@formatjs/ecma402-abstract': 1.18.2 '@formatjs/icu-messageformat-parser': 2.7.6 - '@formatjs/intl': 2.10.0(typescript@5.4.4) + '@formatjs/intl': 2.10.0(typescript@5.9.3) '@formatjs/intl-displaynames': 6.6.6 '@formatjs/intl-listformat': 7.5.5 '@types/hoist-non-react-statics': 3.3.6 @@ -56209,6 +56772,26 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + react-markdown@6.0.3(@types/react@19.2.2)(react@19.2.0): + dependencies: + '@types/hast': 2.3.10 + '@types/react': 19.2.2 + '@types/unist': 2.0.11 + comma-separated-tokens: 1.0.8 + prop-types: 15.8.1 + property-information: 5.6.0 + react: 19.2.0 + react-is: 17.0.2 + remark-parse: 9.0.0 + remark-rehype: 8.1.0 + space-separated-tokens: 1.1.5 + style-to-object: 0.3.0 + unified: 9.2.2 + unist-util-visit: 2.0.3 + vfile: 4.2.1 + transitivePeerDependencies: + - supports-color + react-markdown@9.1.0(@types/react@18.3.27)(react@18.3.1): dependencies: '@types/hast': 3.0.4 @@ -56739,6 +57322,12 @@ snapshots: transitivePeerDependencies: - supports-color + remark-parse@9.0.0: + dependencies: + mdast-util-from-markdown: 0.8.5 + transitivePeerDependencies: + - supports-color + remark-rehype@11.1.2: dependencies: '@types/hast': 3.0.4 @@ -56747,6 +57336,10 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-rehype@8.1.0: + dependencies: + mdast-util-to-hast: 10.2.0 + remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -57811,6 +58404,8 @@ snapshots: sourcemap-codec@1.4.8: {} + space-separated-tokens@1.1.5: {} + space-separated-tokens@2.0.2: {} spawn-command@0.0.2: {} @@ -58219,6 +58814,10 @@ snapshots: dependencies: style-to-object: 1.0.8 + style-to-object@0.3.0: + dependencies: + inline-style-parser: 0.1.1 + style-to-object@1.0.8: dependencies: inline-style-parser: 0.2.4 @@ -58766,6 +59365,8 @@ snapshots: triple-beam@1.4.1: {} + trough@1.0.5: {} + trough@2.2.0: {} ts-api-utils@2.1.0(typescript@5.9.3): @@ -59055,6 +59656,8 @@ snapshots: transitivePeerDependencies: - supports-color + typescript-event-target@1.1.2: {} + typescript@4.9.5: {} typescript@5.4.4: {} @@ -59187,6 +59790,16 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unified@9.2.2: + dependencies: + '@types/unist': 2.0.11 + bail: 1.0.5 + extend: 3.0.2 + is-buffer: 2.0.5 + is-plain-obj: 2.1.0 + trough: 1.0.5 + vfile: 4.2.1 + union@0.5.0: dependencies: qs: 6.14.0 @@ -59199,6 +59812,12 @@ snapshots: dependencies: crypto-random-string: 4.0.0 + unist-builder@2.0.3: {} + + unist-util-generated@1.1.6: {} + + unist-util-is@4.1.0: {} + unist-util-is@6.0.0: dependencies: '@types/unist': 3.0.3 @@ -59207,19 +59826,36 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-position@3.1.0: {} + unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-stringify-position@2.0.3: + dependencies: + '@types/unist': 2.0.11 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-visit-parents@3.1.1: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 4.1.0 + unist-util-visit-parents@6.0.1: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.0 + unist-util-visit@2.0.3: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 4.1.0 + unist-util-visit-parents: 3.1.1 + unist-util-visit@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -59343,6 +59979,8 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + url-template@2.0.8: {} + url@0.11.4: dependencies: punycode: 1.4.1 @@ -59507,11 +60145,23 @@ snapshots: '@types/unist': 3.0.3 vfile: 6.0.3 + vfile-message@2.0.4: + dependencies: + '@types/unist': 2.0.11 + unist-util-stringify-position: 2.0.3 + vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 unist-util-stringify-position: 4.0.0 + vfile@4.2.1: + dependencies: + '@types/unist': 2.0.11 + is-buffer: 2.0.5 + unist-util-stringify-position: 2.0.3 + vfile-message: 2.0.4 + vfile@6.0.3: dependencies: '@types/unist': 3.0.3 @@ -60547,6 +61197,19 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-formik-adapter@1.3.0(formik@2.4.6(react@19.2.0))(zod@4.3.5): + dependencies: + formik: 2.4.6(react@19.2.0) + zod: 4.3.5 + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-to-json-schema@3.25.1(zod@4.3.5): + dependencies: + zod: 4.3.5 + zod@3.22.3: {} zod@3.23.8: {}