diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc new file mode 100644 index 0000000..22158fa --- /dev/null +++ b/.cursor/rules/01-architecture.mdc @@ -0,0 +1,33 @@ +--- +glob: "**/app/ai/**/*.{ts,tsx}" +description: "Core architectural patterns of the `ai-router` framework, including Orchestrators, Workers, and the dual nature of agents as tools and APIs." +alwaysApply: true +--- +# 01: Core Architecture & Philosophy + +This document outlines the core architectural patterns of the `ai-router` framework. Understanding these principles is essential for building robust and scalable AI agents. + +## Core Philosophy: A Framework for Composition + +`ai-router` is not just a tool; it's a framework for composing complex AI workflows from smaller, reusable components. The core architectural pattern is the separation of concerns between **Orchestrators** and **Workers**. + +- **Orchestrator Agents (`/app/ai/index.ts`)**: An orchestrator is the "brain" of a complex operation. It understands the user's high-level goal and is responsible for breaking it down into a sequence of tasks. It manages the master state of the operation and delegates tasks to specialized worker agents. + +- **Worker Agents (`/app/ai/agents/**/*.ts`)**: Workers are specialized tools that perform a single, well-defined task (e.g., scraping a URL, parsing HTML, calling an external API). They are the building blocks of your AI system. + +--- + +## The Dual Nature of Agents: Tools and APIs + +A key feature of `ai-router` is that every agent can serve two purposes: + +1. **As an Internal Tool**: Agents can be called by other agents using `ctx.next.callAgent()`. This is the primary way that orchestrators delegate tasks to workers. When used this way, agents can return large amounts of data without incurring significant token costs, as the data is passed directly between agents on the server. + +2. **As a Standalone API/Tool**: Agents can also be exposed to the main orchestrator (or even to the outside world) as standalone tools using the `.actAsTool()` method. When an agent is used this way, its `outputSchema` should be carefully designed to be as concise as possible. Returning large amounts of data from a tool call can be very expensive, as the entire output is serialized and sent back to the orchestrating AI. + +**Example Scenario: A Web Search Agent** +Imagine an orchestrator agent that performs web research. + +- It might have two worker agents: `fastSearch.ts` and `deepSearch.ts`. These workers are the internal tools that call the Brave Search API and return a rich set of results. +- The orchestrator (`braveResearch/index.ts`) calls these workers using `ctx.next.callAgent()`. It takes their detailed output and saves it to `ctx.state.researchData`. +- Crucially, when it's done, it only returns a simple `{ status: 'Research Completed!' }` message to the main AI that called it. This is a critical optimization that saves a massive amount of tokens. \ No newline at end of file diff --git a/.cursor/rules/02-workflow.mdc b/.cursor/rules/02-workflow.mdc new file mode 100644 index 0000000..7ae41dc --- /dev/null +++ b/.cursor/rules/02-workflow.mdc @@ -0,0 +1,116 @@ +--- +glob: "**/app/ai/**/*.ts" +description: "A step-by-step guide to the `ai-router` development workflow, from defining schemas to creating and integrating agents." +alwaysApply: true +--- +# 02: Agent Development Workflow + +This document provides a step-by-step guide for building new agents and capabilities within the `ai-router` framework. + +--- + +## The Golden Rules of Agent Development + +1. **Be Explicit, Be Rude with Prompts**: Do not be polite or conversational with the AI. Use strong, imperative language (e.g., "You MUST...", "Do NOT..."). Structure prompts with clear sections (`# Rules`, `# Task`) and use system prompts to define the AI's persona and mission. +2. **Never Assume**: The AI will invent information if not strictly forbidden. Always include a rule like: "You MUST only select from the list provided. Do not create, modify, or assume any URLs." +3. **Schema is Your Guardrail**: Use Zod schemas to rigorously define the expected output of every AI call. This is your primary defense against data hallucinations. + +--- + +## Step 1: Define Your Schemas (`/helpers/schema.ts`) + +Before writing any agent logic, define the data structures you'll be working with. +- **Input Schema**: What data does your agent need to do its job? +- **Output Schema**: What data will your agent produce? +- Use Zod for strong type validation. For recursive or self-referential structures, use `z.lazy()`. + +--- + +## Step 2: Create Your Worker Agents (`/app/ai/agents/your-agent`) + +Build your specialized worker agents. Each worker should have a single, well-defined responsibility. + +```typescript +// Example: /app/ai/agents/your-agent/parser.agent.ts +import { AiRouter } from '@microfox/ai-router'; +// ... other imports + +const aiRouter = new AiRouter(); + +export const parsingAgent = aiRouter.agent('/', async (ctx) => { + const { html, url } = ctx.request.params; + // ... perform pure, logic-based parsing ... + return { emails, socials, otherLinks }; +}); +``` + +--- + +## Step 3: Create Your Orchestrator (`/app/ai/agents/your-agent/index.ts`) + +Build your orchestrator agent. This agent will define the high-level workflow and call your worker agents using `ctx.next.callAgent()`. + +```typescript +// Example: /app/ai/agents/your-agent/index.ts +import { AiRouter } from '@microfox/ai-router'; +import { scrapingAgent } from './scraping.agent'; +import { parsingAgent } from './parsing.agent'; + +const aiRouter = new AiRouter(); + +export const yourOrchestratorAgent = aiRouter + .agent('/scrape', scrapingAgent) + .agent('/parse', parsingAgent) + .agent('/', async (ctx) => { + // ... orchestrator logic ... + const scrapeResult = await ctx.next.callAgent('/scrape', { url: '...' }); + // ... + const parseResult = await ctx.next.callAgent('/parse', { html: '...' }); + // ... + }); +``` + +--- + +## Step 4: Expose as a Tool (`actAsTool`) + +Use `.actAsTool()` to expose your orchestrator as a tool to the main AI. Be thoughtful about the `outputSchema` to minimize token usage. + +```typescript +// In your orchestrator file, e.g., /app/ai/agents/your-agent/index.ts +export const yourOrchestratorAgent = new AiRouter() + // ... agent definitions ... + .actAsTool('/', { + id: 'yourToolId', + name: 'Your Tool Name', + description: 'A detailed description of what this tool does.', + inputSchema: yourInputSchema, // Defined in schema.ts + outputSchema: z.object({ status: z.string() }), // Keep it concise! + }); +``` + +--- + +## Step 5: Integrate into the Main Router (`/app/ai/index.ts`) + +Finally, attach your new agent to the main `aiRouter` and update the main AI's prompts to teach it how and when to use your new tool. + +```typescript +// /app/ai/index.ts +import { yourOrchestratorAgent } from './agents/your-agent'; + +const aiRouter = new AiRouter(); + +const aiMainRouter = aiRouter + .agent('/your-agent', yourOrchestratorAgent) + // ... other agents + +// ... in the main AI agent ... +const { object: analysis, usage } = await generateObject({ + model: google('gemini-2.5-pro'), + tools: { + ...ctx.next.agentAsTool('/your-agent'), // Make the tool available + }, + prompt: `Based on the user's request, decide which tool to use.`, +}); +``` \ No newline at end of file diff --git a/.cursor/rules/03-state-management.mdc b/.cursor/rules/03-state-management.mdc new file mode 100644 index 0000000..1086bd0 --- /dev/null +++ b/.cursor/rules/03-state-management.mdc @@ -0,0 +1,80 @@ +--- +glob: '**/app/ai/**/*.ts' +description: 'Best practices for state management in `ai-router`, covering `ctx.state`, token optimization, and safe parallelism.' +alwaysApply: true +--- + +# 03: State Management & Advanced Patterns + +`ctx.state` is a powerful tool for sharing data between agents, but it must be used with care to avoid bugs and maintain a clean architecture. + +--- + +## The Three Ways to Execute Agents + +There are three distinct ways to execute an agent, and each has a different implication for how you should handle return values and state. + +1. **`ctx.next.callAgent()` (Internal Call)** + - **Description**: This is for server-side, inter-agent communication. One agent calls another. + - **Return Value**: Can return large, complex data objects. Since the data never leaves the server, there are no token costs. + - **Pathing**: Supports nested paths (`'/sub-agent'`) but **not** backtracking (`'../'`). For root-based paths from deeply nested agents, use the `'@'` alias (e.g., `'@/main-agent/worker'`). + +2. **`ctx.next.agentAsTool()` (Exposing to AI)** + - **Description**: This exposes an agent as a "tool" that the main orchestrating AI can choose to call. + - **Return Value**: **MUST** be minimal. The entire output is serialized to JSON and sent back to the AI, which consumes a large number of tokens. The best practice is to save the rich output to `ctx.state` and return only a simple status object (e.g., `{ status: 'Completed' }`). + +3. **HTTP API Call (GET Request)** + - **Description**: Every agent is also a standard API endpoint that can be called via a GET request. The URL structure is `/api/studio/chat/agent/{your-agent-path}`. All input parameters must be passed as URL query parameters. + - **Return Value**: Must return all the data the client needs, as the client does not have access to the server-side `ctx.state`. + - **Example**: + + ```typescript + const contactId = '123'; + const urls = ['https://example.com', 'https://another.com']; + const response = await fetch( + `/api/studio/chat/agent/extract/deep-persona?contactId=${contactId}&urls=${urls.join(',')}`, + ); + + const result = await response.json(); + + const agentResponse = result[0]?.parts[0]?.output; + console.log(agentResponse); + ``` + +--- + +## The Golden Rules of State Management + +1. **State is for Sharing and Cost Reduction**: The primary purpose of `ctx.state` is to allow a sequence of agents to share rich data _on the server_ without passing it back to the AI. This is a critical pattern for reducing token costs. + +2. **The Orchestrator Owns the Master State**: The main orchestrator (typically in `/app/ai/index.ts`) is responsible for initializing and managing the "master" state for the entire user session. + +3. **Design State for Parallelism**: This is the most critical rule. When you call multiple agents in parallel, they will all share the _same_ `ctx.state` object. Your state must be designed to be "additive" to prevent race conditions. + - **Use `Set` for unique lists**: If multiple parallel workers are adding to a list of visited URLs, use a `Set`. Multiple workers can safely call `ctx.state.visitedUrls.add(url)` without overwriting each other. + - **Use arrays for results**: If workers are adding results to a list, they can safely `push` to the same array. + - **Avoid simple properties**: Do not have parallel workers trying to update the same simple property (e.g., `ctx.state.status = 'in-progress'`). This will lead to race conditions. + +4. **Isolate When Necessary**: If a worker agent needs to manage its own internal state during a complex, multi-step operation (like a recursive search), it should use local variables, not `ctx.state`. It should then return a self-contained result that the orchestrator can safely merge back into the master state. + +--- + +### Example: State-Safe Parallelism + +```typescript +// In the Orchestrator +ctx.state.visitedUrls = new Set(); // Use a Set for safe parallel adds +const promises = urls.map((url) => + ctx.next.callAgent('/worker', { + url, + masterVisitedUrls: Array.from(ctx.state.visitedUrls), + }), +); +const results = await Promise.all(promises); + +for (const result of results) { + if (result.ok) { + // Safely merge results + result.data.visitedUrls.forEach((url) => ctx.state.visitedUrls.add(url)); + } +} +``` diff --git a/.cursor/rules/04-building-agent-uis.mdc b/.cursor/rules/04-building-agent-uis.mdc new file mode 100644 index 0000000..fd8a1c3 --- /dev/null +++ b/.cursor/rules/04-building-agent-uis.mdc @@ -0,0 +1,114 @@ +--- +glob: "**/components/ai/**/*.{ts,tsx}" +description: "A guide to building type-safe UIs for `ai-router` agents, including the `aiComponentMap`, component structure, and using `AiRouterTools`." +alwaysApply: true +--- +# 04: Building Agent UIs + +The `ai-router` provides a powerful system for creating type-safe, component-based UIs for your agents. + +--- + +## 1. The `aiComponentMap` + +The file `/components/ai/index.tsx` is the central registry for all agent UIs. It exports an `aiComponentMap` object that maps the `id` of a tool (from `.actAsTool()`) to the React component that should render its output. + +```typescript +// /components/ai/index.tsx +'use client'; + +import { yourAgentComponentMap } from "./your-agent"; +import { anotherAgentComponentMap } from "./another-agent"; + +export const aiComponentMap = { + tools: { + ...yourAgentComponentMap, + ...anotherAgentComponentMap, + }, +}; +``` + +--- + +## 2. UI Component Structure + +For each agent, you should create a corresponding directory in `/components/ai`. Inside this directory, an `index.ts` file will define the mapping for that specific agent's components. + +A tool's UI can have multiple parts: +- `full`: The main component for rendering the tool's output. This is the most common part you will define. +- `header_sticky`: A component that can stick to the top of the message UI. +- `footer_sticky`: A component that can stick to the bottom of the message UI. + +```typescript +// /components/ai/your-agent/index.ts +import { YourAgentDashboard } from './Dashboard'; + +export const yourAgentComponentMap = { + yourToolId: { // This ID must match the 'id' in actAsTool + full: YourAgentDashboard, // The main UI component + }, +}; +``` + +--- + +## 3. `actAsTool` Metadata + +The `metadata` object in the `.actAsTool()` configuration is how you control the appearance of your tool in the UI *before* it's rendered. + +- `title`: The main title of the tool shown in the UI. +- `parentTitle`: If provided, this will group the tool under a category. For example, multiple search tools could have a `parentTitle` of "Web Search". +- `icon`: A URL to the icon for the tool. + +```typescript +// In your orchestrator agent, e.g., /app/ai/agents/your-agent/index.ts +.actAsTool('/', { + id: 'yourToolId', + // ... other properties + metadata: { + icon: 'https://.../your-icon.svg', + title: 'Your Tool Name', + parentTitle: 'Your Category', + }, +}); +``` + +--- + +## 4. Type-Safe UI Components + +To connect your UI components to the backend schemas, the main orchestrator (`/app/ai/index.ts`) must export a generated `AiRouterTools` type. Your UI components can then import this type to get full type safety and autocompletion for the tool's output. + +**Step 1: Export the type from the orchestrator.** +```typescript +// /app/ai/index.ts +const aiRouterRegistry = aiMainRouter.registry(); +const aiRouterTools = aiRouterRegistry.tools; +// This is a magical generated type that understands all your tools +type AiRouterTools = InferUITools; +export { aiRouterTools, type AiRouterTools }; +``` + +**Step 2: Import the type and use it in your component.** +```typescript +// /components/ai/your-agent/Dashboard.tsx +import { AiRouterTools } from '@/app/ai'; +import { ComponentType } from 'react'; +import { ToolUIPart } from 'ai'; + +// Use the generated type to create a type-safe prop for your component +export const YourAgentDashboard: ComponentType<{ + tool: ToolUIPart>; +}> = (props) => { + const { tool } = props; + + // Now, 'tool.output' is fully typed based on your Zod schema! + const { status, results } = tool.output; + + return ( +
+ {/* ... your rendering logic ... */} +
+ ); +}; +``` \ No newline at end of file diff --git a/.cursor/rules/05-debugging.mdc b/.cursor/rules/05-debugging.mdc new file mode 100644 index 0000000..9fd82c5 --- /dev/null +++ b/.cursor/rules/05-debugging.mdc @@ -0,0 +1,71 @@ +--- +glob: "**/ai/**/*.{ts,tsx,json}" +description: "A playbook for debugging common `ai-router` issues, including AI/schema errors, state management problems, and Next.js environment issues." +alwaysApply: true +--- +# 05: Debugging Playbook + +This document provides a playbook for debugging common issues encountered when developing with `ai-router`. + +--- + +## AI & Schema Errors + +* **Error**: `AI_NoObjectGeneratedError` or Zod validation errors (e.g., `Invalid input: expected object, received array`). + * **Cause**: This is almost always a **prompt engineering problem**. The AI is returning data in a shape that doesn't match your Zod schema. + * **Solution**: + 1. Make your prompt more explicit. If you want a `contact` object, the prompt must clearly instruct the AI to "extract the contact information." + 2. Add a `system` prompt that defines the AI's role and tells it to be meticulous about schemas. + 3. If the issue persists, simplify your schema. Sometimes, asking for a complex nested object is less reliable than asking for a flatter structure. + +* **Error**: The AI is "hallucinating" or inventing data (e.g., creating fake URLs). + * **Cause**: The prompt is not specific enough and lacks clear constraints. + * **Solution**: Add a **"Critical Rule"** section to your prompt with explicit negative constraints. + ``` + # Critical Rule + You MUST only select URLs from the "Links" sections provided in the content below. Do not create, modify, guess, or assume any URLs. Your job is to select from the existing list, not to invent. + ``` + +--- + +## State & Logic Errors + +* **Error**: Data is getting mixed up between different parallel runs; results are inconsistent. + * **Cause**: You have a **state contamination** or **race condition** problem. You are likely calling stateful worker agents in parallel with a shared `ctx`. + * **Solution**: Adhere to the Orchestrator/Worker pattern for parallelism. The orchestrator manages the master state. The workers it calls in parallel **must be stateless**. They should receive all data as input parameters and return a self-contained result. The orchestrator is then responsible for safely merging the results back into its master state. + +* **Error**: High token usage is causing high costs or slow performance. + * **Cause**: You are likely returning too much data from a tool call that is being passed to the main orchestrating AI. + * **Solution**: Remember the dual nature of agents. Use `ctx.state` to share large amounts of data between agents *on the server*. The final tool call that returns to the AI should have a minimal `outputSchema` and only return a simple status update (e.g., `{ status: 'Completed' }`). + +--- + +## Environment & Server-Side Errors (Next.js) + +* **Error**: Build fails with errors related to server-side packages like `puppeteer` or `jsdom`. + * **Cause**: Next.js, by default, tries to bundle all packages for the client-side, which fails for packages that rely on Node.js APIs. + * **Solution**: Add the problematic package to the `serverExternalPackages` array in your `next.config.ts` file. This tells Next.js to leave it out of the client bundle. + ```javascript + // next.config.ts + const nextConfig: NextConfig = { + serverExternalPackages: [ + 'puppeteer', + 'puppeteer-extra', + 'puppeteer-extra-plugin-stealth', + ], + }; + ``` + +* **Error**: You need to use a server-side-only package or function within an agent that is called from the client. + * **Cause**: The `ai-router` files are often executed on both the client and the server. + * **Solution**: Create a separate "wrapper" file for your server-side logic and mark it with the `'use server'` directive. Then, import and call this server-side function from your agent. This ensures that the code will only ever execute on the server. + ```typescript + // /helpers/your-server-helper.ts + 'use server' + + import { JSDOM } from 'jsdom'; + + export async function performHeavyServerTask(html: string) { + return new JSDOM(html); + } + ``` \ No newline at end of file diff --git a/examples/contact-extractor-agent/.cursor/rules/01-architecture.mdc b/examples/contact-extractor-agent/.cursor/rules/01-architecture.mdc new file mode 100644 index 0000000..22158fa --- /dev/null +++ b/examples/contact-extractor-agent/.cursor/rules/01-architecture.mdc @@ -0,0 +1,33 @@ +--- +glob: "**/app/ai/**/*.{ts,tsx}" +description: "Core architectural patterns of the `ai-router` framework, including Orchestrators, Workers, and the dual nature of agents as tools and APIs." +alwaysApply: true +--- +# 01: Core Architecture & Philosophy + +This document outlines the core architectural patterns of the `ai-router` framework. Understanding these principles is essential for building robust and scalable AI agents. + +## Core Philosophy: A Framework for Composition + +`ai-router` is not just a tool; it's a framework for composing complex AI workflows from smaller, reusable components. The core architectural pattern is the separation of concerns between **Orchestrators** and **Workers**. + +- **Orchestrator Agents (`/app/ai/index.ts`)**: An orchestrator is the "brain" of a complex operation. It understands the user's high-level goal and is responsible for breaking it down into a sequence of tasks. It manages the master state of the operation and delegates tasks to specialized worker agents. + +- **Worker Agents (`/app/ai/agents/**/*.ts`)**: Workers are specialized tools that perform a single, well-defined task (e.g., scraping a URL, parsing HTML, calling an external API). They are the building blocks of your AI system. + +--- + +## The Dual Nature of Agents: Tools and APIs + +A key feature of `ai-router` is that every agent can serve two purposes: + +1. **As an Internal Tool**: Agents can be called by other agents using `ctx.next.callAgent()`. This is the primary way that orchestrators delegate tasks to workers. When used this way, agents can return large amounts of data without incurring significant token costs, as the data is passed directly between agents on the server. + +2. **As a Standalone API/Tool**: Agents can also be exposed to the main orchestrator (or even to the outside world) as standalone tools using the `.actAsTool()` method. When an agent is used this way, its `outputSchema` should be carefully designed to be as concise as possible. Returning large amounts of data from a tool call can be very expensive, as the entire output is serialized and sent back to the orchestrating AI. + +**Example Scenario: A Web Search Agent** +Imagine an orchestrator agent that performs web research. + +- It might have two worker agents: `fastSearch.ts` and `deepSearch.ts`. These workers are the internal tools that call the Brave Search API and return a rich set of results. +- The orchestrator (`braveResearch/index.ts`) calls these workers using `ctx.next.callAgent()`. It takes their detailed output and saves it to `ctx.state.researchData`. +- Crucially, when it's done, it only returns a simple `{ status: 'Research Completed!' }` message to the main AI that called it. This is a critical optimization that saves a massive amount of tokens. \ No newline at end of file diff --git a/examples/contact-extractor-agent/.cursor/rules/02-workflow.mdc b/examples/contact-extractor-agent/.cursor/rules/02-workflow.mdc new file mode 100644 index 0000000..7ae41dc --- /dev/null +++ b/examples/contact-extractor-agent/.cursor/rules/02-workflow.mdc @@ -0,0 +1,116 @@ +--- +glob: "**/app/ai/**/*.ts" +description: "A step-by-step guide to the `ai-router` development workflow, from defining schemas to creating and integrating agents." +alwaysApply: true +--- +# 02: Agent Development Workflow + +This document provides a step-by-step guide for building new agents and capabilities within the `ai-router` framework. + +--- + +## The Golden Rules of Agent Development + +1. **Be Explicit, Be Rude with Prompts**: Do not be polite or conversational with the AI. Use strong, imperative language (e.g., "You MUST...", "Do NOT..."). Structure prompts with clear sections (`# Rules`, `# Task`) and use system prompts to define the AI's persona and mission. +2. **Never Assume**: The AI will invent information if not strictly forbidden. Always include a rule like: "You MUST only select from the list provided. Do not create, modify, or assume any URLs." +3. **Schema is Your Guardrail**: Use Zod schemas to rigorously define the expected output of every AI call. This is your primary defense against data hallucinations. + +--- + +## Step 1: Define Your Schemas (`/helpers/schema.ts`) + +Before writing any agent logic, define the data structures you'll be working with. +- **Input Schema**: What data does your agent need to do its job? +- **Output Schema**: What data will your agent produce? +- Use Zod for strong type validation. For recursive or self-referential structures, use `z.lazy()`. + +--- + +## Step 2: Create Your Worker Agents (`/app/ai/agents/your-agent`) + +Build your specialized worker agents. Each worker should have a single, well-defined responsibility. + +```typescript +// Example: /app/ai/agents/your-agent/parser.agent.ts +import { AiRouter } from '@microfox/ai-router'; +// ... other imports + +const aiRouter = new AiRouter(); + +export const parsingAgent = aiRouter.agent('/', async (ctx) => { + const { html, url } = ctx.request.params; + // ... perform pure, logic-based parsing ... + return { emails, socials, otherLinks }; +}); +``` + +--- + +## Step 3: Create Your Orchestrator (`/app/ai/agents/your-agent/index.ts`) + +Build your orchestrator agent. This agent will define the high-level workflow and call your worker agents using `ctx.next.callAgent()`. + +```typescript +// Example: /app/ai/agents/your-agent/index.ts +import { AiRouter } from '@microfox/ai-router'; +import { scrapingAgent } from './scraping.agent'; +import { parsingAgent } from './parsing.agent'; + +const aiRouter = new AiRouter(); + +export const yourOrchestratorAgent = aiRouter + .agent('/scrape', scrapingAgent) + .agent('/parse', parsingAgent) + .agent('/', async (ctx) => { + // ... orchestrator logic ... + const scrapeResult = await ctx.next.callAgent('/scrape', { url: '...' }); + // ... + const parseResult = await ctx.next.callAgent('/parse', { html: '...' }); + // ... + }); +``` + +--- + +## Step 4: Expose as a Tool (`actAsTool`) + +Use `.actAsTool()` to expose your orchestrator as a tool to the main AI. Be thoughtful about the `outputSchema` to minimize token usage. + +```typescript +// In your orchestrator file, e.g., /app/ai/agents/your-agent/index.ts +export const yourOrchestratorAgent = new AiRouter() + // ... agent definitions ... + .actAsTool('/', { + id: 'yourToolId', + name: 'Your Tool Name', + description: 'A detailed description of what this tool does.', + inputSchema: yourInputSchema, // Defined in schema.ts + outputSchema: z.object({ status: z.string() }), // Keep it concise! + }); +``` + +--- + +## Step 5: Integrate into the Main Router (`/app/ai/index.ts`) + +Finally, attach your new agent to the main `aiRouter` and update the main AI's prompts to teach it how and when to use your new tool. + +```typescript +// /app/ai/index.ts +import { yourOrchestratorAgent } from './agents/your-agent'; + +const aiRouter = new AiRouter(); + +const aiMainRouter = aiRouter + .agent('/your-agent', yourOrchestratorAgent) + // ... other agents + +// ... in the main AI agent ... +const { object: analysis, usage } = await generateObject({ + model: google('gemini-2.5-pro'), + tools: { + ...ctx.next.agentAsTool('/your-agent'), // Make the tool available + }, + prompt: `Based on the user's request, decide which tool to use.`, +}); +``` \ No newline at end of file diff --git a/examples/contact-extractor-agent/.cursor/rules/03-state-management.mdc b/examples/contact-extractor-agent/.cursor/rules/03-state-management.mdc new file mode 100644 index 0000000..1086bd0 --- /dev/null +++ b/examples/contact-extractor-agent/.cursor/rules/03-state-management.mdc @@ -0,0 +1,80 @@ +--- +glob: '**/app/ai/**/*.ts' +description: 'Best practices for state management in `ai-router`, covering `ctx.state`, token optimization, and safe parallelism.' +alwaysApply: true +--- + +# 03: State Management & Advanced Patterns + +`ctx.state` is a powerful tool for sharing data between agents, but it must be used with care to avoid bugs and maintain a clean architecture. + +--- + +## The Three Ways to Execute Agents + +There are three distinct ways to execute an agent, and each has a different implication for how you should handle return values and state. + +1. **`ctx.next.callAgent()` (Internal Call)** + - **Description**: This is for server-side, inter-agent communication. One agent calls another. + - **Return Value**: Can return large, complex data objects. Since the data never leaves the server, there are no token costs. + - **Pathing**: Supports nested paths (`'/sub-agent'`) but **not** backtracking (`'../'`). For root-based paths from deeply nested agents, use the `'@'` alias (e.g., `'@/main-agent/worker'`). + +2. **`ctx.next.agentAsTool()` (Exposing to AI)** + - **Description**: This exposes an agent as a "tool" that the main orchestrating AI can choose to call. + - **Return Value**: **MUST** be minimal. The entire output is serialized to JSON and sent back to the AI, which consumes a large number of tokens. The best practice is to save the rich output to `ctx.state` and return only a simple status object (e.g., `{ status: 'Completed' }`). + +3. **HTTP API Call (GET Request)** + - **Description**: Every agent is also a standard API endpoint that can be called via a GET request. The URL structure is `/api/studio/chat/agent/{your-agent-path}`. All input parameters must be passed as URL query parameters. + - **Return Value**: Must return all the data the client needs, as the client does not have access to the server-side `ctx.state`. + - **Example**: + + ```typescript + const contactId = '123'; + const urls = ['https://example.com', 'https://another.com']; + const response = await fetch( + `/api/studio/chat/agent/extract/deep-persona?contactId=${contactId}&urls=${urls.join(',')}`, + ); + + const result = await response.json(); + + const agentResponse = result[0]?.parts[0]?.output; + console.log(agentResponse); + ``` + +--- + +## The Golden Rules of State Management + +1. **State is for Sharing and Cost Reduction**: The primary purpose of `ctx.state` is to allow a sequence of agents to share rich data _on the server_ without passing it back to the AI. This is a critical pattern for reducing token costs. + +2. **The Orchestrator Owns the Master State**: The main orchestrator (typically in `/app/ai/index.ts`) is responsible for initializing and managing the "master" state for the entire user session. + +3. **Design State for Parallelism**: This is the most critical rule. When you call multiple agents in parallel, they will all share the _same_ `ctx.state` object. Your state must be designed to be "additive" to prevent race conditions. + - **Use `Set` for unique lists**: If multiple parallel workers are adding to a list of visited URLs, use a `Set`. Multiple workers can safely call `ctx.state.visitedUrls.add(url)` without overwriting each other. + - **Use arrays for results**: If workers are adding results to a list, they can safely `push` to the same array. + - **Avoid simple properties**: Do not have parallel workers trying to update the same simple property (e.g., `ctx.state.status = 'in-progress'`). This will lead to race conditions. + +4. **Isolate When Necessary**: If a worker agent needs to manage its own internal state during a complex, multi-step operation (like a recursive search), it should use local variables, not `ctx.state`. It should then return a self-contained result that the orchestrator can safely merge back into the master state. + +--- + +### Example: State-Safe Parallelism + +```typescript +// In the Orchestrator +ctx.state.visitedUrls = new Set(); // Use a Set for safe parallel adds +const promises = urls.map((url) => + ctx.next.callAgent('/worker', { + url, + masterVisitedUrls: Array.from(ctx.state.visitedUrls), + }), +); +const results = await Promise.all(promises); + +for (const result of results) { + if (result.ok) { + // Safely merge results + result.data.visitedUrls.forEach((url) => ctx.state.visitedUrls.add(url)); + } +} +``` diff --git a/examples/contact-extractor-agent/.cursor/rules/04-building-agent-uis.mdc b/examples/contact-extractor-agent/.cursor/rules/04-building-agent-uis.mdc new file mode 100644 index 0000000..fd8a1c3 --- /dev/null +++ b/examples/contact-extractor-agent/.cursor/rules/04-building-agent-uis.mdc @@ -0,0 +1,114 @@ +--- +glob: "**/components/ai/**/*.{ts,tsx}" +description: "A guide to building type-safe UIs for `ai-router` agents, including the `aiComponentMap`, component structure, and using `AiRouterTools`." +alwaysApply: true +--- +# 04: Building Agent UIs + +The `ai-router` provides a powerful system for creating type-safe, component-based UIs for your agents. + +--- + +## 1. The `aiComponentMap` + +The file `/components/ai/index.tsx` is the central registry for all agent UIs. It exports an `aiComponentMap` object that maps the `id` of a tool (from `.actAsTool()`) to the React component that should render its output. + +```typescript +// /components/ai/index.tsx +'use client'; + +import { yourAgentComponentMap } from "./your-agent"; +import { anotherAgentComponentMap } from "./another-agent"; + +export const aiComponentMap = { + tools: { + ...yourAgentComponentMap, + ...anotherAgentComponentMap, + }, +}; +``` + +--- + +## 2. UI Component Structure + +For each agent, you should create a corresponding directory in `/components/ai`. Inside this directory, an `index.ts` file will define the mapping for that specific agent's components. + +A tool's UI can have multiple parts: +- `full`: The main component for rendering the tool's output. This is the most common part you will define. +- `header_sticky`: A component that can stick to the top of the message UI. +- `footer_sticky`: A component that can stick to the bottom of the message UI. + +```typescript +// /components/ai/your-agent/index.ts +import { YourAgentDashboard } from './Dashboard'; + +export const yourAgentComponentMap = { + yourToolId: { // This ID must match the 'id' in actAsTool + full: YourAgentDashboard, // The main UI component + }, +}; +``` + +--- + +## 3. `actAsTool` Metadata + +The `metadata` object in the `.actAsTool()` configuration is how you control the appearance of your tool in the UI *before* it's rendered. + +- `title`: The main title of the tool shown in the UI. +- `parentTitle`: If provided, this will group the tool under a category. For example, multiple search tools could have a `parentTitle` of "Web Search". +- `icon`: A URL to the icon for the tool. + +```typescript +// In your orchestrator agent, e.g., /app/ai/agents/your-agent/index.ts +.actAsTool('/', { + id: 'yourToolId', + // ... other properties + metadata: { + icon: 'https://.../your-icon.svg', + title: 'Your Tool Name', + parentTitle: 'Your Category', + }, +}); +``` + +--- + +## 4. Type-Safe UI Components + +To connect your UI components to the backend schemas, the main orchestrator (`/app/ai/index.ts`) must export a generated `AiRouterTools` type. Your UI components can then import this type to get full type safety and autocompletion for the tool's output. + +**Step 1: Export the type from the orchestrator.** +```typescript +// /app/ai/index.ts +const aiRouterRegistry = aiMainRouter.registry(); +const aiRouterTools = aiRouterRegistry.tools; +// This is a magical generated type that understands all your tools +type AiRouterTools = InferUITools; +export { aiRouterTools, type AiRouterTools }; +``` + +**Step 2: Import the type and use it in your component.** +```typescript +// /components/ai/your-agent/Dashboard.tsx +import { AiRouterTools } from '@/app/ai'; +import { ComponentType } from 'react'; +import { ToolUIPart } from 'ai'; + +// Use the generated type to create a type-safe prop for your component +export const YourAgentDashboard: ComponentType<{ + tool: ToolUIPart>; +}> = (props) => { + const { tool } = props; + + // Now, 'tool.output' is fully typed based on your Zod schema! + const { status, results } = tool.output; + + return ( +
+ {/* ... your rendering logic ... */} +
+ ); +}; +``` \ No newline at end of file diff --git a/examples/contact-extractor-agent/.cursor/rules/05-debugging.mdc b/examples/contact-extractor-agent/.cursor/rules/05-debugging.mdc new file mode 100644 index 0000000..9fd82c5 --- /dev/null +++ b/examples/contact-extractor-agent/.cursor/rules/05-debugging.mdc @@ -0,0 +1,71 @@ +--- +glob: "**/ai/**/*.{ts,tsx,json}" +description: "A playbook for debugging common `ai-router` issues, including AI/schema errors, state management problems, and Next.js environment issues." +alwaysApply: true +--- +# 05: Debugging Playbook + +This document provides a playbook for debugging common issues encountered when developing with `ai-router`. + +--- + +## AI & Schema Errors + +* **Error**: `AI_NoObjectGeneratedError` or Zod validation errors (e.g., `Invalid input: expected object, received array`). + * **Cause**: This is almost always a **prompt engineering problem**. The AI is returning data in a shape that doesn't match your Zod schema. + * **Solution**: + 1. Make your prompt more explicit. If you want a `contact` object, the prompt must clearly instruct the AI to "extract the contact information." + 2. Add a `system` prompt that defines the AI's role and tells it to be meticulous about schemas. + 3. If the issue persists, simplify your schema. Sometimes, asking for a complex nested object is less reliable than asking for a flatter structure. + +* **Error**: The AI is "hallucinating" or inventing data (e.g., creating fake URLs). + * **Cause**: The prompt is not specific enough and lacks clear constraints. + * **Solution**: Add a **"Critical Rule"** section to your prompt with explicit negative constraints. + ``` + # Critical Rule + You MUST only select URLs from the "Links" sections provided in the content below. Do not create, modify, guess, or assume any URLs. Your job is to select from the existing list, not to invent. + ``` + +--- + +## State & Logic Errors + +* **Error**: Data is getting mixed up between different parallel runs; results are inconsistent. + * **Cause**: You have a **state contamination** or **race condition** problem. You are likely calling stateful worker agents in parallel with a shared `ctx`. + * **Solution**: Adhere to the Orchestrator/Worker pattern for parallelism. The orchestrator manages the master state. The workers it calls in parallel **must be stateless**. They should receive all data as input parameters and return a self-contained result. The orchestrator is then responsible for safely merging the results back into its master state. + +* **Error**: High token usage is causing high costs or slow performance. + * **Cause**: You are likely returning too much data from a tool call that is being passed to the main orchestrating AI. + * **Solution**: Remember the dual nature of agents. Use `ctx.state` to share large amounts of data between agents *on the server*. The final tool call that returns to the AI should have a minimal `outputSchema` and only return a simple status update (e.g., `{ status: 'Completed' }`). + +--- + +## Environment & Server-Side Errors (Next.js) + +* **Error**: Build fails with errors related to server-side packages like `puppeteer` or `jsdom`. + * **Cause**: Next.js, by default, tries to bundle all packages for the client-side, which fails for packages that rely on Node.js APIs. + * **Solution**: Add the problematic package to the `serverExternalPackages` array in your `next.config.ts` file. This tells Next.js to leave it out of the client bundle. + ```javascript + // next.config.ts + const nextConfig: NextConfig = { + serverExternalPackages: [ + 'puppeteer', + 'puppeteer-extra', + 'puppeteer-extra-plugin-stealth', + ], + }; + ``` + +* **Error**: You need to use a server-side-only package or function within an agent that is called from the client. + * **Cause**: The `ai-router` files are often executed on both the client and the server. + * **Solution**: Create a separate "wrapper" file for your server-side logic and mark it with the `'use server'` directive. Then, import and call this server-side function from your agent. This ensures that the code will only ever execute on the server. + ```typescript + // /helpers/your-server-helper.ts + 'use server' + + import { JSDOM } from 'jsdom'; + + export async function performHeavyServerTask(html: string) { + return new JSDOM(html); + } + ``` \ No newline at end of file diff --git a/examples/contact-extractor-agent/.gitignore b/examples/contact-extractor-agent/.gitignore new file mode 100644 index 0000000..aa81a57 --- /dev/null +++ b/examples/contact-extractor-agent/.gitignore @@ -0,0 +1,47 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# microfox +/.chat/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +tmp \ No newline at end of file diff --git a/examples/contact-extractor-agent/CHANGELOG.md b/examples/contact-extractor-agent/CHANGELOG.md new file mode 100644 index 0000000..33c0411 --- /dev/null +++ b/examples/contact-extractor-agent/CHANGELOG.md @@ -0,0 +1,15 @@ +# next-perplexity-clone + +## 0.1.2-beta.0 + +### Patch Changes + +- Updated dependencies [07d4a18] + - @microfox/ai-router@2.0.1-beta.0 + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [3dc4cc4] + - @microfox/ai-router@2.0.0 diff --git a/examples/contact-extractor-agent/README.md b/examples/contact-extractor-agent/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/examples/contact-extractor-agent/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/contact.agent.ts b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/contact.agent.ts new file mode 100644 index 0000000..9e35ec2 --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/contact.agent.ts @@ -0,0 +1,232 @@ +import { AiRouter } from '@microfox/ai-router'; +import { Contact } from './helpers/schema'; +import { z } from 'zod'; +import { saveContacts, saveContactsToRag } from './helpers/storage'; +import { google } from '@ai-sdk/google'; +import { generateObject } from 'ai'; +import dedent from 'dedent'; +import { contactSchema } from './helpers/schema'; + +const aiRouter = new AiRouter(); + +export const singleContactExtractorAgent = aiRouter + .agent('/', async (ctx) => { + const { + url, + maxDepth, + directive, + masterVisitedUrls, + } = ctx.request.params as { + url: string; + maxDepth: number; + directive: string; + masterVisitedUrls: string[]; + }; + const usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + const visitedUrls = new Set(masterVisitedUrls); + let contactResult: Contact | null = null; + + let urlsToScrape = [url]; + let contactId: string | undefined; + const path: string[][] = []; + + for (let depth = 1; depth <= maxDepth; depth++) { + if (urlsToScrape.length === 0) { + break; + } + + const currentLevelUrls = [...new Set(urlsToScrape)]; + path.push(currentLevelUrls); + + const scrapePromises = currentLevelUrls.map(async (scrapeUrl) => { + if (visitedUrls.has(scrapeUrl)) return null; + visitedUrls.add(scrapeUrl); + + const scrapeResult = await ctx.next.callAgent('@/extract/scrape', { + url: scrapeUrl, + }); + if (!scrapeResult.ok) return null; + + const { + data: { data: pageData }, + } = scrapeResult as any; + if (typeof pageData.html !== 'string') return null; + + const parseResult = await ctx.next.callAgent('@/extract/parse', { + html: pageData.html, + url: scrapeUrl, + jsContent: pageData.jsContent, + cssContent: pageData.cssContent, + }); + if (!parseResult.ok) return null; + + return { + url: scrapeUrl, + ...parseResult.data, + title: pageData.title, + description: pageData.description, + }; + }); + + const scrapedPages = (await Promise.all(scrapePromises)).filter( + (p): p is NonNullable => p !== null, + ); + if (scrapedPages.length === 0) break; + + const combinedContent = scrapedPages + .map( + (page) => ` + --- + URL: ${page.url} + Title: ${page.title} + Description: ${page.description} + Emails: ${JSON.stringify(page.emails)} + Socials: ${JSON.stringify(page.socials)} + Other Links: ${JSON.stringify(page.otherLinks)} + --- + `, + ) + .join('\n'); + + const aiGeneratedContactSchema = contactSchema.omit({ + path: true, + _id: true, + source: true, + persona: true, + }); + + const existingContactInfo = contactResult + ? ` + # Existing Information + \`\`\`json + ${JSON.stringify( + { + name: contactResult.name || 'N/A', + primaryEmail: contactResult.primaryEmail || 'N/A', + emails: contactResult.emails || [], + socials: contactResult.socials || [], + }, + null, + 2, + )} + \`\`\` + ` + : ''; + + const { object: analysis, usage: aiUsage } = await generateObject({ + model: google('gemini-2.5-pro'), + system: dedent` + You are a meticulous and detail-oriented data extraction agent. + Your mission is to analyze web content to find specific contact information about a single individual per request. + You must adhere strictly to the schemas provided and never invent or assume information. If a piece of information is not present, you must omit the field. + You only select URLs from the lists provided in the content. You do not create, modify, or assume any URLs. + `, + schema: z.object({ + contact: aiGeneratedContactSchema.optional(), + nextUrls: z.array(z.string()).optional(), + }), + prompt: dedent` + # Primary Directive + ${directive} + + # Context + I am performing a level-by-level search for a single person, starting from the URL: ${url}. + I am currently at depth ${depth}. + ${existingContactInfo} + + # Task + Analyze the combined content from all pages scraped at this depth. Your goal is twofold: + 1. **Extract/Update Contact Details**: Identify, extract, or update the contact information for the individual most relevant to the Primary Directive. If existing information is provided, enrich it. + 2. **Identify Next URLs**: From the links provided, select URLs for the next level of scraping that are highly likely to contain more contact details **ABOUT THE SAME INDIVIDUAL**. + + # Rules + - **DO NOT GUESS**: If you cannot find a piece of information, do not include the field in your response. + - **URLS ARE SACRED**: You MUST only select URLs from the "Other Links" sections in the content below. Do not generate or modify URLs. + - **STAY FOCUSED**: All information must pertain to the individual being tracked from the initial candidate URL. + + # Combined Content from Scraped Pages + ${combinedContent} + `, + }); + + if (aiUsage) { + usage.inputTokens += aiUsage.inputTokens ?? 0; + usage.outputTokens += aiUsage.outputTokens ?? 0; + usage.totalTokens += aiUsage.totalTokens ?? 0; + } + + if (analysis.contact) { + const updatedContact: Contact = { + ...(contactResult || {}), + ...analysis.contact, + source: url, + _id: contactId, + path, + }; + + if (contactId) { + // Update existing contact + await saveContacts([{ ...updatedContact, _id: contactId }]); + contactResult = updatedContact; + } else { + // Create new contact + const [savedId] = await saveContacts([updatedContact]); + if (savedId) { + contactId = savedId.toString(); + updatedContact._id = contactId; + contactResult = updatedContact; + } + } + if(contactResult) await saveContactsToRag([contactResult]); + } + + if ( + contactResult?.primaryEmail || + (contactResult?.emails && contactResult.emails.length > 0) + ) { + if (!contactResult.path) contactResult.path = path; + await saveContacts([contactResult]); + await saveContactsToRag([contactResult]); + break; // Email found, exit loop + } + + urlsToScrape = analysis.nextUrls || []; + } + + if (contactResult) { + contactResult.path = path; + await saveContacts([contactResult]); + await saveContactsToRag([contactResult]); + } + + return { + contact: contactResult, + usage, + visitedUrls: Array.from(visitedUrls), + }; + }) + .actAsTool('/', { + id: 'singleContactExtractor', + name: 'Single Contact Extractor', + description: + 'Analyzes a single URL to extract a detailed, structured contact information of the person or entity featured on the page.', + inputSchema: z.object({ + url: z.string().url(), + maxDepth: z.number(), + directive: z.string(), + masterVisitedUrls: z.array(z.string().url()), + }), + outputSchema: z.object({ + contact: contactSchema.nullable(), + usage: z.object({ + inputTokens: z.number(), + outputTokens: z.number(), + totalTokens: z.number(), + }), + visitedUrls: z.array(z.string().url()), + }), + metadata: { + icon: 'https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/perplexity-icon.svg', + title: 'Single Contact Extractor', + }, + }); diff --git a/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/deepPersona.agent.ts b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/deepPersona.agent.ts new file mode 100644 index 0000000..fa06fe4 --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/deepPersona.agent.ts @@ -0,0 +1,109 @@ +import { z } from 'zod'; +import { AiRouter } from '@microfox/ai-router'; +import { google } from '@ai-sdk/google'; +import { generateObject } from 'ai'; +import { jsDom } from './helpers/jsDom'; +import { personaAnalysisSchema, Contact, contactSchema } from './helpers/schema'; +import dedent from 'dedent'; +import { saveContacts, saveContactsToRag, getContactById } from './helpers/storage'; + +const aiRouter = new AiRouter(); + +export const deepPersonaAgent = aiRouter + .agent('/', async (ctx) => { + const { contactId, urls } = ctx.request.params as { contactId: string, urls: string }; + try { + const contact = await getContactById(contactId); + if (!contact) { + throw new Error(`Contact with ID ${contactId} not found.`); + } + + const scrapePromises = urls?.split(',').map(async (url) => { + const scrapeResult = await ctx.next.callAgent('@/extract/scrape', { + url: url, + }); + if (!scrapeResult.ok) return null; + + const { + data: { data: pageData }, + } = scrapeResult as any; + if (typeof pageData.html !== 'string') return null; + + const html = pageData?.html + if (typeof html !== 'string') { + console.error(`No HTML content for ${url}`); + return null; + } + const dom = await jsDom(html); + return dom.window.document.body.textContent || ''; + }); + + const scrapedContents = (await Promise.all(scrapePromises)) + .filter((content): content is string => content !== null) + .map(content => content.substring(0, 20000)); // Limit each page's content + + // console.log("Scraped contents:", scrapedContents); + + const combinedText = scrapedContents.join('\n\n---\n\n'); + + console.log("Combined text:", combinedText); + + const { object: persona, usage } = await generateObject({ + model: google('gemini-2.5-pro'), + schema: personaAnalysisSchema, + prompt: dedent` + Your task is to analyze the text content from multiple webpages belonging to a person and extract key details to build a comprehensive persona. + Focus exclusively on the subject of the page and ignore any generic, boilerplate text about the platform hosting it (like GitHub's navigation, Twitter's UI elements, etc.). Your analysis should only include information about the person. + If you cannot find a specific piece of information, respond with "N/A". Do not guess any information. + + Here is the existing persona information for context. Your job is to enrich this with new details from the content below: + ${JSON.stringify(contact.persona, null, 2)} + + Webpage content: + ${combinedText} + `, + }); + + console.log("Persona:", persona); + + const updatedContact: Contact = { + ...contact, + persona: { + ...contact.persona, + ...persona, + } + }; + + console.log("Updated contact:", updatedContact); + + await saveContacts([updatedContact]); + await saveContactsToRag([updatedContact]); + return { contact: updatedContact, usage, status: 'success' }; + + } catch (error) { + console.error("Failed to analyze persona.", error); + return { error: 'Failed to analyze persona.', status: 'error' }; + } + }) + .actAsTool('/', { + id: 'deepPersonaAnalyzer', + name: 'Deep Persona Analyzer', + description: 'Analyzes multiple URLs for a given contact to create a detailed, structured persona and updates the contact record.', + inputSchema: z.object({ + contactId: z.string().describe('The MongoDB ObjectId of the contact to update.'), + urls: z.array(z.string().url()).describe('The URLs to analyze (e.g., portfolio, GitHub, LinkedIn).'), + }), + outputSchema: contactSchema.extend({ + status: z.enum(['success', 'error']), + error: z.string().optional(), + usage: z.object({ + inputTokens: z.number(), + outputTokens: z.number(), + totalTokens: z.number(), + }), + }), + metadata: { + icon: 'https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/perplexity-icon.svg', + title: 'Deep Persona Analyzer', + }, + }); diff --git a/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/jsDom.ts b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/jsDom.ts new file mode 100644 index 0000000..223fe9e --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/jsDom.ts @@ -0,0 +1,7 @@ +'use server' + +import { JSDOM } from 'jsdom'; + +export async function jsDom(html: string) { + return new JSDOM(html); +} diff --git a/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/schema.ts b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/schema.ts new file mode 100644 index 0000000..927e42e --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/schema.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; + +export const personaAnalysisSchema = z.object({ + name: z.string().optional().describe('The full name of the person.'), + profession: z + .string() + .optional() + .describe('The primary profession or job title.'), + age: z.string().optional().describe('The estimated age or age range.'), + summary: z + .string() + .optional() + .describe( + 'A brief, one-paragraph summary of the person based on the content.', + ), + interests: z + .array(z.string()) + .optional() + .describe('A list of key interests, skills, or hobbies.'), + location: z.string().optional().describe('The city, state, or country.'), +}); + +export const contactSchema = z.object({ + _id: z.string().optional(), + source: z.string(), + name: z.string().optional(), + primaryEmail: z.string().optional(), + emails: z.array(z.string()).optional(), + socials: z + .object({ + linkedin: z.string().optional(), + github: z.string().optional(), + twitter: z.string().optional(), + portfolio: z.string().optional(), + }) + .optional(), + path: z + .array(z.array(z.string().url())) + .optional() + .describe( + 'A nested array representing the navigation path. Each inner array contains the URLs scraped at that depth.', + ), + persona: personaAnalysisSchema.optional(), +}); + +export const agentInputSchema = z.object({ + urls: z + .array(z.string().url()) + .describe('An array of seed URLs to start the extraction process.'), + directive: z + .string() + .describe( + 'A specific instruction for the agent (e.g., "Find contact info for the founders of Vercel").', + ), + maxContacts: z + .number() + .optional() + .describe('The maximum number of contacts to extract. Defaults to 5.'), +}); + +export type Contact = z.infer; + diff --git a/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/scrapper.ts b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/scrapper.ts new file mode 100644 index 0000000..5857d09 --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/scrapper.ts @@ -0,0 +1,108 @@ +'use server' + +import puppeteer from 'puppeteer-extra'; +import StealthPlugin from 'puppeteer-extra-plugin-stealth'; + +puppeteer.use(StealthPlugin()); + +async function fetchResourceContent(url: string): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if ( + !response.ok || + !response.headers.get('content-type')?.match(/text|javascript|css/) + ) { + return null; + } + return await response.text(); + } catch (error) { + // Suppress fetch errors for non-essential resources + return null; + } +} + +export async function scrapper(url: string) { + const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }); + const page = await browser.newPage(); + try { + await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); + + const pageData = await page.evaluate(() => { + const html = document.documentElement.outerHTML; + const title = document.title; + const description = + ( + document.querySelector( + 'meta[name="description"]', + ) as HTMLMetaElement + )?.content || ''; + + const scriptSrcs = Array.from( + document.querySelectorAll('script[src]'), + ).map((s) => s.src); + const styleHrefs = Array.from( + document.querySelectorAll( + 'link[rel="stylesheet"][href]', + ), + ).map((l) => l.href); + + return { html, title, description, scriptSrcs, styleHrefs }; + }); + + const jsUrls = [ + ...new Set( + pageData.scriptSrcs + .map((src: string) => { + try { + return new URL(src, url).toString(); + } catch { + return null; + } + }) + .filter((u: string | null): u is string => u !== null), + ), + ]; + + const cssUrls = [ + ...new Set( + pageData.styleHrefs + .map((href: string) => { + try { + return new URL(href, url).toString(); + } catch { + return null; + } + }) + .filter((u: string | null): u is string => u !== null), + ), + ]; + + const jsContentPromises = jsUrls.map(fetchResourceContent as any); + const cssContentPromises = cssUrls.map(fetchResourceContent as any); + + const jsContent = (await Promise.all(jsContentPromises)).filter( + (c): c is string => c !== null, + ) || []; + const cssContent = (await Promise.all(cssContentPromises)).filter( + (c): c is string => c !== null, + ) || []; + + return { + data: { + html: pageData.html, + url, + title: pageData.title, + description: pageData.description, + jsContent, + cssContent, + }, + }; + } + catch (error) { + console.error(`Failed to scrape ${url}:`, error); + return null; + } + finally { + await browser.close(); + } +} diff --git a/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/storage.ts b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/storage.ts new file mode 100644 index 0000000..e7e3fe8 --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/helpers/storage.ts @@ -0,0 +1,200 @@ +'use server' + +import { MongoClient, ObjectId } from 'mongodb'; +import { RagUpstashSdk } from '@microfox/rag-upstash'; +import type { Contact } from './schema'; + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017'; +const DB_NAME = 'contact-extractor'; +const COLLECTION_NAME = 'contacts'; + +let client: MongoClient | null = null; + +async function getClient() { + if (!client) { + client = new MongoClient(MONGODB_URI); + await client.connect(); + } + return client; +} + +function ensureEmailConsistency(contact: Contact): Contact { + const emailsSet = new Set(contact.emails || []); + if (contact.primaryEmail) { + emailsSet.add(contact.primaryEmail); + } + + const newEmails = Array.from(emailsSet); + let newPrimaryEmail = contact.primaryEmail; + + if (newEmails.length > 0 && !newPrimaryEmail) { + newPrimaryEmail = newEmails[0]; + } + + return { + ...contact, + emails: newEmails, + primaryEmail: newPrimaryEmail, + }; +} + +export async function saveContacts(contacts: Contact[]) { + if (!process.env.MONGODB_URI) { + console.warn('MONGO_URI not set, skipping database save.'); + return []; + } + if (contacts.length === 0) return []; + + const client = await getClient(); + const db = client.db(DB_NAME); + const collection = db.collection(COLLECTION_NAME); + + try { + const consistentContacts = contacts.map(ensureEmailConsistency); + + const operations = consistentContacts.map((contact) => { + const { _id, ...contactData } = contact; + return { + updateOne: { + filter: { _id: _id ? new ObjectId(_id) : new ObjectId() }, + update: { $set: contactData }, + upsert: true, + }, + }; + }); + + if (operations.length === 0) return []; + + const result = await collection.bulkWrite(operations); + console.log('Contacts saved to MongoDB:', result); + return Object.values(result.upsertedIds); + } catch (error) { + console.error('Error saving contacts to MongoDB:', error); + return []; + } +} + +export async function saveContactsToRag(contacts: Contact[]) { + if ( + !process.env.UPSTASH_VECTOR_REST_URL || + !process.env.UPSTASH_VECTOR_REST_TOKEN + ) { + console.warn('Upstash credentials not set, skipping RAG save.'); + return; + } + if (contacts.length === 0) return; + + try { + const rag = new RagUpstashSdk({ + upstashUrl: process.env.UPSTASH_VECTOR_REST_URL, + upstashToken: process.env.UPSTASH_VECTOR_REST_TOKEN, + }); + + const consistentContacts = contacts.map(ensureEmailConsistency); + + const ragData = consistentContacts.map((contact) => { + let doc = `--- ${contact.name || 'Unknown Person'}'s Information ---`; + + if (contact.persona) { + const persona = contact.persona + doc += `--- Identity ---\nName: ${persona.name || 'N/A'}\nProfession: ${persona.profession || 'N/A'}\nAge: ${persona.age || 'N/A'}\nLocation: ${persona.location || 'N/A'}\nSummary: ${persona.summary || 'N/A'}\nInterests: ${persona.interests?.join(', ') || 'N/A'}\n\n`; + } + + doc += `--- Contact ---\nName: ${contact.name || 'N/A'}\nEmails: ${contact.emails?.join(', ') || 'N/A' + }\nSocials: ${JSON.stringify(contact.socials) || 'N/A'}\nSource: ${contact.source + }`; + + return { + id: (contact as any)._id?.toString() || new ObjectId().toString(), + doc, + metadata: contact, + }; + }); + + await rag.feedDocsToRAG(ragData); + + console.log(`${ragData.length} contacts saved to RAG.`); + } catch (error) { + console.error('Error saving contacts to RAG:', error); + } +} + +export async function getContacts() { + if (!process.env.MONGODB_URI) { + console.warn('MONGO_URI not set, skipping database fetch.'); + return []; + } + + const client = await getClient(); + const db = client.db(DB_NAME); + const collection = db.collection(COLLECTION_NAME); + + try { + const contacts = await collection.find({}).toArray(); + return contacts.map(contact => ({ ...contact, _id: contact._id.toString() })); + } catch (error) { + console.error('Error fetching contacts from MongoDB:', error); + return []; + } +} + +export async function searchContacts(query: string, topK: number = 10) { + if ( + !process.env.UPSTASH_VECTOR_REST_URL || + !process.env.UPSTASH_VECTOR_REST_TOKEN + ) { + console.warn('Upstash credentials not set, skipping RAG search.'); + return []; + } + + try { + const rag = new RagUpstashSdk({ + upstashUrl: process.env.UPSTASH_VECTOR_REST_URL, + upstashToken: process.env.UPSTASH_VECTOR_REST_TOKEN, + }); + + const results = await rag.query({ + data: query, + topK: topK, + includeMetadata: true, + }); + + return results.map((result: any) => ({ ...result.metadata, score: result.score })); + + } catch (error) { + console.error('Error searching contacts from RAG:', error); + return []; + } +} + +export async function getContactById(contactId: string): Promise { + const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017'; + const DB_NAME = 'contact-extractor'; + const COLLECTION_NAME = 'contacts'; + + if (!process.env.MONGODB_URI) { + console.warn('MONGO_URI not set, skipping database fetch.'); + return null; + } + + let client: MongoClient | null = null; + try { + client = new MongoClient(MONGODB_URI); + await client.connect(); + const db = client.db(DB_NAME); + const collection = db.collection(COLLECTION_NAME); + const contact = await collection.findOne({ _id: new ObjectId(contactId) }); + + if (contact) { + return { ...contact, _id: contact._id.toString() } as unknown as Contact; + } + return null; + } catch (error) { + console.error('Error fetching contact by ID:', error); + return null; + } finally { + if (client) { + await client.close(); + } + } +} \ No newline at end of file diff --git a/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/index.ts b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/index.ts new file mode 100644 index 0000000..3ba3f5a --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/index.ts @@ -0,0 +1,235 @@ +import { AiRouter } from '@microfox/ai-router'; +import { z } from 'zod'; +import dedent from 'dedent'; +import { generateObject } from 'ai'; +import { google } from '@ai-sdk/google'; +import { jsDom } from './helpers/jsDom'; +import { contactSchema } from './helpers/schema'; +import { agentInputSchema, Contact } from './helpers/schema'; +import { singleContactExtractorAgent } from './contact.agent'; +import { scrapingAgent } from './scraping.agent'; +import { parsingAgent } from './parsing.agent'; +import { deepPersonaAgent } from './deepPersona.agent'; + +export const contactExtractorAgent = new AiRouter() + .agent('/scrape', scrapingAgent) + .agent('/parse', parsingAgent) + .agent('/contact', singleContactExtractorAgent) + .agent('/deep-persona', deepPersonaAgent) + .agent('/', async (ctx) => { + try { + const { urls, directive } = ctx.request.params; + const maxContacts = Number(ctx.request.params.maxContacts) || 5; + const maxDepth = 5; + + ctx.state = { + directive, + initialUrls: urls, + visitedUrls: new Set(), + contacts: [], + progress: 0, + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }; + + // Step 1: Scrape initial URLs to find candidate links + const initialScrapePromises = urls.map(async (url: string) => { + if (ctx.state.visitedUrls.has(url)) return null; + ctx.state.visitedUrls.add(url); + + console.log(`Initial scrape for candidate selection: ${url}`); + const scrapeResult = await ctx.next.callAgent('/scrape', { url }); + if (!scrapeResult.ok) return null; + + const { + data: { + data: { html, title, description }, + }, + } = scrapeResult as { + ok: true; + data: { + data: { + html: string; + url: string; + title: string; + description: string; + }; + }; + }; + if (typeof html !== 'string') return null; + + const dom = await jsDom(html); + const links = Array.from(dom.window.document.querySelectorAll('a')) + .map((a) => { + try { + return new URL(a.href, url).toString(); + } catch (e) { + return null; + } + }) + .filter( + (link): link is string => + link !== null && + !link.startsWith('javascript:') // && + // link.includes('github.com'), + ); + + return { url, pageTitle: title, pageDescription: description, links }; + }); + + const initialPages = (await Promise.all(initialScrapePromises)).filter( + (page): page is NonNullable => Boolean(page), + ); + if (initialPages.length === 0) { + return { + status: 'error', + error: 'Could not scrape any of the initial URLs.', + }; + } + + // Step 2: Use AI to select the best candidate URLs + const combinedInitialContent = initialPages + .map( + (page) => ` + --- + URL: ${page.url} + Title: ${page.pageTitle} + Description: ${page.pageDescription} + Links: + ${page.links.join('\n')} + --- + `, + ) + .join('\n'); + + const { object: candidateAnalysis, usage } = await generateObject({ + model: google('gemini-2.5-pro'), + system: dedent` + You are an expert web reconnaissance analyst. + Your specialty is identifying the most promising paths to find contact information for specific individuals based on initial web page scans. + You are ruthlessly efficient and prioritize URLs that are most likely to lead to a person's direct contact details or professional profiles. + `, + schema: z.object({ + candidateUrls: z + .array(z.string()) + .max(maxContacts || 5) + .describe( + 'An array of URLs that are most likely to lead to contact information for people mentioned in the directive.', + ), + }), + prompt: dedent` + # Primary Directive + My goal is to find contact information for people related to: "${directive}" + + # Task + I have scraped the initial URLs and listed their titles, descriptions, and all the links they contain. + Based on the Primary Directive, you must analyze this content and select up to 10 URLs that are the most promising candidates for finding the contact information I'm looking for. + + # Selection Criteria + - **Prioritize**: "About Us", "Team", "Contact", individual portfolio sites, and direct links to social media profiles (LinkedIn, GitHub, Twitter). + - **De-prioritize**: Links to product pages, pricing, general blog posts, or technical documentation unless they explicitly mention a person relevant to the directive. + + # Critical Rule + You MUST only select max ${maxContacts || 5} URLs from the "Links" sections provided in the content below. Do not create, modify, guess, or assume any URLs. Your job is to select from the existing list, not to invent. + + # Scraped Content + ${combinedInitialContent} + `, + }); + ctx.state.usage.inputTokens += usage.inputTokens; + ctx.state.usage.outputTokens += usage.outputTokens; + ctx.state.usage.totalTokens += usage.totalTokens; + + const candidateUrls = [...new Set(candidateAnalysis.candidateUrls || [])]; // Deduplicate + + if (candidateUrls.length === 0) { + ctx.response.writeMessageMetadata({ + text: 'AI could not identify any candidate URLs to explore further from the initial pages.', + }); + return { + status: 'success', + pagesScraped: ctx.state.visitedUrls.size, + contactsFound: 0, + contacts: [], + usage: ctx.state.usage, + }; + } + + ctx.response.writeMessageMetadata({ + text: `AI identified ${candidateUrls.length} candidate URLs to investigate.`, + }); + + const contactPromises = candidateUrls.map(async (url: string) => + await ctx.next.callAgent('/contact', { + url, + maxDepth, + directive, + masterVisitedUrls: Array.from(ctx.state.visitedUrls), + }), + ); + + const results = await Promise.all(contactPromises); + + for (const result of results) { + if (result.ok) { + const { contact, usage, visitedUrls } = result.data; + + if (contact) { + // Avoid adding duplicates if the same contact is found from multiple candidates + const existingContact = ctx.state.contacts.find( + (c: Contact) => c._id === contact._id, + ); + if (!existingContact) { + ctx.state.contacts.push(contact); + } + } + + usage.inputTokens += usage.inputTokens; + usage.outputTokens += usage.outputTokens; + usage.totalTokens += usage.totalTokens; + + visitedUrls.forEach((url: string) => ctx.state.visitedUrls.add(url)); + + if (ctx.state.contacts.length >= maxContacts) { + break; + } + } + } + + console.log('--- End Contact Extractor Agent ---'); + return { + status: 'success', + pagesScraped: ctx.state.visitedUrls.size, + contactsFound: ctx.state.contacts.length, + contacts: ctx.state.contacts, + usage: ctx.state.usage, + }; + } catch (error: any) { + console.error('Error in Contact Extractor Agent:', error); + ctx.response.writeMessageMetadata({ + text: `Error in Contact Extractor Agent: ${error.message}`, + }); + return { status: 'error', error: 'Internal server error' }; + } + }) + .actAsTool('/', { + id: 'contactExtractor', + name: 'Contact Extractor', + description: 'Extracts contact information from a list of seed URLs. It can autonomously navigate up to 3 levels deep to find relevant information based on a user-provided directive.', + inputSchema: agentInputSchema, + outputSchema: z.object({ + status: z.string(), + pagesScraped: z.number(), + contactsFound: z.number(), + contacts: z.array(contactSchema), + usage: z.object({ + inputTokens: z.number(), + outputTokens: z.number(), + totalTokens: z.number(), + }), + }), + metadata: { + icon: 'https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/chrome.svg', + title: 'Contact Extractor', + parent: 'contactExtractor', + }, + }); diff --git a/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/parsing.agent.ts b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/parsing.agent.ts new file mode 100644 index 0000000..dda7ab5 --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/parsing.agent.ts @@ -0,0 +1,134 @@ +import { AiRouter } from '@microfox/ai-router'; +import { jsDom } from './helpers/jsDom'; + +interface ContactInfo { + value: string; + context: string; +} + +function extractEmailsWithContext(text: string): ContactInfo[] { + const emailRegex = /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/gi; + const emails: ContactInfo[] = []; + let match; + while ((match = emailRegex.exec(text)) !== null) { + const startIndex = Math.max(0, match.index - 50); + const endIndex = Math.min(text.length, match.index + match[0].length + 50); + const context = text + .substring(startIndex, endIndex) + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); + emails.push({ value: match[0], context }); + } + return emails; +} + +function extractSocialsWithContext(html: string): ContactInfo[] { + const socialRegex = + /href="(https?:\/\/(?:www\.)?(?:linkedin\.com\/in\/|github\.com\/|twitter\.com\/|behance\.net\/|dribbble\.com\/)[a-zA-Z0-9\._-]+)"/gi; + const socials: ContactInfo[] = []; + let match; + while ((match = socialRegex.exec(html)) !== null) { + const startIndex = Math.max(0, match.index - 100); + const endIndex = Math.min(html.length, match.index + match[0].length + 100); + const context = html + .substring(startIndex, endIndex) + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); + socials.push({ value: match[1], context }); + } + return socials; +} + +function extractOtherLinksWithContext(html: string): ContactInfo[] { + const linkRegex = /href="(https?:\/\/[^"]+)"/gi; + const links: ContactInfo[] = []; + let match; + while ((match = linkRegex.exec(html)) !== null) { + if ( + !/(linkedin\.com|github\.com|twitter\.com|behance\.net|dribbble\.com)/.test( + match[1], + ) && + !/\.(jpg|jpeg|png|gif|svg|ico)$/i.test(match[1]) + ) { + const startIndex = Math.max(0, match.index - 100); + const endIndex = Math.min(html.length, match.index + match[0].length + 100); + const context = html + .substring(startIndex, endIndex) + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); + links.push({ value: match[1], context }); + } + } + return links; +} + +async function fetchResourceContent(url: string): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if ( + !response.ok || + !response.headers.get('content-type')?.match(/text|javascript/) + ) { + return null; + } + return await response.text(); + } catch (error) { + console.error(`Failed to fetch resource ${url}:`, error); + return null; + } +} + +const aiRouter = new AiRouter(); + +export const parsingAgent = aiRouter.agent('/', async (ctx) => { + const { html, url, jsContent, cssContent } = ctx.request.params as { + html: string; + url: string; + jsContent: string[]; + cssContent: string[]; + }; + + if (url.includes('github.com')) { + const dom = await jsDom(html); + const body = dom.window.document.body; + const entryContent = body.querySelector('.entry-content')?.innerHTML; + const vcardDetails = body.querySelector('.vcard-details')?.innerHTML; + const contentToParse = `${entryContent || ''} ${vcardDetails || ''}`; + if (!contentToParse) { + return { emails: [], socials: [], otherLinks: [] }; + } + return { + emails: extractEmailsWithContext(contentToParse), + socials: extractSocialsWithContext(contentToParse), + otherLinks: extractOtherLinksWithContext(contentToParse), + }; + } + + // For non-GitHub URLs, combine all content for a full analysis + const fullContent = [html, ...(jsContent || []), ...(cssContent || [])].join( + '\n', + ); + + const emails = extractEmailsWithContext(fullContent); + const socials = extractSocialsWithContext(fullContent); + // Only parse HTML for "other" links to reduce noise from API endpoints in JS + const otherLinks = extractOtherLinksWithContext(html); + + const uniqueByValue = (arr: ContactInfo[]) => { + const seen = new Set(); + return arr.filter((item) => { + const duplicate = seen.has(item.value); + seen.add(item.value); + return !duplicate; + }); + }; + + return { + emails: uniqueByValue(emails), + socials: uniqueByValue(socials), + otherLinks: uniqueByValue(otherLinks), + }; +}); diff --git a/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/scraping.agent.ts b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/scraping.agent.ts new file mode 100644 index 0000000..c5b073d --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/contactExtractorAgent/scraping.agent.ts @@ -0,0 +1,33 @@ +import { AiRouter } from '@microfox/ai-router'; +import { z } from 'zod'; +import { scrapper } from './helpers/scrapper'; + +const aiRouter = new AiRouter(); + +export const scrapingAgent = aiRouter + .agent('/', async (ctx) => { + const { url } = ctx.request.params as { url: string }; + return await scrapper(url); + }) + .actAsTool('/', { + id: 'contactExtractorScraping', + name: 'Scrape URL', + description: + 'Scrapes a URL and returns its HTML, metadata, and the content of its linked JS and CSS files.', + inputSchema: z.object({ url: z.string() }), + outputSchema: z.object({ + data: z.object({ + html: z.string(), + url: z.string(), + title: z.string(), + description: z.string(), + jsContent: z.array(z.string()), + cssContent: z.array(z.string()), + }), + }), + metadata: { + icon: 'https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/web.svg', + title: 'Scrape URL', + parent: 'contactExtractor', + }, + }); diff --git a/examples/contact-extractor-agent/app/ai/agents/system/index.ts b/examples/contact-extractor-agent/app/ai/agents/system/index.ts new file mode 100644 index 0000000..ddf7d49 --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/agents/system/index.ts @@ -0,0 +1,45 @@ +import { google } from '@ai-sdk/google'; +import { AiRouter } from '@microfox/ai-router'; +import { z } from 'zod'; +import { streamText, convertToModelMessages } from 'ai'; + +const aiRouter = new AiRouter(); + +export const systemAgent = aiRouter + .agent('/current_date', async () => { + return { result: new Date().toLocaleDateString() }; + }) + .actAsTool('/current_date', { + id: 'systemCurrentDate', + name: 'Get Current Date', + description: 'Get the current date.', + inputSchema: z.object({}), + execute: async () => ({ result: new Date().toLocaleDateString() }), + metadata: { + hideUI: true, + }, + }) + .agent('/current_time', async (ctx) => { + const { format } = ctx.request.params; + return { + result: new Date().toLocaleTimeString('en-US', { + hour12: format === '12h', + }), + }; + }) + .actAsTool('/current_time', { + id: 'systemCurrentTime', + name: 'Get Current Time', + description: 'Get the current time in 12-hour or 24-hour format.', + inputSchema: z.object({ + format: z.enum(['12h', '24h']).describe('The desired time format.'), + }), + execute: async ({ format }) => ({ + result: new Date().toLocaleTimeString('en-US', { + hour12: format === '12h', + }), + }), + metadata: { + hideUI: true, + }, + }); diff --git a/examples/contact-extractor-agent/app/ai/index.ts b/examples/contact-extractor-agent/app/ai/index.ts new file mode 100644 index 0000000..66fef12 --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/index.ts @@ -0,0 +1,74 @@ +import { google } from '@ai-sdk/google'; +import { AiRouter } from '@microfox/ai-router'; +import { contactExtractorAgent } from './agents/contactExtractorAgent'; +import { contextLimiter } from './middlewares/contextLimiter'; +import { onlyTextParts } from './middlewares/onlyTextParts'; +import { + convertToModelMessages, + stepCountIs, + streamText, +} from 'ai'; +import dedent from 'dedent'; + +const aiRouter = new AiRouter(); + +// Define the main router without loading the static registry. +// The CLI will use this file as the entry point for building the registry. +const aiMainRouter = aiRouter + .agent('/extract', contactExtractorAgent) + .use('/', contextLimiter(5)) + .use('/', onlyTextParts(100)) + .agent('/', async (props) => { + try { + props.response.writeMessageMetadata({ + loader: 'Deciding...', + }); + + console.log('REQUEST MESSAGES', props.request.messages.length); + const stream = streamText({ + model: google('gemini-2.5-pro'), + system: dedent` + You are a powerful autonomous agent with a specialization in contact extraction. + You have access to a tool that can scrape websites, extract contact information, and navigate to other pages to continue searching. + When the user provides a directive and a set of starting URLs, your job is to use the tool to autonomously find as many relevant contacts as possible. + `, + messages: convertToModelMessages( + props.state.onlyTextMessages || props.request.messages, + ), + tools: { + ...props.next.agentAsTool('/extract'), + }, + toolChoice: 'auto', + stopWhen: [ + stepCountIs(10), + ({ steps }) => + steps.some((step) => + step.toolResults.some( + (tool) => tool.toolName === 'extract', + ), + ), + ], + onError: (error) => { + console.error('ORCHESTRATION ERROR', error); + }, + onFinish: (result) => { + console.log('ORCHESTRATION USAGE', result.totalUsage); + }, + }); + + props.response.merge( + stream.toUIMessageStream({ + sendFinish: false, + sendStart: false, + }), + ); + } catch (error: any) { + console.error('Error in main AI router:', error); + props.response.write({ + type: 'data-error', + data: `An unexpected error occurred in the main router: ${error.message}`, + }); + } + }); + +export default aiMainRouter; diff --git a/examples/contact-extractor-agent/app/ai/middlewares/contextLimiter.ts b/examples/contact-extractor-agent/app/ai/middlewares/contextLimiter.ts new file mode 100644 index 0000000..1181774 --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/middlewares/contextLimiter.ts @@ -0,0 +1,22 @@ +import { AiMiddleware } from '@microfox/ai-router'; + +/** + * Middleware to limit the number of messages in the context + * @param count - From the last message, number of messages to keep + * @returns + */ +export const contextLimiter = (count: number) => { + const middleware: AiMiddleware = async ( + props, + next, + ) => { + const { messages } = props.request; + if (messages.length < count) { + return next(); + } else { + props.request.messages = [...messages.slice(-count)]; + return next(); + } + }; + return middleware; +}; diff --git a/examples/contact-extractor-agent/app/ai/middlewares/onlyTextParts.ts b/examples/contact-extractor-agent/app/ai/middlewares/onlyTextParts.ts new file mode 100644 index 0000000..80a840f --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/middlewares/onlyTextParts.ts @@ -0,0 +1,70 @@ +import { AiMiddleware, getTextParts } from '@microfox/ai-router'; +import { UIMessage } from 'ai'; + +/** + * Middleware to only keep the text parts of the messages and limit the total combined length + * of all assistant message text parts to the specified maximum + * @param maxTotalTextLength - The maximum total length of all assistant message text parts combined + * @returns + */ +export const onlyTextParts = (maxTotalTextLength: number) => { + const middleware: AiMiddleware = async (props, next) => { + const messages = props.request.messages; + + // First, collect all assistant message text parts and calculate total length + const assistantTextParts: string[] = []; + let totalAssistantTextLength = 0; + + messages.forEach((message) => { + if (message.role === 'assistant') { + message.parts + .filter((part) => part.type === 'text') + .forEach((part) => { + assistantTextParts.push(part.text); + totalAssistantTextLength += part.text.length; + }); + } + }); + + // Calculate how much to truncate if we exceed the limit + const truncationNeeded = totalAssistantTextLength > maxTotalTextLength; + const truncationRatio = truncationNeeded + ? maxTotalTextLength / totalAssistantTextLength + : 1; + + const onlyTextMessages: UIMessage[] = messages.map( + (message) => { + if (message.role === 'user') { + // Keep all text parts for user messages + return { + ...message, + parts: message.parts.filter((part) => part.type === 'text'), + }; + } else if (message.role === 'assistant') { + // For assistant messages, truncate text parts proportionally if needed + return { + ...message, + parts: message.parts + .filter((part) => part.type === 'text') + .map((part) => ({ + ...part, + text: truncationNeeded + ? part.text.slice( + 0, + Math.floor(part.text.length * truncationRatio), + ) + : part.text, + })), + }; + } else { + // For other message types, keep as is + return message; + } + }, + ); + + props.state.onlyTextMessages = onlyTextMessages; + return next(); + }; + return middleware; +}; diff --git a/examples/contact-extractor-agent/app/ai/registry.ts b/examples/contact-extractor-agent/app/ai/registry.ts new file mode 100644 index 0000000..1d4b40f --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/registry.ts @@ -0,0 +1,6810 @@ +// This file is auto-generated by the ai-router build command. Do not edit. +export const aiRouterRegistry = { + "map": { + "/extract/scrape": { + "middlewares": [], + "agents": [ + { + "handler": "anonymous", + "actAsTool": { + "id": "contactExtractorScraping", + "name": "Scrape URL", + "description": "Scrapes a URL and returns its HTML, metadata, and the content of its linked JS and CSS files.", + "inputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "url": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "object" + }, + "outputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "data": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "html": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "url": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "title": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "description": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "jsContent": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "cssContent": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "object" + } + } + }, + "type": "object" + }, + "metadata": { + "icon": "https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/web.svg", + "title": "Scrape URL", + "parent": "contactExtractor" + } + } + } + ] + }, + "/extract/parse": { + "middlewares": [], + "agents": [ + { + "handler": "anonymous" + } + ] + }, + "/extract/contact": { + "middlewares": [], + "agents": [ + { + "handler": "anonymous", + "actAsTool": { + "id": "singleContactExtractor", + "name": "Single Contact Extractor", + "description": "Analyzes a single URL to extract a detailed, structured contact information of the person or entity featured on the page.", + "inputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "url": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + }, + "maxDepth": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "directive": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "masterVisitedUrls": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "object" + }, + "outputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "contact": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "nullable", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "_id": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "source": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "primaryEmail": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "emails": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "socials": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "linkedin": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "github": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "twitter": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "portfolio": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "path": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "optional" + }, + "persona": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "profession": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "age": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "summary": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "interests": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "location": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "nullable" + }, + "usage": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "inputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "outputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "totalTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + } + } + }, + "type": "object" + }, + "visitedUrls": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "object" + }, + "metadata": { + "icon": "https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/perplexity-icon.svg", + "title": "Single Contact Extractor" + } + } + } + ] + }, + "/extract/deep-persona": { + "middlewares": [], + "agents": [ + { + "handler": "anonymous", + "actAsTool": { + "id": "deepPersonaAnalyzer", + "name": "Deep Persona Analyzer", + "description": "Analyzes multiple URLs for a given contact to create a detailed, structured persona and updates the contact record.", + "inputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "contactId": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "urls": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "object" + }, + "outputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "_id": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "source": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "primaryEmail": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "emails": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "socials": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "linkedin": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "github": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "twitter": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "portfolio": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "path": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "optional" + }, + "persona": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "profession": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "age": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "summary": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "interests": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "location": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "status": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "enum", + "entries": { + "success": "success", + "error": "error" + } + }, + "type": "enum", + "enum": { + "success": "success", + "error": "error" + }, + "options": [ + "success", + "error" + ] + }, + "error": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "usage": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "inputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "outputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "totalTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + } + } + }, + "type": "object" + } + }, + "checks": [] + }, + "type": "object" + }, + "metadata": { + "icon": "https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/perplexity-icon.svg", + "title": "Deep Persona Analyzer" + } + } + } + ] + }, + "/extract": { + "middlewares": [], + "agents": [ + { + "handler": "anonymous", + "actAsTool": { + "id": "contactExtractor", + "name": "Contact Extractor", + "description": "Extracts contact information from a list of seed URLs. It can autonomously navigate up to 3 levels deep to find relevant information based on a user-provided directive.", + "inputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "urls": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "directive": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "maxContacts": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + }, + "outputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "status": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "pagesScraped": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "contactsFound": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "contacts": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "_id": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "source": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "primaryEmail": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "emails": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "socials": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "linkedin": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "github": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "twitter": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "portfolio": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "path": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "optional" + }, + "persona": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "profession": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "age": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "summary": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "interests": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "location": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "_id": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "source": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "primaryEmail": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "emails": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "socials": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "linkedin": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "github": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "twitter": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "portfolio": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "path": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "optional" + }, + "persona": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "profession": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "age": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "summary": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "interests": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "location": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "usage": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "inputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "outputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "totalTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + } + } + }, + "type": "object" + } + } + }, + "type": "object" + }, + "metadata": { + "icon": "https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/chrome.svg", + "title": "Contact Extractor", + "parent": "contactExtractor" + } + } + } + ] + }, + "/": { + "middlewares": [ + { + "handler": "middleware" + }, + { + "handler": "middleware" + } + ], + "agents": [ + { + "handler": "anonymous" + } + ] + } + }, + "tools": { + "contactExtractorScraping": { + "id": "contactExtractorScraping", + "name": "Scrape URL", + "description": "Scrapes a URL and returns its HTML, metadata, and the content of its linked JS and CSS files.", + "inputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "url": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "object" + }, + "outputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "data": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "html": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "url": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "title": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "description": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "jsContent": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "cssContent": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "object" + } + } + }, + "type": "object" + }, + "metadata": { + "icon": "https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/web.svg", + "title": "Scrape URL", + "parent": "contactExtractor", + "absolutePath": "/extract/scrape", + "toolKey": "contactExtractorScraping", + "name": "Scrape URL", + "description": "Scrapes a URL and returns its HTML, metadata, and the content of its linked JS and CSS files." + } + }, + "singleContactExtractor": { + "id": "singleContactExtractor", + "name": "Single Contact Extractor", + "description": "Analyzes a single URL to extract a detailed, structured contact information of the person or entity featured on the page.", + "inputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "url": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + }, + "maxDepth": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "directive": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "masterVisitedUrls": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "object" + }, + "outputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "contact": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "nullable", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "_id": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "source": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "primaryEmail": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "emails": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "socials": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "linkedin": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "github": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "twitter": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "portfolio": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "path": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "optional" + }, + "persona": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "profession": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "age": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "summary": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "interests": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "location": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "nullable" + }, + "usage": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "inputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "outputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "totalTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + } + } + }, + "type": "object" + }, + "visitedUrls": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "object" + }, + "metadata": { + "icon": "https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/perplexity-icon.svg", + "title": "Single Contact Extractor", + "absolutePath": "/extract/contact", + "toolKey": "singleContactExtractor", + "name": "Single Contact Extractor", + "description": "Analyzes a single URL to extract a detailed, structured contact information of the person or entity featured on the page." + } + }, + "deepPersonaAnalyzer": { + "id": "deepPersonaAnalyzer", + "name": "Deep Persona Analyzer", + "description": "Analyzes multiple URLs for a given contact to create a detailed, structured persona and updates the contact record.", + "inputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "contactId": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "urls": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "object" + }, + "outputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "_id": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "source": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "primaryEmail": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "emails": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "socials": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "linkedin": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "github": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "twitter": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "portfolio": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "path": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "optional" + }, + "persona": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "profession": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "age": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "summary": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "interests": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "location": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "status": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "enum", + "entries": { + "success": "success", + "error": "error" + } + }, + "type": "enum", + "enum": { + "success": "success", + "error": "error" + }, + "options": [ + "success", + "error" + ] + }, + "error": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "usage": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "inputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "outputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "totalTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + } + } + }, + "type": "object" + } + }, + "checks": [] + }, + "type": "object" + }, + "metadata": { + "icon": "https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/perplexity-icon.svg", + "title": "Deep Persona Analyzer", + "absolutePath": "/extract/deep-persona", + "toolKey": "deepPersonaAnalyzer", + "name": "Deep Persona Analyzer", + "description": "Analyzes multiple URLs for a given contact to create a detailed, structured persona and updates the contact record." + } + }, + "contactExtractor": { + "id": "contactExtractor", + "name": "Contact Extractor", + "description": "Extracts contact information from a list of seed URLs. It can autonomously navigate up to 3 levels deep to find relevant information based on a user-provided directive.", + "inputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "urls": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "directive": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "maxContacts": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + }, + "outputSchema": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "status": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "pagesScraped": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "contactsFound": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "contacts": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "_id": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "source": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "primaryEmail": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "emails": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "socials": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "linkedin": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "github": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "twitter": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "portfolio": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "path": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "optional" + }, + "persona": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "profession": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "age": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "summary": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "interests": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "location": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "_id": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "source": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + }, + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "primaryEmail": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "emails": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "socials": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "linkedin": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "github": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "twitter": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "portfolio": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + }, + "path": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "checks": [ + { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string", + "format": "url", + "check": "string_format", + "abort": false + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + ] + }, + "type": "string", + "format": "url", + "minLength": null, + "maxLength": null + } + } + } + }, + "type": "optional" + }, + "persona": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "name": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "profession": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "age": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "summary": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + }, + "interests": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "array", + "element": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + } + }, + "type": "optional" + }, + "location": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "optional", + "innerType": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "string" + }, + "type": "string", + "format": null, + "minLength": null, + "maxLength": null + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "type": "optional" + } + } + }, + "type": "object" + } + }, + "usage": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "object", + "shape": { + "inputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "outputTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + }, + "totalTokens": { + "~standard": { + "vendor": "zod", + "version": 1 + }, + "def": { + "type": "number", + "checks": [] + }, + "type": "number", + "minValue": null, + "maxValue": null, + "isInt": false, + "isFinite": true, + "format": null + } + } + }, + "type": "object" + } + } + }, + "type": "object" + }, + "metadata": { + "icon": "https://raw.githubusercontent.com/microfox-ai/microfox/refs/heads/main/logos/chrome.svg", + "title": "Contact Extractor", + "parent": "contactExtractor", + "absolutePath": "/extract", + "toolKey": "contactExtractor", + "name": "Contact Extractor", + "description": "Extracts contact information from a list of seed URLs. It can autonomously navigate up to 3 levels deep to find relevant information based on a user-provided directive." + } + } + } +}; diff --git a/examples/contact-extractor-agent/app/ai/types.d.ts b/examples/contact-extractor-agent/app/ai/types.d.ts new file mode 100644 index 0000000..b32dc03 --- /dev/null +++ b/examples/contact-extractor-agent/app/ai/types.d.ts @@ -0,0 +1,2 @@ +// This file is auto-generated by the ai-router build command. Do not edit. +export type AiRouterTools = InferUITools; diff --git a/examples/contact-extractor-agent/app/api/contacts/analyze-persona/route.ts b/examples/contact-extractor-agent/app/api/contacts/analyze-persona/route.ts new file mode 100644 index 0000000..53b115d --- /dev/null +++ b/examples/contact-extractor-agent/app/api/contacts/analyze-persona/route.ts @@ -0,0 +1,21 @@ + +import { NextResponse } from 'next/server'; + +export async function POST(request: Request) { + try { + const { contactId, urls } = await request.json(); + + if (!contactId || !urls || !Array.isArray(urls)) { + return NextResponse.json({ message: 'Missing contactId or urls' }, { status: 400 }); + } + + const response = await fetch(`/api/studio/chat/agent/extract/deep-persona?contactId=${contactId}&urls=${urls}`); + const result = await response.json(); + + return NextResponse.json(result); + + } catch (error: any) { + console.error('Error in analyze-persona API:', error); + return NextResponse.json({ message: 'Internal Server Error', error: error.message }, { status: 500 }); + } +} diff --git a/examples/contact-extractor-agent/app/api/contacts/route.ts b/examples/contact-extractor-agent/app/api/contacts/route.ts new file mode 100644 index 0000000..e1de71f --- /dev/null +++ b/examples/contact-extractor-agent/app/api/contacts/route.ts @@ -0,0 +1,22 @@ + +import { NextResponse } from 'next/server'; +import { getContacts, searchContacts } from '@/app/ai/agents/contactExtractorAgent/helpers/storage'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + const topK = searchParams.get('topK'); + + try { + if (query) { + const contacts = await searchContacts(query, topK ? parseInt(topK, 10) : 10); + return NextResponse.json(contacts); + } else { + const contacts = await getContacts(); + return NextResponse.json(contacts); + } + } catch (error) { + console.error('Error in contacts API:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/examples/contact-extractor-agent/app/api/studio/chat/agent/[...slug]/route.ts b/examples/contact-extractor-agent/app/api/studio/chat/agent/[...slug]/route.ts new file mode 100644 index 0000000..7f01c11 --- /dev/null +++ b/examples/contact-extractor-agent/app/api/studio/chat/agent/[...slug]/route.ts @@ -0,0 +1,58 @@ +import aiMainRouter from '@/app/ai'; +import { UIMessage } from 'ai'; +import { NextRequest } from 'next/server'; + +export async function GET(req: NextRequest) { + //const body = req.body; + const agentFullPath = req.nextUrl.href.split('/api/studio/chat/agent')[1]; + const agentPath = agentFullPath.includes('?') + ? agentFullPath.split('?')[0] + : agentFullPath; + + const searchParams = req.nextUrl.searchParams; + const params: any = {}; + searchParams.entries().forEach(([key, value]) => { + params[key] = value; + }); + + //const revalidatePath = lastMessage?.metadata?.revalidatePath; + + const response = await aiMainRouter.toAwaitResponse(agentPath, { + request: { + messages: [], + params, + //loadedRevalidatePath: agentPath, + }, + }); + + return response; +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + + const agentFullPath = req.nextUrl.href.split('/api/studio/chat/agent')[1]; + const agentPath = agentFullPath.includes('?') + ? agentFullPath.split('?')[0] + : agentFullPath; + + const searchParams = req.nextUrl.searchParams; + const params: any = {}; + searchParams.entries().forEach(([key, value]) => { + params[key] = value; + }); + + const { messages, ...restOfBody } = body; + const lastMessage = body.messages?.[body.messages.length - 1] as UIMessage<{ + revalidatePath?: string; + }>; + //const revalidatePath = lastMessage?.metadata?.revalidatePath; + + return await aiMainRouter.toAwaitResponse(agentPath, { + request: { + ...body, + params, + //loadedRevalidatePath: agentPath, + }, + }); +} diff --git a/examples/contact-extractor-agent/app/api/studio/chat/message/route.ts b/examples/contact-extractor-agent/app/api/studio/chat/message/route.ts new file mode 100644 index 0000000..4910dcd --- /dev/null +++ b/examples/contact-extractor-agent/app/api/studio/chat/message/route.ts @@ -0,0 +1,176 @@ +import { + messageStore, + sessionStore, +} from '@/app/api/studio/chat/sessions/chatSessionUpstash'; +import { + ChatSessionData, + sesionLocalStore, +} from '@/app/api/studio/chat/sessions/chatSessionLocal'; +import { NextRequest, NextResponse } from 'next/server'; +import { StudioConfig } from '@/microfox.config'; + +// Create Chat message +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + //console.log("body", body); + if (!body.id) { + return NextResponse.json( + { error: 'Message id is required' }, + { status: 400 }, + ); + } + if (StudioConfig.studioSettings.database.type === 'upstash-redis') { + const message = await messageStore.set(body.id, { + ...body, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + return NextResponse.json(message, { status: 200 }); + } else { + const store = await sesionLocalStore(body.sessionId); + const message = { + ...body, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const sessionData = await store?.get('sessionData'); + if (sessionData) { + await store?.set('sessionData', { + ...sessionData, + messages: [...(sessionData?.messages ?? []), message], + }); + } else { + await store?.set('sessionData', { + messages: [message], + }); + } + return NextResponse.json(message, { status: 200 }); + } + } catch (error) { + console.error('Error creating Chat message:', error); + return NextResponse.json( + { error: 'Error creating Chat message' }, + { status: 500 }, + ); + } +} + +export async function PUT(req: NextRequest) { + const body = await req.json(); + if (StudioConfig.studioSettings.database.type === 'upstash-redis') { + const message = await messageStore.update(body.id, body); + if (!message) { + return NextResponse.json({ error: 'Message not found' }, { status: 404 }); + } + } else { + const store = await sesionLocalStore(body.sessionId); + const sessionData = await store?.get('sessionData'); + const message = sessionData?.messages.find( + (message) => message.id === body.id, + ); + if (!message) { + return NextResponse.json({ error: 'Message not found' }, { status: 404 }); + } + await store?.set('sessionData', { + ...sessionData, + messages: sessionData?.messages.map((message) => + message.id === body.id ? body : message, + ), + }); + } + return NextResponse.json(body, { status: 200 }); +} + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + const sessionId = searchParams.get('sessionId'); + + if (!id && !sessionId) { + return NextResponse.json( + { error: 'Id or sessionId is required' }, + { status: 400 }, + ); + } + if (id) { + if (StudioConfig.studioSettings.database.type === 'upstash-redis') { + const messages = await messageStore.get(id); + return NextResponse.json(messages); + } else { + const store = await sesionLocalStore(id.split('-')[0]); + const sessionData = await store?.get('sessionData'); + const message = sessionData?.messages.find( + (message) => message.id === id, + ); + return NextResponse.json(message); + } + } else if (sessionId) { + if (StudioConfig.studioSettings.database.type === 'upstash-redis') { + console.log('sessionId', sessionId); + const messages = await messageStore.query(`${sessionId}-*`, { + count: 1000, + offset: 0, + }); + return NextResponse.json( + messages.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ), + ); + } else { + const store = await sesionLocalStore(sessionId); + const sessionData = await store?.get('sessionData'); + return NextResponse.json( + sessionData?.messages.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ), + ); + } + } + + return NextResponse.json([]); +} + +export async function DELETE(req: NextRequest) { + const { searchParams } = new URL(req.url); + const ids = searchParams.get('ids'); + let sessionId = searchParams.get('sessionId'); + const idsArray = ids?.split(','); + //console.log("idsArray", idsArray, sessionId); + if (!idsArray || !sessionId) { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } + if (StudioConfig.studioSettings.database.type === 'upstash-redis') { + const deletes = idsArray.map((id) => messageStore.del(id)); + const message = await Promise.all(deletes); + return NextResponse.json( + { + success: message.length > 0 ? true : false, + count: message.length, + }, + { status: 200 }, + ); + } else { + if (!sessionId) { + sessionId = idsArray[0].split('-')[0]; + } + const store = await sesionLocalStore(sessionId); + const sessionData = await store?.get('sessionData'); + const messages = sessionData?.messages.filter( + (message) => !idsArray.includes(message.id), + ); + await store?.set('sessionData', { + ...sessionData, + messages: messages, + }); + return NextResponse.json( + { + success: idsArray && idsArray?.length > 0 ? true : false, + count: idsArray?.length, + }, + { status: 200 }, + ); + } +} diff --git a/examples/contact-extractor-agent/app/api/studio/chat/route.ts b/examples/contact-extractor-agent/app/api/studio/chat/route.ts new file mode 100644 index 0000000..4d7246e --- /dev/null +++ b/examples/contact-extractor-agent/app/api/studio/chat/route.ts @@ -0,0 +1,41 @@ +import aiMainRouter from '@/app/ai'; +import { chatRestoreLocal } from '@/app/api/studio/chat/sessions/chatSessionLocal'; +import { chatRestoreUpstash } from '@/app/api/studio/chat/sessions/chatSessionUpstash'; +import { StudioConfig } from '@/microfox.config'; +import { UIMessage } from 'ai'; +import { NextRequest, NextResponse } from 'next/server'; + +export const maxDuration = 300_1000; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { messages, ...restOfBody } = body; + console.log("messages", messages) + const lastMessage = body.messages?.[body.messages.length - 1] as UIMessage<{ + revalidatePath?: string; + }>; + const revalidatePath = lastMessage?.metadata?.revalidatePath; + + return aiMainRouter + .use( + '/', + StudioConfig.studioSettings.database.type === 'upstash-redis' + ? chatRestoreUpstash + : chatRestoreLocal, + ) + .handle(revalidatePath ? revalidatePath : '/', { + request: { + ...body, + messages: messages, + loadedRevalidatePath: revalidatePath, + }, + }); + } catch (error) { + console.log('Error creating Chat message:', error); + return NextResponse.json( + { error: 'Error creating Chat' }, + { status: 500 }, + ); + } +} diff --git a/examples/contact-extractor-agent/app/api/studio/chat/sessions/chatSessionLocal.ts b/examples/contact-extractor-agent/app/api/studio/chat/sessions/chatSessionLocal.ts new file mode 100644 index 0000000..82e510b --- /dev/null +++ b/examples/contact-extractor-agent/app/api/studio/chat/sessions/chatSessionLocal.ts @@ -0,0 +1,244 @@ +import { AiMiddleware, getTextParts } from '@microfox/ai-router'; +import { FileSystemStore } from '@microfox/ai-router/fs_store'; +import { generateId, generateText, UIMessage } from 'ai'; +import { google } from '@ai-sdk/google'; + +export type ChatSession = { + id: string; + title: string; + createdAt: string; + metadata?: any; + autoSubmit?: string; +}; + +export type ChatMessage = { + id: string; + sessionId: string; + role: 'system' | 'user' | 'assistant'; + content: string; + metadata?: any; + parts: any[]; + createdAt: string; + updatedAt: string; +}; + +export type ChatSessionData = { + session: ChatSession; + messages: ChatMessage[]; +}; + +// Create .chat directory if it doesn't exist +const ensureChatDirectory = async () => { + try { + if (typeof window !== 'undefined') { + return null; + } + + // Use eval to avoid bundler module resolution + const fs = eval('require("fs/promises")'); + const path = eval('require("path")'); + const chatDir = path.join(process.cwd(), '.chat'); + try { + await fs.access(chatDir); + } catch { + await fs.mkdir(chatDir, { recursive: true }); + } + return chatDir; + } catch (error) { + console.error('Error ensuring chat directory', error); + return null; + } +}; + +const getSessionFilePath = async (sessionId: string) => { + try { + if (typeof window !== 'undefined') { + return null; + } + + // Use eval to avoid bundler module resolution + const path = eval('require("path")'); + const chatDir = await ensureChatDirectory(); + if (!chatDir) { + return null; + } + return path.join(chatDir, `${sessionId}.json`); + } catch (error) { + console.error('Error getting session file path', error); + return null; + } +}; + +export const sesionLocalStore = async (sessionId: string) => { + try { + if (typeof window !== 'undefined') { + return null; + } + + // Use eval to avoid bundler module resolution + const fs = eval('require("fs/promises")'); + const path = eval('require("path")'); + const chatDir = await ensureChatDirectory(); + if (!chatDir) { + return null; + } + const sessionFilePath = path.join(chatDir, `${sessionId}.json`); + + try { + await fs.access(sessionFilePath); + return new FileSystemStore(sessionFilePath); + } catch { + // File doesn't exist, create it + await fs.writeFile( + sessionFilePath, + JSON.stringify({ + sessionData: { + session: { + id: sessionId, + title: 'New Chat', + createdAt: new Date().toISOString(), + metadata: {}, + autoSubmit: null, + }, + messages: [], + }, + }), + 'utf-8', + ); + return new FileSystemStore(sessionFilePath); + } + } catch (error) { + console.error('Error ensuring chat directory', error); + return null; + } +}; + +export const sessionLocalListOut = async () => { + try { + if (typeof window !== 'undefined') { + return null; + } + + // Use eval to avoid bundler module resolution + const fs = eval('require("fs/promises")'); + const chatDir = await ensureChatDirectory(); + if (!chatDir) { + return null; + } + const sessions = await fs.readdir(chatDir); + return sessions.map((session: string) => session.replace('.json', '')); + } catch (error) { + console.error('Error ensuring chat directory', error); + return null; + } +}; + +/** + * Middleware to restore chat session from local file storage + * @param props - The context object + * @param next - The next middleware or router + * @returns + */ +export const chatRestoreLocal: AiMiddleware<{ + sessionId: string; + loader?: string; +}> = async (props, next) => { + try { + const { sessionId, messages } = props.request; + + if (!sessionId || sessionId === 'undefined') { + return next(); + } + + // update UI on frontend for smoother experience + props.response.writeMessageMetadata({ + loader: 'Initializing...', + }); + + const newMessage = messages[messages.length - 1]; + const chatDir = await ensureChatDirectory(); + const sessionFilePath = await getSessionFilePath(sessionId); + + if (!sessionFilePath) { + console.error('Failed to get session file path'); + return next(); + } + + // Create FileSystemStore instance for this session + const sessionStore = new FileSystemStore(sessionFilePath); + // Try to load existing session data + const existingData = await sessionStore.get('sessionData'); + //("sessionData"); + if ( + existingData && + existingData.messages && + existingData.messages.length > 0 + ) { + const oldMessages = existingData.messages.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + // Check if the new message already exists in the store + const isNewMessageInStore = oldMessages.find( + (message) => message.id === newMessage.id, + ); + + if (isNewMessageInStore) { + props.request.messages = oldMessages.map((message) => + message.id === newMessage.id ? newMessage : message, + ); + } else { + const updatedMessages = oldMessages.concat([ + { + ...newMessage, + sessionId: sessionId, + content: getTextParts(newMessage).join('\n'), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]); + + props.request.messages = updatedMessages; + } + } else { + // Create new chat session + props.request.messages = [newMessage]; + + const title = await generateText({ + model: google('gemini-2.5-flash'), + prompt: `Generate a title for the chat session based on the messages: ${getTextParts(newMessage).join('\n')}`, + system: `You are a helpful assistant that generates a title for a chat session based on the messages.`, + }); + + const newSession: ChatSession = { + id: sessionId, + title: title.text, + createdAt: new Date().toISOString(), + }; + + const newMessageData: ChatMessage = { + ...newMessage, + sessionId: sessionId, + content: getTextParts(newMessage).join('\n'), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + // Save new session and message to file + await sessionStore.set('sessionData', { + session: newSession, + messages: [newMessageData], + }); + + props.request.sessionId = sessionId; + } + + // continue to next middleware or router + return next(); + } catch (error) { + props.logger.error('Error restoring chat session', error); + // stops the router from continuing to next middleware or router + return; + } +}; diff --git a/examples/contact-extractor-agent/app/api/studio/chat/sessions/chatSessionUpstash.ts b/examples/contact-extractor-agent/app/api/studio/chat/sessions/chatSessionUpstash.ts new file mode 100644 index 0000000..5bb41cd --- /dev/null +++ b/examples/contact-extractor-agent/app/api/studio/chat/sessions/chatSessionUpstash.ts @@ -0,0 +1,124 @@ +import { AiMiddleware, getTextParts } from '@microfox/ai-router'; +import { CrudHash, CrudStore } from '@microfox/db-upstash'; +import { generateId, generateText, UIMessage } from 'ai'; +import { Redis } from '@upstash/redis'; +import { google } from '@ai-sdk/google'; + +export type ChatSession = { + id: string; + title: string; + createdAt: string; + metadata?: any; + autoSubmit?: string; +}; + +export type ChatMessage = { + id: string; + sessionId: string; + role: 'system' | 'user' | 'assistant'; + content: string; + metadata?: any; + parts: any[]; + createdAt: string; + updatedAt: string; +}; + +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL, + token: process.env.UPSTASH_REDIS_REST_TOKEN, +}); + +export const sessionStore = new CrudHash(redis, 'chat_sessions'); +export const messageStore = new CrudHash( + redis, + 'chat_messages_v2', +); + +/** + * Middleware to restore chat session from Redis + * @param props - The context object + * @param next - The next middleware or router + * @returns + */ +export const chatRestoreUpstash: AiMiddleware<{ + sessionId: string; + loader?: string; +}> = async (props, next) => { + try { + const { sessionId, messages } = props.request; + if (!sessionId || sessionId === 'undefined') { + return next(); + } + const newMessage = messages[messages.length - 1]; + + // update UI on frontend for smoother experience + props.response.writeMessageMetadata({ + loader: 'Initializing...', + }); + + let oldMessages = ( + await messageStore.query(`${sessionId}-*`, { + count: 1000, + offset: 0, + }) + ).sort( + (b, a) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + console.log('last oldMessages', oldMessages[oldMessages.length - 1]); + // restore chat session + if (oldMessages && oldMessages.length > 0) { + // slice context to only the last 5 messages + // if (oldMessages.length > 5) { + // oldMessages = oldMessages.slice(0, -5); + // } + const isNewMessageInStore = oldMessages.find( + (message) => message.id === newMessage.id, + ); + if (isNewMessageInStore) { + props.request.messages = oldMessages.map((message) => + message.id === newMessage.id ? newMessage : message, + ); + } else { + props.request.messages = oldMessages.concat([ + { + ...newMessage, + sessionId: sessionId, + content: getTextParts(newMessage).join('\n'), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]); + } + } + + // create chat session + if (!oldMessages || oldMessages.length === 0) { + props.request.messages = [newMessage]; + const title = await generateText({ + model: google('gemini-2.5-flash'), + prompt: `Generate a title for the chat session based on the messages: ${getTextParts(newMessage).join('\n')}`, + system: `You are a helpful assistant that generates a title for a chat session based on the messages.`, + }); + await sessionStore.update(sessionId, { + title: title.text, + createdAt: new Date().toISOString(), + }); + await messageStore.set(newMessage.id, { + ...newMessage, + sessionId: sessionId, + content: getTextParts(newMessage).join('\n'), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + props.request.sessionId = sessionId; + } + + // continue to next middleware or router + return next(); + } catch (error) { + props.logger.error('Error restoring chat session', error); + // stops the router from continuing to next middleware or router + return; + } +}; diff --git a/examples/contact-extractor-agent/app/api/studio/chat/sessions/route.ts b/examples/contact-extractor-agent/app/api/studio/chat/sessions/route.ts new file mode 100644 index 0000000..f04b445 --- /dev/null +++ b/examples/contact-extractor-agent/app/api/studio/chat/sessions/route.ts @@ -0,0 +1,60 @@ +import { + ChatSessionData, + sesionLocalStore, + sessionLocalListOut, +} from '@/app/api/studio/chat/sessions/chatSessionLocal'; +import { sessionStore } from '@/app/api/studio/chat/sessions/chatSessionUpstash'; +import { StudioConfig } from '@/microfox.config'; +import { generateId } from 'ai'; +import dayjs from 'dayjs'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const sessionId = generateId(); + const date = dayjs().format('DD-MM-YYYY'); + if (StudioConfig.studioSettings.database.type === 'upstash-redis') { + const session = await sessionStore.set(sessionId, { + id: sessionId, + title: 'New Chat - ' + date, + createdAt: new Date().toISOString(), + ...body, + }); + return NextResponse.json(session, { status: 201 }); + } else { + const store = await sesionLocalStore(sessionId); + const session = { + id: sessionId, + title: 'New Chat - ' + date, + createdAt: new Date().toISOString(), + ...body, + }; + await store?.set('sessionData', { + session: session, + messages: [], + }); + return NextResponse.json(session, { status: 201 }); + } +} + +export async function GET(req: NextRequest) { + if (StudioConfig.studioSettings.database.type === 'upstash-redis') { + const sessions = await sessionStore.list(); + return NextResponse.json(sessions, { status: 200 }); + } else { + const sessionIds = await sessionLocalListOut(); + if (!sessionIds) { + return NextResponse.json([], { status: 200 }); + } + const sessions = ( + await Promise.all( + sessionIds.map(async (sessionId: string) => { + const store = await sesionLocalStore(sessionId); + const session = await store?.get('sessionData'); + return session?.session; + }), + ) + ).filter((session) => session !== null && session !== undefined); + return NextResponse.json(sessions, { status: 200 }); + } +} diff --git a/examples/contact-extractor-agent/app/api/studio/chat/sessions/search/route.ts b/examples/contact-extractor-agent/app/api/studio/chat/sessions/search/route.ts new file mode 100644 index 0000000..15cde3c --- /dev/null +++ b/examples/contact-extractor-agent/app/api/studio/chat/sessions/search/route.ts @@ -0,0 +1,32 @@ +import { + ChatSessionData, + sesionLocalStore, + sessionLocalListOut, +} from '@/app/api/studio/chat/sessions/chatSessionLocal'; +import { sessionStore } from '@/app/api/studio/chat/sessions/chatSessionUpstash'; +import { StudioConfig } from '@/microfox.config'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const query = searchParams.get('q'); + if (StudioConfig.studioSettings.database.type === 'upstash-redis') { + const sessions = await sessionStore.list(); + const filteredSessions = sessions.filter((session) => + session.title.toLowerCase().includes(query?.toLowerCase() ?? ''), + ); + return NextResponse.json(filteredSessions, { status: 200 }); + } else { + const sessionIds = await sessionLocalListOut(); + const sessions = ( + await Promise.all( + sessionIds.map(async (sessionId: string) => { + const store = await sesionLocalStore(sessionId); + const session = await store?.get('sessionData'); + return session?.session; + }), + ) + ).filter((session) => session !== null); + return NextResponse.json(sessions, { status: 200 }); + } +} diff --git a/examples/contact-extractor-agent/app/favicon.ico b/examples/contact-extractor-agent/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/examples/contact-extractor-agent/app/favicon.ico differ diff --git a/examples/contact-extractor-agent/app/globals.css b/examples/contact-extractor-agent/app/globals.css new file mode 100644 index 0000000..fb325b5 --- /dev/null +++ b/examples/contact-extractor-agent/app/globals.css @@ -0,0 +1,217 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --animate-shimmer: shimmer 2s infinite linear; + --animate-cube: cube 1.3s infinite ease; + + @keyframes cube { + 0% { + transform: rotate(45deg) rotateX(-25deg) rotateY(25deg); + } + 50% { + transform: rotate(45deg) rotateX(-385deg) rotateY(25deg); + } + 100% { + transform: rotate(45deg) rotateX(-385deg) rotateY(385deg); + } + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: 'Inter', sans-serif; + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + font-family: var(--font-sans); + } + + .loading-text { + @apply relative bg-repeat !text-transparent bg-clip-text first-letter:uppercase overflow-hidden animate-shimmer bg-gradient-to-r from-gray-400 via-gray-800 to-gray-400 bg-[length:200%_100%]; + } + + .thin-scrollbar { + @apply [&::-webkit-scrollbar]:!w-2 + [&::-webkit-scrollbar-track]:!rounded-full + [&::-webkit-scrollbar-track]:!bg-gray-100 + [&::-webkit-scrollbar-thumb]:!rounded-full + [&::-webkit-scrollbar-thumb]:!bg-gray-300 + dark:[&::-webkit-scrollbar-track]:!bg-neutral-700 + dark:[&::-webkit-scrollbar-thumb]:!bg-neutral-500; + } + + button { + @apply cursor-pointer; + } + + .no-scrollbar { + @apply [&::-webkit-scrollbar]:!w-0; + } + + /* Add transition classes for scrollbar */ + .scrollbar-transition { + @apply transition-all duration-300; + } + .scrollbar-transition::-webkit-scrollbar { + @apply transition-all duration-300; + } +} + +.scrollbar-hidden { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +.scrollbar-hidden::-webkit-scrollbar { + display: none; /* Chrome, Safari and Opera */ +} + +@keyframes scroll { + from { + transform: translateX(0); + } + to { + transform: translateX(calc(-50% - 20px)); + } +} + +[data-scroll-speed='slow'] { + --_animation-duration: 100s; +} + +[data-scroll-speed='medium'] { + --_animation-duration: 40s; +} + +[data-scroll-speed='fast'] { + --_animation-duration: 10s; +} +/* Apply scrolling only if data-scroll="true" */ +[data-scroll='true'] { + animation: scroll var(--_animation-duration, 40s) linear infinite; +} + +[data-scroll-to-left='true'] { + animation-direction: reverse; +} diff --git a/examples/contact-extractor-agent/app/layout.tsx b/examples/contact-extractor-agent/app/layout.tsx new file mode 100644 index 0000000..a883b03 --- /dev/null +++ b/examples/contact-extractor-agent/app/layout.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { usePathname } from "next/navigation"; + +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +const Layout = ({ children }: { children: React.ReactNode }) => { + const pathname = usePathname(); + + return ( + + + {children} + + + ); +}; + +export default Layout; + diff --git a/examples/contact-extractor-agent/app/page.tsx b/examples/contact-extractor-agent/app/page.tsx new file mode 100644 index 0000000..42d5b77 --- /dev/null +++ b/examples/contact-extractor-agent/app/page.tsx @@ -0,0 +1,182 @@ + +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Zap, BookOpen, ExternalLink, SplitIcon } from "lucide-react"; +import { StudioConfig } from "@/microfox.config"; + +export default function Homepage() { + return ( +
+
+ {/* Header */} +
+

+ {StudioConfig.appName} +

+

+ {StudioConfig.appDescription} +

+
+ + {/* Main Content Card */} + {/* + + What is AI Router? + + Powerful framework for building sophisticated AI systems + + + +

+ AI Router is a powerful framework that enables you to build sophisticated, + multi-agent AI systems with ease. Inspired by Express.js simplicity and + Google's Agent Development Kit approach, it provides a seamless integration + with Next.js and Vercel's AI SDK. +

+

+ Whether you're building conversational AI, research agents, or complex + orchestration systems, AI Router gives you the tools to create robust, + scalable AI applications. +

+
+
*/} + + {/* Action Buttons */} +
+ + +
+ + +
+
+

Built with

+ + + Ai Router + + +
+

+ Ai Router is a framework for orchestrating structured, multi-agent AI systems. + Built on top of Vercel's AI SDK with the simplicity of Express.js and power of ADK. +

+
+ + {/* Documentation Links Card */} + {/* + + Quick Links + + Essential resources to get started with AI Router + + + +
+
+

Getting Started

+
+ + + +
+
+
+

Advanced Topics

+
+ + + +
+
+
+
+
*/} + + {/* Footer */} +
+

Built with ❤️ by the Microfox team

+
+
+
+ ); +} diff --git a/examples/contact-extractor-agent/app/studio/[...slug]/page.tsx b/examples/contact-extractor-agent/app/studio/[...slug]/page.tsx new file mode 100644 index 0000000..a72625c --- /dev/null +++ b/examples/contact-extractor-agent/app/studio/[...slug]/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { PlaceholderPage } from "@/components/studio/layout/PlaceholderPage"; + + +const StudioRouter = async ({ + params, + searchParams, +}: { + params: Promise<{ slug: string | string[] }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) => { + + const { slug } = await params; + const allParams = await searchParams; + + if (Array.isArray(slug)) { + return + } + else { + return + } + +} + +export default StudioRouter; \ No newline at end of file diff --git a/examples/contact-extractor-agent/app/studio/chat/[sessionId]/page.tsx b/examples/contact-extractor-agent/app/studio/chat/[sessionId]/page.tsx new file mode 100644 index 0000000..9ee7fe1 --- /dev/null +++ b/examples/contact-extractor-agent/app/studio/chat/[sessionId]/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { aiRouterTools } from "@/app/ai"; +import { aiComponentMap } from "@/components/ai"; +import { ChatPage } from "@/components/studio/layout/ChatPage"; +import { Loader2 } from "lucide-react"; +import { Suspense } from "react"; + + +const StudioRouter = () => { + return ( + }> + + + ) +} + +export default StudioRouter; \ No newline at end of file diff --git a/examples/contact-extractor-agent/app/studio/chat/page.tsx b/examples/contact-extractor-agent/app/studio/chat/page.tsx new file mode 100644 index 0000000..747dd97 --- /dev/null +++ b/examples/contact-extractor-agent/app/studio/chat/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { PlaceholderPage } from "@/components/studio/layout/PlaceholderPage"; + + +const StudioRouter = () => { + + return +} + +export default StudioRouter; \ No newline at end of file diff --git a/examples/contact-extractor-agent/app/studio/dashboard/page.tsx b/examples/contact-extractor-agent/app/studio/dashboard/page.tsx new file mode 100644 index 0000000..663b920 --- /dev/null +++ b/examples/contact-extractor-agent/app/studio/dashboard/page.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { ContactsTable } from '@/components/contacts-dashboard/contacts-table'; + +const page = () => { + return ( +
+
+

Contacts

+ +
+
+ ); +}; + +export default page; diff --git a/examples/contact-extractor-agent/app/studio/layout.tsx b/examples/contact-extractor-agent/app/studio/layout.tsx new file mode 100644 index 0000000..3a27ab3 --- /dev/null +++ b/examples/contact-extractor-agent/app/studio/layout.tsx @@ -0,0 +1,36 @@ +import Head from 'next/head'; +import { AppLayout } from '@/components/studio/layout/StudioLayout'; + +export default function StudioLayout({ + children, +}: { + children: React.ReactNode; +}) { + // const sessionId = Array.isArray(slug) && slug.length > 1 ? slug[1] : undefined; + if (process.env.NODE_ENV != 'development') { + return
Not allowed
; + } + return ( + <> + + + + + + + {children} + + ); +} diff --git a/examples/contact-extractor-agent/app/studio/page.tsx b/examples/contact-extractor-agent/app/studio/page.tsx new file mode 100644 index 0000000..662ef42 --- /dev/null +++ b/examples/contact-extractor-agent/app/studio/page.tsx @@ -0,0 +1,8 @@ +import { PlaceholderPage } from "@/components/studio/layout/PlaceholderPage"; + + +export default function StudioHomePage() { + return ( + + ) +} \ No newline at end of file diff --git a/examples/contact-extractor-agent/components.json b/examples/contact-extractor-agent/components.json new file mode 100644 index 0000000..26ad91e --- /dev/null +++ b/examples/contact-extractor-agent/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/examples/contact-extractor-agent/components/ai/contactExtractorAgent/ContactsView.tsx b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/ContactsView.tsx new file mode 100644 index 0000000..052291a --- /dev/null +++ b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/ContactsView.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { Contact } from '@/app/ai/agents/contactExtractorAgent/helpers/schema'; +import { columns } from './columns'; +import { DataTable } from './DataTable'; +import { useState } from 'react'; +import { PersonaModal } from '@/components/contacts-dashboard/persona-modal'; + +export function ContactsView({ contacts }: { contacts: Contact[] }) { + const [selectedContact, setSelectedContact] = useState(null); + const [isPersonaModalOpen, setIsPersonaModalOpen] = useState(false); + + const openPersonaModal = (contact: Contact) => { + setSelectedContact(contact); + setIsPersonaModalOpen(true); + } + + return ( +
+ + setIsPersonaModalOpen(false)} + /> +
+ ); +} diff --git a/examples/contact-extractor-agent/components/ai/contactExtractorAgent/Dashboard.tsx b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/Dashboard.tsx new file mode 100644 index 0000000..60f68b7 --- /dev/null +++ b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/Dashboard.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { ContactsView } from './ContactsView'; +import type { AiRouterTools } from '@/app/ai'; +import { ComponentType } from 'react'; +import { ToolUIPart } from 'ai'; + +export const Dashboard: ComponentType<{ + tool: ToolUIPart>; +}> = (props) => { + const { tool } = props; + const { + contacts = [], + pagesScraped = 0, + usage = { totalTokens: 0 }, + } = tool.output || {}; + + return ( +
+
+
+

+ Contact Extraction Results +

+
+
+ + + + Total Contacts Found + + + +
{contacts.length}
+
+
+ + + + Pages Scraped + + + +
{pagesScraped}
+
+
+ + + + Total AI Usage + + + +
+ {usage.totalTokens} tokens +
+
+
+
+
+ + + Contacts + + A list of all contacts found during the extraction process. + + + + + + +
+
+
+ ); +}; diff --git a/examples/contact-extractor-agent/components/ai/contactExtractorAgent/DataTable.tsx b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/DataTable.tsx new file mode 100644 index 0000000..5c8d130 --- /dev/null +++ b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/DataTable.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table'; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + refreshContacts?: () => void; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table?.getHeaderGroups().map((headerGroup) => ( + + {headerGroup?.headers.map((header) => { + return ( + + {header?.isPlaceholder + ? null + : flexRender( + header?.column?.columnDef?.header, + header?.getContext() + )} + + ); + })} + + ))} + + + {table?.getRowModel()?.rows?.length ? ( + table?.getRowModel()?.rows?.map((row) => ( + + {row?.getVisibleCells()?.map((cell) => ( + + {flexRender(cell?.column?.columnDef?.cell, cell?.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/examples/contact-extractor-agent/components/ai/contactExtractorAgent/columns.tsx b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/columns.tsx new file mode 100644 index 0000000..8e392e3 --- /dev/null +++ b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/columns.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { ColumnDef } from '@tanstack/react-table'; +import { Contact } from '@/app/ai/agents/contactExtractorAgent/helpers/schema'; +import { Button } from '@/components/ui/button'; +import { useState } from 'react'; +import { Github, Linkedin, Twitter, Home, UserSquare } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +async function analyzePersona(contactId: string, urls: string[]) { + const response = await fetch( + `/api/studio/chat/agent/extract/deep-persona?contactId=${contactId}&urls=${urls}`, + ); + const result = await response.json(); + + if (result?.error) { + throw new Error(result.error); + } + + return result[0]?.parts[0]?.output?.contact; +} + +export const columns = ( + openPersonaModal: (contact: Contact) => void, +): ColumnDef[] => [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => row.original.name || 'N/A', + }, + { + accessorKey: 'primaryEmail', + header: 'Email', + cell: ({ row }) => { + const email = row.original.primaryEmail; + if (!email) return 'N/A'; + return ( + + + + {email} + + +

{email}

+
+
+
+ ); + }, + }, + { + header: 'Socials', + cell: ({ row }) => { + const { socials } = row.original; + return ( +
+ {socials?.linkedin && ( + + + + + + + + +

{socials.linkedin}

+
+
+
+ )} + {socials?.github && ( + + + + + + + + +

{socials.github}

+
+
+
+ )} + {socials?.twitter && ( + + + + + + + + +

{socials.twitter}

+
+
+
+ )} +
+ ); + }, + }, + { + accessorKey: 'socials.portfolio', + header: 'Portfolio', + cell: ({ row }) => { + const portfolio = row.original.socials?.portfolio; + return portfolio ? ( + + + + + + + + +

{portfolio}

+
+
+
+ ) : ( + 'N/A' + ); + }, + }, + { + id: 'actions', + cell: ({ row }) => { + const contact = row.original; + const [isLoading, setIsLoading] = useState(false); + + const handleAnalyze = async () => { + if (!contact._id) return; + const urls = Object.values(contact.socials || {}).filter( + Boolean, + ) as string[]; + // if(contact.source) urls.push(contact.source); + if (urls.length === 0) { + alert('No URLs found for this contact to analyze.'); + return; + } + setIsLoading(true); + try { + await analyzePersona(contact._id, urls); + alert('Persona analysis complete!'); + // Here you might want to refresh the data in the table + } catch (error) { + console.error(error); + alert('Failed to analyze persona.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {contact.persona && ( + + )} + +
+ ); + }, + }, +]; diff --git a/examples/contact-extractor-agent/components/ai/contactExtractorAgent/index.ts b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/index.ts new file mode 100644 index 0000000..bf29904 --- /dev/null +++ b/examples/contact-extractor-agent/components/ai/contactExtractorAgent/index.ts @@ -0,0 +1,14 @@ +'use client'; + +import { AiComponentMap } from '@/components/studio/context/ComponentProvider'; +import { Dashboard } from './Dashboard'; +import type { AiRouterTools } from '@/app/ai'; + +export const contactExtractorMap: AiComponentMap< + Pick, + Pick +>['tools'] = { + contactExtractor: { + full: Dashboard, + }, +}; diff --git a/examples/contact-extractor-agent/components/ai/index.tsx b/examples/contact-extractor-agent/components/ai/index.tsx new file mode 100644 index 0000000..e502ded --- /dev/null +++ b/examples/contact-extractor-agent/components/ai/index.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { contactExtractorMap } from './contactExtractorAgent'; + +export const aiComponentMap = { + tools: { + ...contactExtractorMap, + }, +}; diff --git a/examples/contact-extractor-agent/components/contacts-dashboard/columns.tsx b/examples/contact-extractor-agent/components/contacts-dashboard/columns.tsx new file mode 100644 index 0000000..56c22de --- /dev/null +++ b/examples/contact-extractor-agent/components/contacts-dashboard/columns.tsx @@ -0,0 +1,142 @@ +"use client" + +import { ColumnDef } from "@tanstack/react-table" +import { Contact } from "@/app/ai/agents/contactExtractorAgent/helpers/schema" +import { Button } from "@/components/ui/button" +import { useState } from "react" +import { Github, Linkedin, Twitter, Home, UserSquare } from "lucide-react" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +async function analyzePersona(contactId: string, urls: string[]): Promise { + // This will be a call to our agent. + const response = await fetch(`/api/studio/chat/agent/extract/deep-persona?contactId=${contactId}&urls=${urls.join(',')}`); + if (!response.ok) { + throw new Error('Failed to analyze persona'); + } + const result = await response.json(); + + if (result?.error) { + throw new Error(result.error); + } + + return result[0]?.parts[0]?.output?.contact; +} + + +export const columns = ( + openPersonaModal: (contact: Contact) => void, + refreshData: () => void +): ColumnDef[] => [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => row.original.name || "N/A", + }, + { + accessorKey: "primaryEmail", + header: "Email", + cell: ({ row }) => { + const email = row.original.primaryEmail; + if (!email) return "N/A"; + return ( + + + + {email} + + +

{email}

+
+
+
+ ) + } + }, + { + header: "Socials", + cell: ({ row }) => { + const { socials } = row.original; + return ( +
+ {socials?.linkedin &&

{socials.linkedin}

} + {socials?.github &&

{socials.github}

} + {socials?.twitter &&

{socials.twitter}

} +
+ ) + } + }, + { + accessorKey: "socials.portfolio", + header: "Portfolio", + cell: ({ row }) => { + const portfolio = row.original.socials?.portfolio + return portfolio ? ( + + + + + + +

{portfolio}

+
+
+
+ ) : "N/A" + } + }, + { + accessorKey: "score", + header: "Score", + cell: ({ row }) => { + const score = row.original.score; + return score ? score.toFixed(4) : "N/A"; + } + }, + { + id: "actions", + cell: ({ row }) => { + const contact = row.original; + const [isLoading, setIsLoading] = useState(false); + + const handleAnalyze = async () => { + if (!contact._id) return; + const urls = Object.values(contact.socials || {}).filter(Boolean) as string[]; + // if(contact.source) urls.push(contact.source); + if (urls.length === 0) { + alert("No URLs found for this contact to analyze."); + return; + } + setIsLoading(true); + try { + const updatedContact = await analyzePersona(contact._id, urls); + alert('Persona analysis complete!'); + refreshData(); + openPersonaModal(updatedContact); + } catch (error) { + console.error(error); + alert('Failed to analyze persona.'); + } finally { + setIsLoading(false); + } + } + + return ( +
+ {contact.persona && ( + + )} + +
+ ) + }, + }, +] diff --git a/examples/contact-extractor-agent/components/contacts-dashboard/contacts-table.tsx b/examples/contact-extractor-agent/components/contacts-dashboard/contacts-table.tsx new file mode 100644 index 0000000..5d6d575 --- /dev/null +++ b/examples/contact-extractor-agent/components/contacts-dashboard/contacts-table.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { Contact } from '@/app/ai/agents/contactExtractorAgent/helpers/schema'; +import { DataTable } from './data-table'; +import { columns } from './columns'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { PersonaModal } from './persona-modal'; + +interface SearchResult { + metadata: Contact; + score: number; +} + +async function getContacts(): Promise { + console.log(`Fetching all contacts...`); + const url = '/api/contacts'; + try { + const response = await fetch(url); + if (!response.ok) { + console.error(`API request to ${url} failed with status ${response.status}`); + return []; + } + const data = await response.json(); + console.log(`Successfully fetched ${data.length} contacts from ${url}`); + return data; + } catch (error) { + console.error(`An error occurred while fetching from ${url}:`, error); + return []; + } +} + +async function searchContacts(query: string, topK: number): Promise { + console.log(`Searching contacts... Query: "${query}", topK: ${topK}`); + const url = `/api/contacts?q=${query}&topK=${topK}`; + try { + const response = await fetch(url); + if (!response.ok) { + console.error(`API request to ${url} failed with status ${response.status}`); + return []; + } + const data = await response.json(); + console.log(`Successfully fetched ${data.length} contacts from ${url}`); + return data; + } catch (error) { + console.error(`An error occurred while fetching from ${url}:`, error); + return []; + } +} + +export function ContactsTable() { + const [contacts, setContacts] = useState([]); + const [search, setSearch] = useState(''); + const [topK, setTopK] = useState(10); + const [selectedContact, setSelectedContact] = useState(null); + const [isPersonaModalOpen, setIsPersonaModalOpen] = useState(false); + + const openPersonaModal = (contact: Contact) => { + setSelectedContact(contact); + setIsPersonaModalOpen(true); + } + + const refreshData = () => { + if (search) { + handleSearch(); + } else { + getContacts().then(setContacts); + } + } + + const handleSearch = async () => { + if (!search) { + getContacts().then(setContacts); + return; + } + const results = await searchContacts(search, topK); + setContacts(results); + } + + // Effect for the initial data load when the component mounts. + useEffect(() => { + console.log('Component mounted. Fetching initial contacts.'); + getContacts().then(setContacts); + }, []); // Empty dependency array ensures this runs only once on mount. + + const tableData = contacts.map(c => 'metadata' in c ? { ...c.metadata, score: c.score } : c); + + return ( +
+
+ setSearch(event.target.value)} + className="max-w-sm" + /> + setTopK(parseInt(event.target.value, 10))} + className="max-w-[100px]" + /> + +
+ + setIsPersonaModalOpen(false)} + /> +
+ ); +} diff --git a/examples/contact-extractor-agent/components/contacts-dashboard/data-table.tsx b/examples/contact-extractor-agent/components/contacts-dashboard/data-table.tsx new file mode 100644 index 0000000..0502645 --- /dev/null +++ b/examples/contact-extractor-agent/components/contacts-dashboard/data-table.tsx @@ -0,0 +1,80 @@ +"use client" + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ) +} diff --git a/examples/contact-extractor-agent/components/contacts-dashboard/persona-modal.tsx b/examples/contact-extractor-agent/components/contacts-dashboard/persona-modal.tsx new file mode 100644 index 0000000..65beabe --- /dev/null +++ b/examples/contact-extractor-agent/components/contacts-dashboard/persona-modal.tsx @@ -0,0 +1,54 @@ + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Contact } from "@/app/ai/agents/contactExtractorAgent/helpers/schema"; + +interface PersonaModalProps { + contact: Contact | null; + isOpen: boolean; + onClose: () => void; +} + +export function PersonaModal({ contact, isOpen, onClose }: PersonaModalProps) { + if (!contact || !contact.persona) return null; + + return ( + + + + {contact.name || 'Contact'} Persona + + Detailed persona analysis. + + +
+
+

Profession

+

{contact.persona.profession || 'N/A'}

+
+
+

Age

+

{contact.persona.age || 'N/A'}

+
+
+

Location

+

{contact.persona.location || 'N/A'}

+
+
+

Summary

+

{contact.persona.summary || 'N/A'}

+
+
+

Interests

+

{contact.persona.interests?.join(', ') || 'N/A'}

+
+
+
+
+ ) +} diff --git a/examples/contact-extractor-agent/components/studio/context/AppChatProvider.tsx b/examples/contact-extractor-agent/components/studio/context/AppChatProvider.tsx new file mode 100644 index 0000000..5494952 --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/AppChatProvider.tsx @@ -0,0 +1,432 @@ + +import { RestSdk } from "@/lib/studio/services/RestSdk"; +import { useChat, UseChatOptions } from "@ai-sdk/react"; +import { getTextParts } from "@microfox/ai-router"; +import { + ChatRequestOptions, + DefaultChatTransport, + UIMessage +} from "ai"; +import { useParams } from "next/navigation"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { toast } from "sonner"; +import useSWR from "swr"; + +type AppContextType = ReturnType; + +const AppContext = createContext( + undefined +); + +export interface AppChatProviderProps { + children: React.ReactNode; + chatOptions?: Partial>>; + sessionId?: string; +} + +export const useAppContext = ( + userChatOptions?: Partial>, + thisSessionId?: string +) => { + + const sessionId = thisSessionId || "undefined"; + + const { + data: serverMessages, + mutate, + isValidating, + isLoading, + } = useSWR( + sessionId && sessionId != "undefined" ? `/api/studio/chat/message?sessionId=${sessionId}` : undefined, + async (url: string) => { + const res = await RestSdk.getData(url, {}); + return res; + }, + { + revalidateOnMount: true, + revalidateOnFocus: true, + } + ); + + const { ...chatOptions } = useChat>({ + transport: new DefaultChatTransport({ + api: sessionId && sessionId != "undefined" ? "/api/studio/chat" : undefined, + prepareSendMessagesRequest: ({ + id, + messages, + requestMetadata, + body, + ...rest + }) => { + console.log("[prepareSendMessagesRequest]", { + id, + messages, + requestMetadata, + body, + rest, + }); + return { + ...rest, + headers: { + "Content-Type": "application/json", + "x-client-request-id": sessionId ?? "", + ...rest.headers, + }, + body: { + ...body, + messages: [messages[messages.length - 1]], + sessionId: sessionId + }, + }; + }, + }), + resume: false, + messages: (serverMessages as any[]) || [], + ...userChatOptions, + id: sessionId, + onFinish: (response) => { + console.log("[onFinish] called with response:", response); + try { + const { message, ...rest } = response; + let _minionFlow; + let _minionType; + //add this message to the db + if ( + message?.parts?.length === 0 || + !message.role || + !message?.parts || + !message?.parts[0] + ) { + console.log("[onFinish] Message has no parts, returning."); + return; + } + const _sessionId = + message.metadata?.sessionId ?? sessionId; + const postDataPayload = { + context: "on-finish", + id: message.id?.startsWith(_sessionId) + ? message.id + : _sessionId + "-" + message.id, + role: message.role, + metadata: message.metadata, + parts: message.parts, + content: getTextParts(message).join(", ") ?? "", + ...rest, + sessionId: _sessionId, + }; + console.log( + "[onFinish] Storing assistant message to DB with payload:", + postDataPayload + ); + RestSdk.postData("/api/studio/chat/message", postDataPayload); + } catch (error) { + console.error("[onFinish] Error storing assistant message:", error); + } + }, + onError: (error) => { + console.error("[onError] Chat error:", error); + }, + }); + + useEffect(() => { + if ( + serverMessages && + chatOptions.messages.length === 0 && + serverMessages.length > 0 + ) { + chatOptions.setMessages(serverMessages as any); + } + }, [serverMessages, chatOptions.messages, chatOptions.status]); + + //-----------LISTENING------------- + //console.log("lastKnownStuff", lastKnownMinionType, lastKnownMinionFlow); + + useEffect(() => { + // console.log("messages", chatOptions.messages); + // const lastMessage = chatOptions.messages?.[chatOptions.messages.length - 1]; + // const isClientRequestTitle: any = lastMessage?.metadata?.clientRequestTitle; + // if (isClientRequestTitle && isClientRequestTitle !== selectedRequest?.title) { + // mutateSelectedClientRequest(); + // } + }, [chatOptions.messages]); + + // useEffect(() => { + // //console.log("data", chatOptions.data); + // }, [chatOptions.data]); + + //-----------DB OPERATIONS ----------------- + const createMessage = useCallback( + async ( + message: UIMessage + ) => { + console.log("[createMessage] called with:", { + message + }); + if (!message.id) { + message.id = crypto.randomUUID(); + console.log("[createMessage] Generated new message id:", message.id); + } + try { + const payload = { + ...message, + id: message.id?.startsWith(sessionId) ? message.id : sessionId + "-" + message.id, + sessionId: sessionId, + content: getTextParts(message).join(", ") ?? "", + context: "on-start", + }; + console.log( + "[createMessage] Posting message to DB with payload:", + payload + ); + await RestSdk.postData("/api/studio/chat/message", payload); + console.log("[createMessage] Successfully posted message to DB."); + } catch (error) { + console.error("[createMessage] Error creating message:", error); + } + }, + [sessionId] + ); + + const deleteMessages = useCallback( + async (messageIds: string[]) => { + if (!sessionId) { + return; + } + let _ids = messageIds.map((_id) => + _id.startsWith(sessionId) ? _id : sessionId + "-" + _id + ); + return await RestSdk.deleteData( + `/api/studio/chat/message?ids=${_ids.join(",")}&sessionId=${sessionId}`, + {} + ); + }, + [sessionId] + ); + + //-----------USECHAT OVERWITES------------ + // BYPASSING the original handle submit + const handleSubmit = async ( + text: string, + event?: { + preventDefault?: () => void; + }, + chatRequestOptions?: ChatRequestOptions + ) => { + console.log("[handleSubmit] called with:", { text, chatRequestOptions }); + // first store the lastmessage to db + const newMessage: UIMessage = { + id: crypto.randomUUID(), + role: "user", + parts: [ + { + type: "text", + text: text, + }, + ], + }; + if (event) { + event.preventDefault?.(); + } + setInput(""); + console.log("[handleSubmit] created new message:", newMessage); + await appendMessage({ message: newMessage, chatRequestOptions }); + console.log("[handleSubmit] finished."); + }; + + /** should prefill the minion annotation */ + const appendMessage = useCallback( + async ({ + message, + chatRequestOptions, + bypassDb, + bypassAi, + }: { + message: UIMessage; + chatRequestOptions?: ChatRequestOptions; + bypassDb?: boolean; + bypassAi?: boolean; + }) => { + console.log("[appendMessage] called with:", { + message, + chatRequestOptions, + bypassDb, + }); + + if ( + !sessionId && + !(chatRequestOptions?.body as any)?.sessionId && + sessionId == "undefined" + ) { + toast.error("Chat session not initialized. Please try again."); + return; + } + + const bodyParams = { + ...chatRequestOptions?.body, + ...(sessionId ? { sessionId: sessionId } : {}), + }; + console.log("[appendMessage] Constructed bodyParams:", bodyParams); + + let messageNew = { + ...message, + id: message.id + ? sessionId && message.id.startsWith(sessionId) + ? message.id + : sessionId + "-" + message.id + : crypto.randomUUID(), + metadata: { + ...bodyParams, + ...(message.metadata || {}), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }; + console.log("[appendMessage] Constructed messageNew:", messageNew); + + const promises = [] + if (!bypassDb) { + console.log("[appendMessage] Storing user message to DB..."); + promises.push(createMessage(messageNew)); + console.log("[appendMessage] Stored user message to DB."); + } + + console.log("[appendMessage] Calling chatOptions.sendMessage with:", { + messageNew, + chatRequestOptions: { ...chatRequestOptions, body: bodyParams }, + }); + if (!bypassAi) { + promises.push(chatOptions.sendMessage(messageNew, { + ...chatRequestOptions, + body: bodyParams, + })); + } else { + promises.push(chatOptions.setMessages((messages) => [...messages, messageNew])); + } + await Promise.all(promises); + }, + [ + sessionId + ] + ); + + const handleEditMessage = async (messageIndex: number) => { + if (messageIndex >= chatOptions.messages.length) { + return; + } + // show alert dialog to confirm the edit + const messageToEdit = { ...chatOptions.messages[messageIndex] }; + if (messageToEdit.role !== "user") { + console.error("Cannot edit an assistant message index."); + toast.error("Cannot edit from this point."); + return; + } + chatOptions.stop(); + + const newTextInput = getTextParts( + messageToEdit as UIMessage + ); + setInput(newTextInput?.[0] ?? ""); + + const messagesToDelete = chatOptions.messages.slice(messageIndex); + if (messagesToDelete.length > 0) { + const response = await deleteMessages(messagesToDelete.map((m) => m.id)); + console.log("deleted messages", response); + } + + chatOptions.setMessages((messages) => { + let newMessages = messages.slice(0, messageIndex); + if (!newMessages || newMessages.length === 0) { + newMessages = []; + } + return newMessages; + }); + }; + + const handleRefresh = async (messageIndex: number) => { + if (messageIndex >= chatOptions.messages.length) { + return; + } + + const messageToResend = { ...chatOptions.messages[messageIndex] }; // The user message to resend + if (messageToResend.role !== "user") { + console.error("Cannot refresh from an assistant message index."); + toast.error("Cannot refresh from this point."); + return; + } + + chatOptions.stop(); + // Extract body parameters from annotations + const bodyParams = messageToResend.metadata; + + const messagesToDelete = chatOptions.messages.slice(messageIndex + 1); // Assistant response and subsequent messages + if (messagesToDelete.length > 0) { + const response = await deleteMessages(messagesToDelete.map((m) => m.id)); + console.log("deleted messages", response); + } + + chatOptions.setMessages((messages) => { + const newMessages = messages.slice(0, messageIndex); + return newMessages; + }); + + if (messageToResend) { + try { + // Resend the user message, triggering backend processing again + await appendMessage({ + message: messageToResend as UIMessage, + chatRequestOptions: { body: bodyParams }, + bypassDb: true, + bypassAi: false, + }); + } catch (error) { + console.log("error appending message during refresh", error); + toast.error("Failed to refresh. Please try again."); + } + } + }; + + const [input, setInput] = useState(""); + + return { + ...chatOptions, + handleSubmit, + append: appendMessage, + mutate, + isLoading, + isValidating, + serverMessages, + handleRefresh, + handleEditMessage, + input, + setInput, + }; +}; + +export const AppChatProvider: React.FC = ({ + children, + chatOptions: userChatOptions, + sessionId, +}) => { + const value = useAppContext(userChatOptions, sessionId); + return ( + + {children} + + ); +}; + +// Custom hook to use the App context +export const useAppChat = () => { + const context = useContext(AppContext); + if (context === undefined) { + throw new Error("useApp must be used within a AppProvider"); + } + return context; +}; diff --git a/examples/contact-extractor-agent/components/studio/context/AppSessionProvider.tsx b/examples/contact-extractor-agent/components/studio/context/AppSessionProvider.tsx new file mode 100644 index 0000000..05fd73e --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/AppSessionProvider.tsx @@ -0,0 +1,140 @@ + + +"use client"; + +import { RestSdk } from "@/lib/studio/services/RestSdk"; +import { useParams, useRouter } from "next/navigation"; +import { + createContext, + ReactNode, + useContext, + useEffect, + useState +} from "react"; +import { ChatSession } from "@/app/api/studio/chat/sessions/chatSessionUpstash"; +import { toast } from "sonner"; +import useSWR from "swr"; +import useLocalState from "./hooks/useLocalState"; +import dayjs from "dayjs"; + + +export const constructAutoSubmitMessage = (autoSubmit: string) => { + return { + id: crypto.randomUUID(), + content: autoSubmit, + role: "user" as const, + parts: [ + { + type: "text" as const, + text: autoSubmit, + }, + ], + }; +}; + +type AppSessionContext = ReturnType; + +const AppSessionContext = createContext< + AppSessionContext | undefined +>(undefined); + +function useAppSessionContext(sessionId?: string) { + const [selectedSession, setSelectedSession] = + useLocalState("selectedSession", null); + const router = useRouter(); + + + const [submitMetadata, setSubmitMetadata] = useState({}); + const [autoSubmit, setAutoSubmit] = useState(null); + + + const { + data: sessions = [], + error, + isLoading, + mutate: mutateSessions, + } = useSWR( + `/api/studio/chat/sessions`, + async (url: string) => await fetch(url).then((res) => res.json()) + ); + + useEffect(() => { + if (sessionId && sessionId != "undefined" && sessions.length > 0) { + setSelectedSession(sessions.find((session) => session.id === sessionId) ?? null); + } + }, [sessionId, sessions]); + + const createNewEmptySession = async ( + metadata?: any, + _autoSubmit?: string + ) => { + + toast.promise( + RestSdk.postData(`/api/studio/chat/sessions`, { + metadata: metadata ?? {}, + autoSubmit: _autoSubmit ?? null, + }), + { + loading: "Starting new session...", + success: (newRequest) => { + if (!newRequest) { + return "Failed to create session. Please try again."; + } + console.log("newRequest", newRequest); + setSelectedSession(newRequest); + setSubmitMetadata(metadata ?? {}); + setAutoSubmit(_autoSubmit ?? null); + mutateSessions(); + router.push( + `/studio/chat/${newRequest.id}` + ); + return "Session created successfully!"; + }, + error: "Failed to create session. Please try again.", + } + ); + }; + + + + return { + sessionId: sessionId ?? selectedSession?.id ?? null, + session: selectedSession, + sessions: sessions?.sort((a, b) => dayjs(b.createdAt).toDate().getTime() - dayjs(a.createdAt).toDate().getTime()), + isLoading: isLoading, + createNewEmptySession, + setSelectedSession, + mutateSessions, + submitMetadata, + setSubmitMetadata, + autoSubmit, + setAutoSubmit, + }; +} + +export function useAppSession() { + const context = useContext(AppSessionContext); + if (context === undefined) { + throw new Error( + "useAppSession must be used within a AppSessionProvider" + ); + } + return context; +} + +interface ClientProjectProviderProps { + children: ReactNode; + sessionId?: string; +} + +export function AppSessionProvider({ + children, + sessionId, +}: ClientProjectProviderProps) { + const value = useAppSessionContext(sessionId); + return ( + + {children} + + ); +} diff --git a/examples/contact-extractor-agent/components/studio/context/ChatUiProvider.tsx b/examples/contact-extractor-agent/components/studio/context/ChatUiProvider.tsx new file mode 100644 index 0000000..a6053bc --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/ChatUiProvider.tsx @@ -0,0 +1,63 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, +} from "react"; + +// Create the context with a default value +const ChatUIContext = createContext | undefined>(undefined); + +// Props for the provider component +export interface ChatUIProviderProps { + children: React.ReactNode; +} + +// Define the hook that will be used to create the context value +export const useChatUIContextValue = () => { + const [hasScrolled, setHasScrolled] = useState(false); + const messagesEndRef = useRef(null); + + // Scroll to the bottom of the chat window + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + + const handleScroll = (e: React.UIEvent) => { + if (!hasScrolled) { + setHasScrolled(true); + } + }; + + // Return the context value + return { + hasScrolled, + setHasScrolled, + handleScroll, + messagesEndRef, + scrollToBottom + }; +}; + +// The provider component +export const ChatUIProvider: React.FC = ({ children }) => { + const contextValue = useChatUIContextValue(); + + return ( + + {children} + + ); +}; + +// Custom hook to use the ChatUI context +export const useChatUI = () => { + const context = useContext(ChatUIContext); + if (context === undefined) { + throw new Error("useChatUIContext must be used within a ChatUIProvider"); + } + return context; +}; diff --git a/examples/contact-extractor-agent/components/studio/context/ComponentProvider.tsx b/examples/contact-extractor-agent/components/studio/context/ComponentProvider.tsx new file mode 100644 index 0000000..772511e --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/ComponentProvider.tsx @@ -0,0 +1,130 @@ +'use client'; + +import React, { + createContext, + useContext, + useState, + ReactNode, + ComponentType, +} from 'react'; +import { ToolSet, ToolUIPart } from 'ai'; +import { AgentData, AgentTool, UITools } from '@microfox/ai-router'; + +type Equal = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false; + + +type ToolComponentMap = { + input?: ComponentType<{ tool: ToolUIPart }>; + output?: ComponentType<{ tool: ToolUIPart }>; + full?: ComponentType<{ tool: ToolUIPart }>; + header?: ComponentType<{ tool: ToolUIPart }>; + header_sticky?: ComponentType<{ tool: ToolUIPart, isStickyRender?: boolean }>; + footer?: ComponentType<{ tool: ToolUIPart }>; + footer_sticky?: ComponentType<{ tool: ToolUIPart, isStickyRender?: boolean }>; + side?: ComponentType<{ tool: ToolUIPart }>; +} & { + [key: string]: ComponentType<{ tool: ToolUIPart }>; +} + +type DataComponentMap> = { + [key: string]: ComponentType; +} + +// Define the type for the component map. It maps a string key to a React component. +export type AiComponentMap

= { + tools?: Record>; + data?: Partial>>; +}; + +// Define the props for the ComponentProvider. It takes a map of components and children. +export interface ComponentProviderProps { + children: ReactNode; + componentMap: AiComponentMap; + aiRouterTools: ToolSet; + //aiRouterMetadata: Record; +} + +export const useComponentProviderValue = ({ + componentMap, + aiRouterToolMetadata, +}: { + componentMap: AiComponentMap; + aiRouterToolMetadata: Record +}) => { + const [components] = useState(componentMap); + const [metadata] = useState>(aiRouterToolMetadata); + + /** + * Retrieves a component from the map by its name. + * @param {string} name The name of the component to retrieve. + * @returns {ComponentType | undefined} The component if found, otherwise undefined. + */ + const getToolComponent =

(id: string, type: P extends keyof ToolComponentMap ? P : keyof ToolComponentMap) => { + let _id = (id as string)?.replace("tool-", ""); + if (components?.tools && _id in components.tools) { + return components.tools + ?.[_id as keyof AiComponentMap["tools"]]?.[type] as ToolComponentMap[P]; + } + return undefined; + }; + + const getDataComponent = (id: any, type: keyof DataComponentMap) => { + let _id = (id as string)?.replace("data-", "") as string; + if (components?.data && _id in components.data) { + return components.data?.[_id as keyof AiComponentMap["data"]]?.[type]; + } + return undefined; + }; + + return { + getToolComponent, + getDataComponent, + aiRouterMetadata: metadata, + }; +}; + +// Create the context with a default undefined value. +const ComponentContext = createContext< + ReturnType | undefined +>(undefined); + +/** + * The provider component that holds the component map and makes it available to its children. + * @param {ComponentProviderProps} props The props for the component. + * @returns {ReactNode} The provider component wrapping the children. + */ +export const ComponentProvider: React.FC = ({ + children, + componentMap, + aiRouterTools +}) => { + const aiRouterMetadata: Record = Object.entries(aiRouterTools).reduce((acc, [key, value]) => { + acc[key as string] = { + ...(value as AgentTool).metadata, + }; + return acc; + }, {} as Record); + const contextValue = useComponentProviderValue({ componentMap, aiRouterToolMetadata: aiRouterMetadata }); + + return ( + + {children} + + ); +}; + +/** + * Custom hook to access the component context. + * This makes it easy for child components to get components from the map. + * @returns {ReturnType} The context value. + * @throws {Error} If used outside of a ComponentProvider. + */ +export const useComponents = () => { + const context = useContext(ComponentContext); + if (context === undefined) { + throw new Error('useComponents must be used within a ComponentProvider'); + } + return context; +}; diff --git a/examples/contact-extractor-agent/components/studio/context/FileUploadProvider.tsx b/examples/contact-extractor-agent/components/studio/context/FileUploadProvider.tsx new file mode 100644 index 0000000..0d0331a --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/FileUploadProvider.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { StudioConfig } from "@/microfox.config"; +import { createContext, ReactNode, useContext, useState } from "react"; +import { toast } from "sonner"; + +export type AttachedMedia = { + file: File; + preview: string; +}; + +export type MediaPayload = { + mediaName: string; + mediaType: string; + mediaFormat: string; + mediaUrl: string; +}; + +interface FileUploadContextType { + attachedMedia: AttachedMedia[]; + mediaPayload: MediaPayload[]; + mediaUploadStatus: "IDLE" | "UPLOADING" | "SUCCESS" | "ERROR"; + handleFileChange: ( + files: FileList, + userId: string, + folderName: string, + ) => Promise; + removeMedia: (index: number) => void; + setAttachedMedia: (media: AttachedMedia[]) => void; + setMediaPayload: (payload: MediaPayload[]) => void; + resetMedia: () => void; +} + +const FileUploadContext = createContext( + undefined, +); + +export function FileUploadProvider({ children }: { children: ReactNode }) { + const [attachedMedia, setAttachedMedia] = useState([]); + const [mediaPayload, setMediaPayload] = useState([]); + const [mediaUploadStatus, setMediaUploadStatus] = useState< + "IDLE" | "UPLOADING" | "SUCCESS" | "ERROR" + >("IDLE"); + + const handleFileChange = async ( + files: FileList, + userId: string, + folderName: string, + ) => { + if (files && files.length > 0) { + const filesArray = Array.from(files); + const newMedia = filesArray.map((file) => ({ + file, + preview: URL.createObjectURL(file), + })); + setAttachedMedia((prev) => [...prev, ...newMedia]); + + const formData = new FormData(); + filesArray.forEach((file) => { + formData.append("file", file); + }); + formData.append("userId", userId); + formData.append("folderName", folderName); + + setMediaUploadStatus("UPLOADING"); + try { + const response = await fetch("/api/client-requests/chat/upload-media", { + method: "POST", + body: formData, + headers: { + Authorization: StudioConfig.studioSettings.database.fileUpload?.apiKey || '', + }, + }); + + if (!response.ok) { + throw new Error("Failed to upload media"); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || "Failed to upload media"); + } + + setMediaPayload((prev) => [...prev, ...data.media]); + setMediaUploadStatus("SUCCESS"); + } catch (error) { + toast.error("Failed to upload media"); + console.error("Error uploading media:", error); + setMediaUploadStatus("ERROR"); + } + } + }; + + const removeMedia = (index: number) => { + setMediaPayload((prev) => prev.filter((_, i) => i !== index)); + setAttachedMedia((prev) => { + const updated = [...prev]; + if (updated[index]?.preview) { + URL.revokeObjectURL(updated[index].preview); + } + updated.splice(index, 1); + return updated; + }); + //TODO: delete file from backend + }; + + const resetMedia = () => { + attachedMedia.forEach((media) => URL.revokeObjectURL(media.preview)); + setAttachedMedia([]); + setMediaPayload([]); + setMediaUploadStatus("IDLE"); + }; + + return ( + + {children} + + ); +} + +export function useFileUpload() { + const context = useContext(FileUploadContext); + if (context === undefined) { + throw new Error("useFileUpload must be used within a FileUploadProvider"); + } + return context; +} diff --git a/examples/contact-extractor-agent/components/studio/context/LayoutProvider.tsx b/examples/contact-extractor-agent/components/studio/context/LayoutProvider.tsx new file mode 100644 index 0000000..70811e6 --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/LayoutProvider.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useRef, + useCallback, +} from "react"; +import * as ResizablePrimitive from "react-resizable-panels"; +import useLocalState from "./hooks/useLocalState"; + +interface LayoutContextType { + isLeftCollapsed: boolean; + isRightCollapsed: boolean; + isLeftTransitioning: boolean; + isRightTransitioning: boolean; + leftPanelRef: React.RefObject; + rightPanelRef: React.RefObject; + handleLeftCollapse: (collapsed: boolean) => void; + handleRightCollapse: (collapsed: boolean) => void; + handleLeftResizeHandleClick: () => void; + handleRightResizeHandleClick: () => void; + isFeedbackModalOpen: boolean; + setIsFeedbackModalOpen: (open: boolean) => void; + rightPanelContent: React.ReactNode | null; + setRightPanelContent: (content: React.ReactNode | null) => void; +} + +const LayoutContext = createContext(undefined); + +export const LayoutProvider = ({ children }: { children: React.ReactNode }) => { + const [isLeftCollapsed, setIsLeftCollapsed] = useState( + false + ); + const [isRightCollapsed, setIsRightCollapsed] = useState( + true + ); + const [isLeftTransitioning, setIsLeftTransitioning] = useState(false); + const [isRightTransitioning, setIsRightTransitioning] = useState(false); + const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false); + const [rightPanelContent, setRightPanelContent] = + useState(null); + const leftPanelRef = useRef( + null + ); + const rightPanelRef = useRef( + null + ); + const transitionTimeoutRef = useRef(undefined); + + const handleLeftCollapse = useCallback((collapsed: boolean) => { + if (transitionTimeoutRef.current) { + clearTimeout(transitionTimeoutRef.current); + } + + setIsLeftTransitioning(true); + if (leftPanelRef.current) { + if (collapsed) { + leftPanelRef.current.collapse(); + } else { + leftPanelRef.current.expand(); + } + } + setIsLeftCollapsed(collapsed); + + transitionTimeoutRef.current = setTimeout(() => { + setIsLeftTransitioning(false); + }, 400); + }, []); + + const handleRightCollapse = useCallback((collapsed: boolean) => { + if (transitionTimeoutRef.current) { + clearTimeout(transitionTimeoutRef.current); + } + + setIsRightTransitioning(true); + if (rightPanelRef.current) { + if (collapsed) { + rightPanelRef.current.collapse(); + } else { + rightPanelRef.current.expand(); + } + } + setIsRightCollapsed(collapsed); + + transitionTimeoutRef.current = setTimeout(() => { + setIsRightTransitioning(false); + }, 400); + }, []); + + const handleLeftResizeHandleClick = useCallback(() => { + handleLeftCollapse(!isLeftCollapsed); + }, [isLeftCollapsed, handleLeftCollapse]); + + const handleRightResizeHandleClick = useCallback(() => { + handleRightCollapse(!isRightCollapsed); + }, [isRightCollapsed, handleRightCollapse]); + + return ( + + {children} + + ); +}; + +export const useLayout = () => { + const context = useContext(LayoutContext); + if (context === undefined) { + throw new Error("useLayout must be used within a LayoutProvider"); + } + return context; +}; diff --git a/examples/contact-extractor-agent/components/studio/context/MessageProvider.tsx b/examples/contact-extractor-agent/components/studio/context/MessageProvider.tsx new file mode 100644 index 0000000..ef48cef --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/MessageProvider.tsx @@ -0,0 +1,78 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; +import { UIMessage, UITools } from "ai"; +import { getDisplayParts, getStickyUiParts, getToolParts, getUiParts } from "./helpers/parts"; +import { useAppChat } from "./AppChatProvider"; +import { useComponents } from "./ComponentProvider"; + + + +type MessageContextType = ReturnType>; + +const MessageContext = createContext(undefined); + +export interface MessageProviderProps { + children: React.ReactNode; + message: UIMessage; +} + +export const useMessageContext = ({ message }: { message: UIMessage }) => { + const [activePart, setActivePart] = useState(0); + const { aiRouterMetadata } = useComponents(); + const displayParts = getDisplayParts(message, aiRouterMetadata); + const uiParts = getUiParts(message); + const toolParts = getToolParts(message); + const stickyUiParts = getStickyUiParts(uiParts); + + const { status } = useAppChat(); + + useEffect(() => { + if (displayParts && displayParts.length > 1) { + setActivePart(displayParts.length - 1); + } + }, []); + + useEffect(() => { + if (displayParts && displayParts.length > 1 && status === "streaming" && activePart !== displayParts.length - 1) { + setActivePart((_index) => displayParts.length - 1); + } + }, [displayParts, status, activePart]); + + const setActivePartByToolCallId = (toolCallId?: string) => { + if (!toolCallId) { + return; + } + const part = displayParts.findIndex((p) => (p as any).toolCallId === toolCallId); + if (part !== -1) { + setActivePart(part); + } + } + + return { + activePart, + setActivePart, + displayParts, + uiParts, + toolParts, + stickyUiParts, + message, + setActivePartByToolCallId, + }; +}; + +export const MessageProvider: React.FC = ({ + children, + message, +}) => { + const value = useMessageContext({ message }); + return ( + {children} + ); +}; + +export const useMessageParts = () => { + const context = useContext(MessageContext) as MessageContextType; + if (context === undefined) { + throw new Error("useMessage must be used within a MessageProvider"); + } + return context; +}; diff --git a/examples/contact-extractor-agent/components/studio/context/TabUiProvider.tsx b/examples/contact-extractor-agent/components/studio/context/TabUiProvider.tsx new file mode 100644 index 0000000..ca27efc --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/TabUiProvider.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useState } from "react"; + +export type TabType = "chat" | "dashboard" | "history" | "code" | "publish"; + +interface TabUiContextType { + activeTab: TabType; + setActiveTab: (tab: TabType) => void; + previousTab: TabType | null; + setPreviousTab: (tab: TabType) => void; + getSlideDirection: (tabValue: string) => number; +} + +const TabUiContext = createContext(undefined); + +export const useTabUi = () => { + const context = useContext(TabUiContext); + if (!context) { + throw new Error("useTabUi must be used within a TabUiProvider"); + } + return context; +}; + +export const TabUiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [activeTab, setActiveTab] = useState("chat"); + const [previousTab, setPreviousTab] = useState(null); + + const getSlideDirection = (tabValue: string) => { + if (!previousTab || previousTab === tabValue) return 0; + const tabOrder = ["chat", "history", "dashboard"]; + const prevIndex = tabOrder.indexOf(previousTab); + const currentIndex = tabOrder.indexOf(tabValue); + return prevIndex < currentIndex ? 100 : -100; + }; + + const value = { + activeTab, + setActiveTab, + previousTab, + setPreviousTab, + getSlideDirection, + }; + + return {children}; +}; \ No newline at end of file diff --git a/examples/contact-extractor-agent/components/studio/context/helpers/parts.ts b/examples/contact-extractor-agent/components/studio/context/helpers/parts.ts new file mode 100644 index 0000000..5a80d94 --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/helpers/parts.ts @@ -0,0 +1,125 @@ +import { UIMessage } from '@ai-sdk/react'; +import { AgentTool } from '@microfox/ai-router'; +import { ToolUIPart } from 'ai'; + +const EXCLUDED_PARTS = ['tool-', 'step-start']; +const MUST_INCLUDE_PARTS = ['tool-ui-']; + +export const getDisplayParts = ( + message: UIMessage, + aiRouterMetadata?: Record, +) => { + return message?.parts?.filter((part) => { + if (part.type.startsWith('tool-ui-')) { + if ((part as any).output?.props?.isUISticky) { + return false; + } + return true; + } + if (part.type === 'text') { + if ((part as any).text.replaceAll('/n', '').trim() === '') { + return false; + } + return true; + } + if ( + part.type.startsWith('tool-') && + (part as any).state == 'output-available' && + !(aiRouterMetadata + ? aiRouterMetadata[ + (part as any).type.replace( + 'tool-', + '', + ) as keyof typeof aiRouterMetadata + ]?.hideUI + : true) + // && ((part as any).output?.data || (part as any).output?.ui) + ) { + return true; + } + if ( + part.type.startsWith('data-') && + !(part as any).data?.metadata?.hideUI + ) { + return true; + } + if ( + part.type.match(/summary/i) && + (part as any).state == 'output-available' && + (part as any).output?.summary && + (part as any).output?._isFinal + ) { + return true; + } + if (EXCLUDED_PARTS.some((excluded) => part.type.startsWith(excluded))) { + return false; + } + return true; + }); +}; + +export const getToolParts = (message: UIMessage) => { + return message?.parts?.filter((part) => { + if (part.type.startsWith('tool-')) { + return true; + } + }) as ToolUIPart[]; +}; + +export const getUiParts = (message: UIMessage) => { + return message?.parts?.filter( + (part) => + part.type.startsWith('tool-ui-') || + (part as any).output?._humanIntervention, + ) as ToolUIPart[]; +}; + +/** + * Sticky Parts are that parts which are sticky even in the next parts they appear.. + * @param parts + * @returns + */ +export const getStickyUiParts = (parts: ToolUIPart[]) => { + return parts?.filter( + (part, _index) => + (part.type.startsWith('data-') && + (part as any).data?.metadata?.isUISticky) || + (part.state === 'output-available' && + ((part as any).output?.metadata?.isUISticky || + part.output?._humanIntervention)), + ); +}; + +export const getLastActiveToolPart = ( + message: UIMessage, + activePart: number, + displayParts?: UIMessage['parts'], +): ToolUIPart | undefined => { + if (!displayParts || displayParts.length === 0) { + return undefined; + } + + if (activePart === displayParts.length - 1) { + const lastDisplayPart = displayParts[activePart]; + + if (lastDisplayPart && displayParts) { + const originalIndexOfLastDisplayPart = + displayParts.lastIndexOf(lastDisplayPart); + + if (originalIndexOfLastDisplayPart > 0) { + for (let i = originalIndexOfLastDisplayPart - 1; i >= 0; i--) { + const part = displayParts[i]; + if ( + part && + part.type.startsWith('tool-') && + !part.type.startsWith('tool-ui-') + ) { + return part as ToolUIPart; + } + } + } + } + } + + return undefined; +}; diff --git a/examples/contact-extractor-agent/components/studio/context/hooks/useLocalState.ts b/examples/contact-extractor-agent/components/studio/context/hooks/useLocalState.ts new file mode 100644 index 0000000..19ff46f --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/context/hooks/useLocalState.ts @@ -0,0 +1,53 @@ +import { useState, useCallback, useRef } from 'react'; + +export const IS_BROWSER = typeof window !== 'undefined'; +export const SUPPORTED = IS_BROWSER && window.localStorage; + +type Dispatch = (value: A) => void; +type SetStateAction = S | ((prevState: S) => S); + +const useLocalState = ( + key: string, + defaultValue: S | (() => S), +): [S, Dispatch>, () => void] => { + const [value, setValue] = useState(() => { + const isCallable = (value: unknown): value is () => S => + typeof value === 'function'; + const toStore = isCallable(defaultValue) ? defaultValue() : defaultValue; + if (!SUPPORTED) return toStore; + const item = window.localStorage.getItem(key); + try { + return item ? JSON.parse(item) : toStore; + } catch (error) { + return toStore; + } + }); + + const lastValue = useRef(value); + lastValue.current = value; + + const setLocalStateValue = useCallback( + (newValue: SetStateAction) => { + const isCallable = (value: unknown): value is (prevState: S) => S => + typeof value === 'function'; + const toStore = isCallable(newValue) + ? newValue(lastValue.current) + : newValue; + if (SUPPORTED) window.localStorage.setItem(key, JSON.stringify(toStore)); + setValue(toStore); + }, + [key], + ); + + const reset = useCallback(() => { + const isCallable = (value: unknown): value is (prevState: S) => S => + typeof value === 'function'; + const toStore = isCallable(defaultValue) ? defaultValue() : defaultValue; + setValue(toStore); + if (SUPPORTED) window.localStorage.removeItem(key); + }, [defaultValue, key]); + + return [value, setLocalStateValue, reset]; +}; + +export default useLocalState; diff --git a/examples/contact-extractor-agent/components/studio/global/MessageThoughtWrapper.tsx b/examples/contact-extractor-agent/components/studio/global/MessageThoughtWrapper.tsx new file mode 100644 index 0000000..638b859 --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/global/MessageThoughtWrapper.tsx @@ -0,0 +1,44 @@ +import { FC, ReactNode, useRef, useEffect, useState } from 'react'; +import { InternalMarkdown } from './markdown'; + +interface MessageThoughtWrapperProps { + children: string; + isStreaming?: boolean; +} + +export const MessageMarkdown: FC = ({ children, isStreaming = false }) => { + const scrollRef = useRef(null); + const [isCollapsed, setIsCollapsed] = useState(false); + const isThought = children?.trim()?.toLowerCase()?.startsWith('thought:'); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [children]); + + useEffect(() => { + if (!isStreaming && isThought) { + setIsCollapsed(true); + } + }, [isStreaming, isThought]); + + if (isThought) { + return ( +

+
+
+
!isStreaming && setIsCollapsed(!isCollapsed)} + style={{ cursor: !isStreaming ? 'pointer' : 'default' }} + > + {children} +
+
+ ); + } + + return {children}; +}; diff --git a/examples/contact-extractor-agent/components/studio/global/PrintCode.tsx b/examples/contact-extractor-agent/components/studio/global/PrintCode.tsx new file mode 100644 index 0000000..4fca852 --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/global/PrintCode.tsx @@ -0,0 +1,10 @@ +import { InternalMarkdown } from "./markdown"; + + +export const PrintCode = ({ code }: { code: string }) => { + return ( + + {code} + + ); +}; \ No newline at end of file diff --git a/examples/contact-extractor-agent/components/studio/global/markdown.tsx b/examples/contact-extractor-agent/components/studio/global/markdown.tsx new file mode 100644 index 0000000..772fc4b --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/global/markdown.tsx @@ -0,0 +1,28 @@ +import dynamic from 'next/dynamic'; +import { FC } from 'react'; + +const TiptapMarkdown = dynamic(() => import('./tiptap-markdown'), { + ssr: false, +}); + +const markdownRegex = + /(^#+\s|\*\*|__|\*|_|~~|`|\[.+\]\(.+\)|!\[.+\]\(.+\)|`{3}|(^>\s)|(^\s*[-\*]\s)|(^\s*\d+\.\s))/m; + +const isMarkdown = (text: string) => { + return markdownRegex.test(text); +}; + +const htmlRegex = /<[a-z][\s\S]*>/i; +const isHtml = (text: string) => { + return htmlRegex.test(text); +}; + +export const InternalMarkdown: FC<{ children: string; className?: string }> = ({ + children, + className, +}) => { + if (isMarkdown(children) || isHtml(children)) { + return {children}; + } + return <>{children}; +}; diff --git a/examples/contact-extractor-agent/components/studio/global/remarkTimeSince.ts b/examples/contact-extractor-agent/components/studio/global/remarkTimeSince.ts new file mode 100644 index 0000000..c67f614 --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/global/remarkTimeSince.ts @@ -0,0 +1,37 @@ +import dayjs from 'dayjs'; + +function formatTimeSince(timestamp: number): string { + const now = Date.now(); + const secondsAgo = Math.floor((now - timestamp) / 1000); + + const intervals = { + year: 31536000, + month: 2592000, + week: 604800, + day: 86400, + hour: 3600, + minute: 60, + second: 1, + }; + + for (const [unit, seconds] of Object.entries(intervals)) { + const interval = Math.floor(secondsAgo / seconds); + if (interval >= 1) { + return `${interval} ${unit}${interval !== 1 ? 's' : ''} ago`; + } + } + + return 'just now'; +} + +export function processTimeSince(content: string): string { + return content.replace(/(.*?)<\/timesince>/g, (_, timestamp) => { + const time = isNaN(timestamp as any) + ? dayjs(timestamp).valueOf() + : parseInt(timestamp, 10) * 1000; + if (!isNaN(time)) { + return formatTimeSince(time); + } + return `${timestamp}`; + }); +} diff --git a/examples/contact-extractor-agent/components/studio/global/tiptap-markdown.tsx b/examples/contact-extractor-agent/components/studio/global/tiptap-markdown.tsx new file mode 100644 index 0000000..cb45825 --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/global/tiptap-markdown.tsx @@ -0,0 +1,121 @@ +import { FC, useEffect, useState } from 'react'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Link from '@tiptap/extension-link'; +import CodeBlock from '@tiptap/extension-code-block'; +import { Table } from '@tiptap/extension-table'; +import { TableRow } from '@tiptap/extension-table-row'; +import { TableHeader } from '@tiptap/extension-table-header'; +import { TableCell } from '@tiptap/extension-table-cell'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkGfm from 'remark-gfm'; +import remarkRehype from 'remark-rehype'; +import rehypeRaw from 'rehype-raw'; +import rehypeStringify from 'rehype-stringify'; +import { processTimeSince } from './remarkTimeSince'; +import '@/components/studio/tiptap.css'; + +interface TiptapMarkdownProps { + children: string; + className?: string; +} + +const TiptapMarkdown: FC = ({ + children, + className, +}) => { + const [htmlContent, setHtmlContent] = useState(''); + + useEffect(() => { + const processMarkdown = async () => { + const processedContent = processTimeSince(children); + + const result = await unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeRaw) + .use(rehypeStringify) + .process(processedContent); + + setHtmlContent(String(result)); + }; + + processMarkdown(); + }, [children]); + + const editor = useEditor({ + immediatelyRender: false, + extensions: [ + StarterKit.configure({ + heading: { + levels: [1, 2, 3, 4, 5, 6], + }, + paragraph: { + }, + bulletList: { + keepMarks: true, + keepAttributes: false, + }, + orderedList: { + keepMarks: true, + keepAttributes: false, + }, + }), + Link.configure({ + openOnClick: true, + HTMLAttributes: { + class: 'text-blue-500 hover:text-blue-600 underline', + }, + }), + CodeBlock.configure({ + HTMLAttributes: { + class: 'bg-zinc-100 dark:bg-zinc-800 rounded-md p-4 my-2 overflow-x-auto', + }, + }), + Table.configure({ + resizable: true, + HTMLAttributes: { + class: 'text-xs dark:border-gray-700 rounded-md p-1 px-2 overflow-x-auto rounded-xl my-3', + }, + }), + TableRow.configure({ + HTMLAttributes: { + class: 'text-xs dark:border-gray-700 rounded-md p-1 px-2 overflow-x-auto rounded-xl', + }, + }), + TableHeader.configure({ + HTMLAttributes: { + class: 'border border-gray-300 bg-neutral-100 dark:border-gray-700 rounded-md p-1 px-2 overflow-x-auto', + }, + }), + TableCell.configure({ + HTMLAttributes: { + class: 'border border-gray-300 dark:border-gray-700 rounded-md p-1 overflow-x-auto rounded-xl', + }, + }), + ], + editorProps: { + attributes: { + style: 'font-size: 12px', + }, + }, + content: htmlContent, + editable: false, + }); + + useEffect(() => { + if (editor && htmlContent) { + editor.commands.setContent(htmlContent); + } + }, [editor, htmlContent]); + + return ( +
+ +
+ ); +}; + +export default TiptapMarkdown; \ No newline at end of file diff --git a/examples/contact-extractor-agent/components/studio/input/ChatInputBox.tsx b/examples/contact-extractor-agent/components/studio/input/ChatInputBox.tsx new file mode 100644 index 0000000..eab7daf --- /dev/null +++ b/examples/contact-extractor-agent/components/studio/input/ChatInputBox.tsx @@ -0,0 +1,212 @@ +/* eslint-disable @next/next/no-img-element */ +import { cn } from "@/lib/utils"; +import { UIMessage } from "ai"; +import { + ChevronRight, + FileText, + RefreshCw, + XIcon +} from "lucide-react"; +import { useEffect, useRef } from "react"; +import { useAppSession } from "../context/AppSessionProvider"; +import { useFileUpload } from "../context/FileUploadProvider"; +// import { McpSelect } from "./input/McpSelect"; +import { ToolSpeed } from "./ToolSpeed"; +import { constructAutoSubmitMessage } from "../context/AppSessionProvider"; +import { useAppChat } from "../context/AppChatProvider"; +interface ChatInputBoxProps { + messageStatus: "pending" | "streaming" | "submitted" | "ready" | "error"; + className?: string; + lastMessage?: UIMessage; +} + +export default function ChatInputBox({ + messageStatus, + lastMessage, + className, +}: ChatInputBoxProps) { + const fileInputRef = useRef(null); + const { attachedMedia, mediaUploadStatus, handleFileChange, removeMedia } = + useFileUpload(); + const { submitMetadata, setSubmitMetadata, autoSubmit, setAutoSubmit } = useAppSession(); + const { handleSubmit, messages, status, append, input, setInput } = useAppChat(); + const textareaRef = useRef(null); + useEffect(() => { + //console.log("textareaRef", textareaRef.current); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; // Reset height + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; // Expand to fit content + } + }, [input]); // Runs when input changes + + const handleFileInputChange = async ( + e: React.ChangeEvent + ) => { + if (e.target.files) { + await handleFileChange( + e.target.files, + "user", + "customBots/clientMessages" + ); + e.target.value = ""; + } + }; + + const handleCustomInputChange = ( + e: React.ChangeEvent + ) => { + setInput(e.target.value); + }; + + const handleSend = (e?: any) => { + if (status === "streaming") { + return; + } + const isFirstMessage = messages.length === 0; + const extraBody = { + ...submitMetadata + } + setSubmitMetadata({}); + setAutoSubmit(null); + handleSubmit(input, e, { + body: { + // toolMode: toolMode, + // selectedMcps: selectedMcps.map((mcp) => mcp.id), + ...extraBody + } + }); + } + + const apiErrors = submitMetadata.api_errors; + useEffect(() => { + if (apiErrors?.length > 0) { + setInput("Fix the following") + textareaRef.current?.focus(); + } + }, [apiErrors]); + + useEffect(() => { + if (autoSubmit && autoSubmit.length > 0) { + append({ + message: constructAutoSubmitMessage(autoSubmit), chatRequestOptions: { + body: { + ...submitMetadata + } + } + }); + setAutoSubmit(null); + setSubmitMetadata({}); + } + }, [autoSubmit, submitMetadata]); + + return ( +
+ {attachedMedia.length > 0 && ( +
+ {attachedMedia.map((media, index) => ( +
+ {media.file.type.startsWith("image/") ? ( + {media.file.name} + ) : ( +
+ +
+ )} + +
+ ))} +
+ )} +
+