├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── app
├── analysis
│ └── page.tsx
├── api
│ ├── assistant
│ │ ├── route.ts
│ │ └── thoughts
│ │ │ └── [session_id]
│ │ │ └── route.ts
│ ├── mcp-servers
│ │ ├── mcp-servers.json
│ │ ├── route.ts
│ │ └── test
│ │ │ └── route.ts
│ └── router
│ │ ├── route.ts
│ │ └── validate-session
│ │ └── [session_id]
│ │ └── route.ts
├── favicon.ico
├── fonts
│ ├── GeistMonoVF.woff
│ └── GeistVF.woff
├── globals.css
├── layout.tsx
└── page.tsx
├── assets
├── architecture.svg
└── screenshots
│ ├── fashion.gif
│ ├── finance.gif
│ ├── mcp.png
│ ├── multi-session.png
│ └── shopping.gif
├── components.json
├── components
├── BrowserControl.tsx
├── ChartRenderer.tsx
├── Chat.tsx
├── FilePreview.tsx
├── MCPServerSettings.tsx
├── MessageComponent.tsx
├── ThoughtProcess.tsx
├── TopNavBar.tsx
├── theme-provider.tsx
└── ui
│ ├── avatar.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── chart.tsx
│ ├── checkbox.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── textarea.tsx
│ ├── toast.tsx
│ └── toaster.tsx
├── config.ts
├── constants.ts
├── hooks
├── use-toast.ts
├── useAgentControl.ts
├── useAutoScroll.ts
├── useBrowserControl.ts
├── useChat.ts
├── useMCPServers.ts
├── useScrollHandling.ts
└── useThoughtProcess.ts
├── lib
└── utils.ts
├── next-env.d.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── amazon-dark.png
├── amazon.webp
├── ant-logo.svg
├── bedrock-logo.png
├── wordmark-dark.svg
└── wordmark.svg
├── py-backend
├── .env.example
├── app
│ ├── __init__.py
│ ├── act_agent
│ │ ├── client
│ │ │ ├── __init__.py
│ │ │ ├── agent_executor.py
│ │ │ └── browser_manager.py
│ │ ├── server
│ │ │ ├── __init__.py
│ │ │ └── nova-act-server
│ │ │ │ ├── __init__.py
│ │ │ │ ├── browser_controller.py
│ │ │ │ ├── nova_act_config.py
│ │ │ │ ├── nova_act_server.py
│ │ │ │ └── schemas.py
│ │ └── test-run.py
│ ├── api_routes
│ │ ├── __init__.py
│ │ ├── agent_control.py
│ │ ├── browser_control.py
│ │ ├── mcp_servers.py
│ │ ├── router.py
│ │ └── thought_stream.py
│ ├── app.py
│ └── libs
│ │ ├── __init__.py
│ │ ├── config
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── prompts.py
│ │ ├── core
│ │ ├── __init__.py
│ │ ├── agent_manager.py
│ │ ├── browser_state_manager.py
│ │ ├── browser_utils.py
│ │ ├── task_classifier.py
│ │ ├── task_executors.py
│ │ └── task_supervisor.py
│ │ ├── data
│ │ ├── __init__.py
│ │ ├── conversation_manager.py
│ │ ├── conversation_store.py
│ │ ├── message.py
│ │ ├── session_manager.py
│ │ ├── session_models.py
│ │ └── session_store.py
│ │ └── utils
│ │ ├── __init__.py
│ │ ├── decorators.py
│ │ ├── error_handler.py
│ │ ├── error_responses.py
│ │ ├── profile_manager.py
│ │ ├── shutdown_manager.py
│ │ ├── thought_stream.py
│ │ └── utils.py
├── mcp_server_config.json
├── requirements.txt
├── test_concurrent_browsers.py
└── test_nova_act.py
├── services
└── eventService.ts
├── tailwind.config.ts
├── test.py
├── tsconfig.json
├── types
├── chart.ts
└── chat.ts
└── utils
├── api.ts
├── apiClient.ts
├── errorHandler.ts
├── fileHandling.ts
├── logger.ts
├── messageUtils.ts
├── streamUtils.ts
├── thoughtProcessingUtils.ts
└── timerUtils.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | .next/
2 | out/
3 |
4 | # Python
5 | __pycache__/
6 | *.py[cod]
7 | *$py.class
8 | *.so
9 | .Python
10 | build/
11 | develop-eggs/
12 | dist/
13 | downloads/
14 | eggs/
15 | .eggs/
16 | lib64/
17 | parts/
18 | sdist/
19 | var/
20 | wheels/
21 | *.egg-info/
22 | .installed.cfg
23 | *.egg
24 | MANIFEST
25 | .env
26 | env/
27 | .venv/
28 | venv/
29 | ENV/
30 | .python-version
31 | .pytest_cache/
32 | .coverage
33 | htmlcov/
34 | py-backend/data/
35 | .claude
36 |
37 | # React/Node
38 | node_modules/
39 | /build
40 | .venv
41 | .DS_Store
42 | .env.local
43 | .env.development.local
44 | .env.test.local
45 | .env.production.local
46 | npm-debug.log*
47 | yarn-debug.log*
48 | yarn-error.log*
49 | .pnp
50 | .pnp.js
51 | coverage/
52 |
53 | # IDE
54 | .idea/
55 | .vscode/
56 | *.swp
57 | *.swo
58 | .project
59 | .classpath
60 | .settings/
61 | *.sublime-workspace
62 | *.sublime-project
63 |
64 | # Misc
65 | .DS_Store
66 | Thumbs.db
67 | *.log
68 | logs/
69 | tmp/
70 | temp/
71 |
72 |
73 | sample.db
74 | .env
75 | sample-data/
76 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | ## Code of Conduct
2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
4 | opensource-codeofconduct@amazon.com with any additional questions or comments.
5 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines
2 |
3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional
4 | documentation, we greatly value feedback and contributions from our community.
5 |
6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary
7 | information to effectively respond to your bug report or contribution.
8 |
9 |
10 | ## Reporting Bugs/Feature Requests
11 |
12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features.
13 |
14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already
15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
16 |
17 | * A reproducible test case or series of steps
18 | * The version of our code being used
19 | * Any modifications you've made relevant to the bug
20 | * Anything unusual about your environment or deployment
21 |
22 |
23 | ## Contributing via Pull Requests
24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
25 |
26 | 1. You are working against the latest source on the *main* branch.
27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already.
28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
29 |
30 | To send us a pull request, please:
31 |
32 | 1. Fork the repository.
33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
34 | 3. Ensure local tests pass.
35 | 4. Commit to your fork using clear commit messages.
36 | 5. Send us a pull request, answering any default questions in the pull request interface.
37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
38 |
39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and
40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
41 |
42 |
43 | ## Finding contributions to work on
44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start.
45 |
46 |
47 | ## Code of Conduct
48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
50 | opensource-codeofconduct@amazon.com with any additional questions or comments.
51 |
52 |
53 | ## Security issue notifications
54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue.
55 |
56 |
57 | ## Licensing
58 |
59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
60 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT No Attribution
2 |
3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so.
10 |
11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
17 |
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Browser Automation with Amazon Nova Act
2 |
3 | Automate web tasks using natural language with Amazon Nova Act and Bedrock. Transform routine browser interactions into simple conversational commands that free up your time for more meaningful work.
4 |
5 | ## What is Nova Act?
6 |
7 | Nova Act is Amazon's specialized AI model designed specifically for reliable web browser automation. Unlike general-purpose language models, Nova Act excels at translating natural language instructions into precise browser actions—clicking, typing, scrolling, and navigating just like a human would.
8 |
9 | ## Key Features
10 |
11 | ### 🎯 **Natural Language Browser Control**
12 | Control any website using simple, conversational commands:
13 | ```
14 | "Search for wireless headphones on Amazon"
15 | "Find the best-rated product under $100"
16 | "Add it to my cart and proceed to checkout"
17 | ```
18 |
19 | ### 🧠 **Intelligent Agent Layer**
20 | Bridges the gap between human intent and browser actions:
21 | - **Purpose-Driven Navigation**: Knows which websites to visit and what elements matter
22 | - **Contextual Continuity**: Maintains context across complex multi-step tasks
23 | - **Smart Task Breakdown**: Converts high-level goals into step-by-step browser actions
24 |
25 | ### 🚀 Multi-Session Browsing
26 |
27 |
28 | Enable multiple sessions (or users) to automate browser tasks simultaneously:
29 | - Session-Based Isolation: Each user gets a dedicated browser instance with unique session ID
30 | - Independent Browser Profiles: Separate cookies, authentication, and browsing data per session
31 | - Parallel Task Execution: Multiple browser automation tasks run concurrently without interference
32 | - Scalable Architecture: Handles dozens of concurrent users with isolated browser contexts
33 |
34 | ### 👥 **Human-in-the-Loop**
35 | Seamlessly handles scenarios that require human judgment:
36 | - Authentication challenges and CAPTCHAs
37 | - Ambiguous UI elements
38 | - Unexpected interface changes
39 | - Intelligent handoff between automated and manual control
40 |
41 | ### 🔌 **Model Context Protocol (MCP) Integration**
42 |
43 |
44 | Advanced tool integration through standardized protocol:
45 | - Standardized Tool Communication enables seamless integration of browser automation with external services
46 | - Streamable HTTP Transport enables real-time bidirectional communication between agents and tools with optimizerd resource usage
47 |
48 | ## Demo
49 |
50 | ### Real-World Use Cases
51 | This system enables automation across various domains:
52 | - **Fashion Research**: Trend analysis and product comparison
53 | - **Financial Analysis**: Market research and data gathering
54 | - **E-commerce**: Shopping, price comparison, and inventory management
55 | - **News Aggregation**: Technology trends and industry insights
56 | - **Travel Planning**: Flight searches, hotel bookings, and itinerary planning
57 |
58 | ### E-commerce Shopping (from Search to Cart)
59 | - `Go to Amazon and search for 'laptop stand'. Filter by brand 'AmazonBasics', check customer ratings above 4 stars, and add the adjustable one to your cart.`
60 |
61 |
62 |
63 | ### Financial Product (ETF) Comparison
64 | - `Go to https://investor.vanguard.com/investment-products/index-fudds`
65 | - `Filter for Stock-Sector funds only, then identify which sector ETF has the best YTD performance. Also note its expense ratio and 5-year return.`
66 |
67 |
68 |
69 | ### Fashion Trend Analysis
70 | - `Analyze current fashion trends on Pinterest for “summer 2025 fashion women".`
71 |
72 |
73 |
74 | ## Quick Start
75 |
76 | ### Prerequisites
77 | - **Operating System**: MacOS (recommended)
78 | - **Python**: 3.10 or higher
79 | - **Node.js**: 18 or higher
80 | - **Package Manager**: npm or yarn
81 |
82 | ### Installation
83 |
84 | ```bash
85 | # Clone the repository
86 | git clone https://github.com/aws-samples/browser-control-with-nova-act.git
87 | cd browser-control-with-nova-act
88 |
89 | # Backend setup
90 | python -m venv venv
91 | source venv/bin/activate # On Windows: venv\Scripts\activate
92 | cd py-backend
93 | pip install -r requirements.txt
94 |
95 | # Frontend setup
96 | cd ..
97 | npm install
98 | ```
99 |
100 | ### Configuration
101 |
102 | **1. Set up Environment Variables**
103 | ```bash
104 | # Copy the example environment file
105 | cd py-backend
106 | cp .env.example .env
107 |
108 | # Edit .env file and add your Nova Act API Key
109 | # NOVA_ACT_API_KEY=your_api_key_here
110 | ```
111 |
112 | **Alternative: Use system environment variables**
113 | ```bash
114 | export NOVA_ACT_API_KEY="your_api_key_here"
115 | ```
116 |
117 | **2. Configure Browser Settings (Optional)**
118 | All browser settings can be configured in the `.env` file or by editing `py-backend/app/libs/config/config.py`:
119 | ```python
120 | # Core browser settings
121 | BROWSER_HEADLESS = True # Set to False for debugging
122 | BROWSER_START_URL = "https://www.google.com"
123 | BROWSER_MAX_STEPS = 2 # Keep small for reliability
124 |
125 | # Browser profile (for persistent sessions)
126 | BROWSER_USER_DATA_DIR = '/path/to/chrome/profile'
127 | ```
128 |
129 | **3. AI Model Configuration**
130 | ```python
131 | # Multimodal models required for screenshot interpretation
132 | DEFAULT_MODEL_ID = "us.amazon.nova-premier-v1:0"
133 | # Tested models: Nova Premier, Claude 3.7 Sonnet, Claude 3.5 Sonnet
134 | ```
135 |
136 | ### Running the Application
137 |
138 | ```bash
139 | npm run dev
140 | ```
141 |
142 | Visit **http://localhost:3000** to start automating!
143 |
144 | ## Usage Examples
145 |
146 | ### Basic Commands
147 | ```
148 | # Simple navigation
149 | "Go to amazon.com"
150 | "Search for wireless headphones"
151 |
152 | # Interactive actions
153 | "Click the search bar and type 'gaming laptop'"
154 | "Scroll down to see more products"
155 | "Select the third result"
156 |
157 | # Complex research tasks
158 | "Find gaming laptops under $1000 and compare their specs"
159 | "Research the latest AI news and summarize key trends"
160 | "Book a flight from Seattle to New York for next Friday"
161 | ```
162 |
163 | ## Architecture Overview
164 |
165 |
166 |
167 | The system uses a three-tier architecture:
168 | - **Supervisor Layer**: Breaks down complex tasks and coordinates workflow
169 | - **Agent Layer**: Executes browser missions and interprets results
170 | - **Nova Act Layer**: Performs direct browser interactions
171 |
172 | ## Learn More
173 |
174 | A detailed blog post covering technical implementation, architectural decisions, and advanced usage patterns will be published soon. It will include:
175 | - Deep dive into the agent architecture
176 | - Advanced prompting strategies
177 | - Performance optimization techniques
178 | - Troubleshooting common issues
179 | - Real-world deployment scenarios
180 |
181 | ## Contributing
182 |
183 | We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
184 |
185 | ## License
186 |
187 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
188 |
189 | ---
190 |
191 | **Ready to automate your web workflows?** Start with `npm run dev` and experience the future of browser automation! 🚀
--------------------------------------------------------------------------------
/app/analysis/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useState } from "react";
3 | import Chat from '@/components/Chat';
4 | import ThoughtProcess from '@/components/ThoughtProcess';
5 | import BrowserControl from '@/components/BrowserControl';
6 | import { useChat } from "@/hooks/useChat";
7 | import { models, regions } from '@/constants';
8 | import TopNavBar from "@/components/TopNavBar";
9 |
10 | export default function AIChat() {
11 | const [selectedRegion, setSelectedRegion] = useState("us-west-2");
12 | const [selectedModel, setSelectedModel] = useState(
13 | "us.anthropic.claude-3-7-sonnet-20250219-v1:0"
14 | );
15 | const [isUserControlInProgress, setIsUserControlInProgress] = useState(false);
16 | const {
17 | messages,
18 | input,
19 | isLoading,
20 | isThinking,
21 | isStopping,
22 | currentUpload,
23 | queryDetails,
24 | sessionId,
25 | actions,
26 | fileInputRef
27 | } = useChat(selectedModel, selectedRegion);
28 |
29 | const navFeatures = {
30 | showDomainSelector: false,
31 | showViewModeSelector: false,
32 | showPromptCaching: false
33 | };
34 |
35 | return (
36 |
37 |
41 |
42 |
43 |
44 |
66 |
67 |
74 |
75 |
76 |
77 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/app/api/assistant/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { redirect } from "next/navigation";
3 |
4 | // This route is deprecated in favor of /api/router
5 | export async function POST(req: NextRequest) {
6 | // Redirect to the /api/router endpoint
7 | const { protocol, host } = new URL(req.url);
8 | const routerUrl = `${protocol}//${host}/api/router`;
9 |
10 | console.log(`Assistant API is deprecated. Redirecting request to ${routerUrl}`);
11 |
12 | return NextResponse.redirect(routerUrl, { status: 308 }); // 308 is Permanent Redirect
13 | }
--------------------------------------------------------------------------------
/app/api/assistant/thoughts/[session_id]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server";
2 | export const dynamic = 'force-dynamic';
3 | export const fetchCache = 'force-no-store';
4 |
5 | async function connectWithRetry(backendUrl: string, maxAttempts = 5) {
6 | for (let attempt = 0; attempt < maxAttempts; attempt++) {
7 | try {
8 | const controller = new AbortController();
9 | const timeoutId = setTimeout(() => controller.abort(), 120000);
10 |
11 | const response = await fetch(backendUrl, {
12 | cache: "no-store",
13 | headers: { "Accept": "text/event-stream" },
14 | signal: controller.signal
15 | });
16 |
17 | clearTimeout(timeoutId);
18 |
19 | if (response.ok) return response;
20 |
21 | console.log(`Connection attempt ${attempt + 1}/${maxAttempts} failed with status: ${response.status} ${response.statusText}`);
22 |
23 | } catch (error) {
24 | console.log(`Connection attempt ${attempt + 1}/${maxAttempts} failed with error:`, error);
25 | }
26 |
27 | if (attempt < maxAttempts - 1) {
28 | await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
29 | }
30 | }
31 | throw new Error('Failed to connect after maximum attempts');
32 | }
33 |
34 | export async function GET(
35 | req: NextRequest,
36 | { params }: { params: { session_id: string } }
37 | ) {
38 | const param = await params;
39 | const session_id = await param.session_id;
40 |
41 | if (!session_id) {
42 | return new Response(JSON.stringify({ error: "Session ID is required" }), {
43 | status: 400,
44 | headers: {
45 | 'Content-Type': 'application/json',
46 | },
47 | });
48 | }
49 |
50 | console.log(`Setting up SSE thought stream for session: ${session_id}`);
51 |
52 | try {
53 | const headers = new Headers({
54 | "Content-Type": "text/event-stream",
55 | "Cache-Control": "no-cache, no-store, must-revalidate",
56 | "Connection": "keep-alive",
57 | "Transfer-Encoding": "chunked",
58 | "X-Accel-Buffering": "no",
59 | "Access-Control-Allow-Origin": "*",
60 | "Content-Encoding": "identity"
61 | });
62 |
63 | const encoder = new TextEncoder();
64 | let isStreamActive = true;
65 |
66 | const stream = new ReadableStream({
67 | async start(controller) {
68 | try {
69 | controller.enqueue(encoder.encode(`data: ${JSON.stringify({
70 | type: "connected",
71 | message: "SSE stream initialized"
72 | })}\n\n`));
73 | } catch (err) {
74 | console.log(`Failed to send initial message: ${err.message}`);
75 | isStreamActive = false;
76 | return;
77 | }
78 |
79 | try {
80 | const apiBaseUrl = process.env.API_BASE_URL || 'http://127.0.0.1:8000';
81 | const backendUrl = `${apiBaseUrl}/api/assistant/thoughts/${session_id}`;
82 | console.log(`Attempting backend connection: ${backendUrl}`);
83 |
84 | const response = await connectWithRetry(backendUrl);
85 | const reader = response.body?.getReader();
86 |
87 | if (!reader) {
88 | throw new Error("No response stream available");
89 | }
90 |
91 | let decoder = new TextDecoder();
92 | let buffer = "";
93 |
94 | while (isStreamActive) {
95 | const { done, value } = await reader.read();
96 |
97 | if (done) {
98 | if (buffer.length > 0 && isStreamActive) {
99 | try {
100 | controller.enqueue(encoder.encode(buffer));
101 | } catch (err) {
102 | if (err.code === 'ERR_INVALID_STATE') {
103 | console.log(`Stream already closed for session ${session_id}`);
104 | isStreamActive = false;
105 | } else {
106 | throw err;
107 | }
108 | }
109 | }
110 | break;
111 | }
112 |
113 | const chunk = decoder.decode(value, { stream: true });
114 | buffer += chunk;
115 |
116 | const messages = buffer.split('\n\n');
117 | buffer = messages.pop() || "";
118 |
119 | if (messages.length > 0 && isStreamActive) {
120 | const completeMessages = messages.join('\n\n') + '\n\n';
121 | try {
122 | controller.enqueue(encoder.encode(completeMessages));
123 | } catch (err) {
124 | if (err.code === 'ERR_INVALID_STATE') {
125 | console.log(`Stream already closed for session ${session_id}`);
126 | isStreamActive = false;
127 | break;
128 | } else {
129 | throw err;
130 | }
131 | }
132 | }
133 | }
134 | } catch (error) {
135 | console.error(`Error in SSE stream for session ${session_id}:`, error);
136 | if (isStreamActive) {
137 | try {
138 | const errorMessage = error instanceof Error ? error.message : "Unknown error";
139 | controller.enqueue(encoder.encode(`data: ${JSON.stringify({type: "error", message: errorMessage})}\n\n`));
140 | } catch (err) {
141 | if (err.code !== 'ERR_INVALID_STATE') {
142 | console.error(`Failed to send error message: ${err.message}`);
143 | }
144 | }
145 | }
146 | } finally {
147 |
148 | isStreamActive = false;
149 | }
150 | },
151 | cancel() {
152 | console.log(`SSE stream canceled for session: ${session_id}`);
153 | isStreamActive = false
154 | }
155 | });
156 |
157 | return new Response(stream, { headers });
158 | } catch (error) {
159 | console.error(`Error setting up SSE stream: ${error}`);
160 | return new Response(
161 | JSON.stringify({ error: "Failed to set up thought stream" }),
162 | { status: 500, headers: { "Content-Type": "application/json" } }
163 | );
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/app/api/mcp-servers/mcp-servers.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "default-mcp-1",
4 | "name": "MCP Server (Local)",
5 | "hostname": "localhost:8000",
6 | "isActive": true,
7 | "isConnected": true
8 | }
9 | ]
--------------------------------------------------------------------------------
/app/api/mcp-servers/route.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { promises as fs } from 'fs';
3 | import { NextRequest } from 'next/server';
4 |
5 | export const dynamic = 'force-dynamic';
6 |
7 | // Path to our JSON file
8 | const dataFilePath = path.join(process.cwd(), 'app/api/mcp-servers/mcp-servers.json');
9 |
10 | async function readServersData() {
11 | try {
12 | const data = await fs.readFile(dataFilePath, 'utf8');
13 | return JSON.parse(data);
14 | } catch (error) {
15 | console.error('Error reading MCP servers file:', error);
16 | return [];
17 | }
18 | }
19 |
20 | async function writeServersData(data: any) {
21 | try {
22 | await fs.writeFile(dataFilePath, JSON.stringify(data, null, 2), 'utf8');
23 | return true;
24 | } catch (error) {
25 | console.error('Error writing MCP servers file:', error);
26 | return false;
27 | }
28 | }
29 |
30 | // GET /api/mcp-servers
31 | export async function GET() {
32 | try {
33 | const data = await readServersData();
34 | return new Response(JSON.stringify(data), {
35 | status: 200,
36 | headers: { 'Content-Type': 'application/json' },
37 | });
38 | } catch (error) {
39 | return new Response(JSON.stringify({ error: 'Failed to fetch MCP servers' }), {
40 | status: 500,
41 | headers: { 'Content-Type': 'application/json' },
42 | });
43 | }
44 | }
45 |
46 | // POST /api/mcp-servers
47 | export async function POST(request: NextRequest) {
48 | try {
49 | const data = await request.json();
50 | const success = await writeServersData(data);
51 |
52 | if (success) {
53 | return new Response(JSON.stringify({ success: true }), {
54 | status: 200,
55 | headers: { 'Content-Type': 'application/json' },
56 | });
57 | } else {
58 | throw new Error('Failed to save servers');
59 | }
60 | } catch (error) {
61 | return new Response(JSON.stringify({ error: 'Failed to save MCP servers' }), {
62 | status: 500,
63 | headers: { 'Content-Type': 'application/json' },
64 | });
65 | }
66 | }
--------------------------------------------------------------------------------
/app/api/mcp-servers/test/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from 'next/server';
2 |
3 | export const dynamic = 'force-dynamic';
4 |
5 | // POST /api/mcp-servers/test
6 | export async function POST(request: NextRequest) {
7 | try {
8 | const { hostname } = await request.json();
9 |
10 | if (!hostname) {
11 | return new Response(JSON.stringify({ success: false, error: 'Hostname is required' }), {
12 | status: 400,
13 | headers: { 'Content-Type': 'application/json' },
14 | });
15 | }
16 |
17 | // Try to connect to the server with a timeout
18 | try {
19 | // We need to ensure the URL is properly formatted
20 | let url = hostname;
21 | if (!url.startsWith('http://') && !url.startsWith('https://')) {
22 | url = `http://${url}`;
23 | }
24 |
25 | // Add a health check endpoint path if needed
26 | if (!url.includes('/health') && !url.endsWith('/')) {
27 | url = `${url}/health`;
28 | } else if (url.endsWith('/')) {
29 | url = `${url}health`;
30 | }
31 |
32 | const controller = new AbortController();
33 | const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout
34 |
35 | const response = await fetch(url, {
36 | method: 'GET',
37 | headers: { 'Accept': 'application/json' },
38 | signal: controller.signal,
39 | });
40 |
41 | clearTimeout(timeoutId);
42 |
43 | // Check if the response is ok (status in the range 200-299)
44 | const success = response.ok;
45 |
46 | return new Response(JSON.stringify({ success }), {
47 | status: 200,
48 | headers: { 'Content-Type': 'application/json' },
49 | });
50 | } catch (error) {
51 | console.error('Error testing server connection:', error);
52 | return new Response(JSON.stringify({ success: false, error: 'Failed to connect to server' }), {
53 | status: 200, // We return 200 but with success: false to indicate the test failed
54 | headers: { 'Content-Type': 'application/json' },
55 | });
56 | }
57 | } catch (error) {
58 | return new Response(JSON.stringify({ success: false, error: 'Invalid request' }), {
59 | status: 400,
60 | headers: { 'Content-Type': 'application/json' },
61 | });
62 | }
63 | }
--------------------------------------------------------------------------------
/app/api/router/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server";
2 | import { apiClient } from "@/utils/apiClient";
3 |
4 | // Route path to the backend router API
5 | const ROUTER_API_PATH = "/api/router";
6 |
7 |
8 | export async function POST(req: NextRequest) {
9 | try {
10 | console.log("Router API called directly");
11 | const body = await req.json();
12 |
13 | if (!body.messages || !Array.isArray(body.messages)) {
14 | return new Response(
15 | JSON.stringify({ error: "Messages array is required" }),
16 | { status: 400 }
17 | );
18 | }
19 |
20 | if (!body.model || !body.region) {
21 | return new Response(
22 | JSON.stringify({ error: "Model and region are required" }),
23 | { status: 400 }
24 | );
25 | }
26 |
27 | console.log("Router API forwarding request to backend:", body.messages.length, "messages");
28 |
29 | // Use the API client for consistent error handling and retries
30 | const response = await apiClient.request(ROUTER_API_PATH, {
31 | method: "POST",
32 | body: JSON.stringify(body),
33 | });
34 |
35 | if (!response.ok) {
36 | throw new Error(`Router backend API error! status: ${response.status}`);
37 | }
38 |
39 | const responseData = await response.json();
40 |
41 | return new Response(JSON.stringify(responseData), {
42 | status: 200,
43 | headers: { 'Content-Type': 'application/json' }
44 | });
45 | } catch (error) {
46 | console.error("Router API: Error in POST handler:", error);
47 |
48 | // Handle different error types
49 | const errorName = error.name;
50 | const errorMessage = error.message;
51 |
52 | // Check if it's an abort error (timeout)
53 | if (errorName === 'AbortError') {
54 | return new Response(
55 | JSON.stringify({ error: "Request to backend timed out. Please try again." }),
56 | { status: 504 }
57 | );
58 | }
59 |
60 | // Handle ECONNREFUSED specifically
61 | if (error.code === 'ECONNREFUSED' || error.cause?.code === 'ECONNREFUSED') {
62 | return new Response(
63 | JSON.stringify({
64 | error: "Could not connect to backend server. Make sure the backend service is running.",
65 | details: "Connection refused"
66 | }),
67 | { status: 502 }
68 | );
69 | }
70 |
71 | return new Response(
72 | JSON.stringify({
73 | error: "An error occurred while processing the request",
74 | details: errorMessage || "Unknown error"
75 | }),
76 | { status: 500 }
77 | );
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/app/api/router/validate-session/[session_id]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest } from "next/server";
2 |
3 | // Configuration
4 | const BACKEND_URL = "http://localhost:8000/api/router/validate-session";
5 | const MAX_RETRIES = 3;
6 | const RETRY_DELAY = 1000; // 1 second
7 | const REQUEST_TIMEOUT = 5000; // 5 seconds
8 |
9 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
10 |
11 | async function fetchWithRetry(url: string, options: RequestInit, retries = MAX_RETRIES) {
12 | try {
13 | const controller = new AbortController();
14 | const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
15 |
16 | const response = await fetch(url, {
17 | ...options,
18 | signal: controller.signal
19 | });
20 |
21 | clearTimeout(timeoutId);
22 | return response;
23 | } catch (error) {
24 | if (retries > 0) {
25 | console.log(`Retrying validation (${retries} attempts left)...`);
26 | await sleep(RETRY_DELAY);
27 | return fetchWithRetry(url, options, retries - 1);
28 | }
29 | throw error;
30 | }
31 | }
32 |
33 | export async function GET(req: NextRequest, { params }: { params: { session_id: string } }) {
34 | try {
35 | const session_id = params.session_id;
36 | console.log(`Session validation requested for: ${session_id}`);
37 |
38 | if (!session_id) {
39 | return new Response(
40 | JSON.stringify({ valid: false, error: "Session ID is required" }),
41 | { status: 400, headers: { "Content-Type": "application/json" } }
42 | );
43 | }
44 |
45 | // Validate session with backend
46 | const response = await fetchWithRetry(`${BACKEND_URL}/${session_id}`, {
47 | method: "GET",
48 | headers: { "Content-Type": "application/json" },
49 | });
50 |
51 | if (!response.ok) {
52 | return new Response(
53 | JSON.stringify({ valid: false, message: `Backend validation failed: ${response.status}` }),
54 | { status: 200, headers: { "Content-Type": "application/json" } }
55 | );
56 | }
57 |
58 | const data = await response.json();
59 |
60 | // Return the backend validation result
61 | return new Response(
62 | JSON.stringify(data),
63 | { status: 200, headers: { "Content-Type": "application/json" } }
64 | );
65 | } catch (error) {
66 | console.error("Session validation error:", error);
67 |
68 | // Check if it's a timeout
69 | if (error.name === 'AbortError') {
70 | return new Response(
71 | JSON.stringify({ valid: false, error: "Validation request timed out" }),
72 | { status: 200, headers: { "Content-Type": "application/json" } }
73 | );
74 | }
75 |
76 | return new Response(
77 | JSON.stringify({ valid: false, error: error.message || "Unknown error" }),
78 | { status: 200, headers: { "Content-Type": "application/json" } }
79 | );
80 | }
81 | }
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/app/favicon.ico
--------------------------------------------------------------------------------
/app/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/app/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/app/fonts/GeistVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/app/fonts/GeistVF.woff
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import localFont from "next/font/local";
4 | import { ThemeProvider } from "@/components/theme-provider";
5 | import { Toaster } from "@/components/ui/toaster";
6 | import "./globals.css";
7 |
8 | const inter = Inter({
9 | subsets: ["latin"],
10 | variable: "--font-inter",
11 | display: "swap",
12 | weight: ["300", "400", "500", "600", "700"],
13 | });
14 |
15 | const geistMono = localFont({
16 | src: "./fonts/GeistMonoVF.woff",
17 | variable: "--font-geist-mono",
18 | weight: "100 900",
19 | });
20 |
21 | export const metadata: Metadata = {
22 | title: "Browser Automation Agent",
23 | description: "Generated by create next app",
24 | };
25 |
26 | export default function RootLayout({
27 | children,
28 | }: Readonly<{
29 | children: React.ReactNode;
30 | }>) {
31 | return (
32 |
33 |
36 |
42 | {children}
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | // /app/page.tsx
2 | "use client";
3 |
4 | import React from "react";
5 | import AnalysisPage from "./analysis/page";
6 |
7 | export default function Home() {
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/assets/screenshots/fashion.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/assets/screenshots/fashion.gif
--------------------------------------------------------------------------------
/assets/screenshots/finance.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/assets/screenshots/finance.gif
--------------------------------------------------------------------------------
/assets/screenshots/mcp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/assets/screenshots/mcp.png
--------------------------------------------------------------------------------
/assets/screenshots/multi-session.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/assets/screenshots/multi-session.png
--------------------------------------------------------------------------------
/assets/screenshots/shopping.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/assets/screenshots/shopping.gif
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/components/FilePreview.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { X, FileText } from "lucide-react";
3 | import { Badge } from "@/components/ui/badge";
4 | import Image from "next/image";
5 |
6 | interface FilePreviewProps {
7 | file: {
8 | base64: string;
9 | fileName: string;
10 | mediaType: string;
11 | isText?: boolean;
12 | };
13 | onRemove?: () => void;
14 | size?: "small" | "large";
15 | }
16 |
17 | const FilePreview: React.FC = ({
18 | file,
19 | onRemove,
20 | size = "large",
21 | }) => {
22 | const isImage = file.mediaType.startsWith("image/");
23 | const fileExtension = file.fileName.split(".").pop()?.toLowerCase() || "";
24 |
25 | const truncatedName =
26 | file.fileName.length > 7
27 | ? `${file.fileName.slice(0, 7)}...${file.fileName.slice(
28 | file.fileName.lastIndexOf("."),
29 | )}`
30 | : file.fileName;
31 |
32 | const imageUrl = isImage
33 | ? `data:${file.mediaType};base64,${file.base64}`
34 | : "";
35 |
36 | if (size === "small") {
37 | return (
38 |
39 | {isImage ? (
40 |
41 |
49 |
50 | ) : (
51 |
52 | )}
53 | {truncatedName}
54 |
55 | );
56 | }
57 |
58 | return (
59 |
60 | {isImage ? (
61 |
62 |
70 |
71 | ) : (
72 |
73 |
74 | {fileExtension}
75 |
76 | )}
77 | {onRemove && (
78 |
87 | )}
88 |
89 | );
90 | };
91 |
92 | export default FilePreview;
93 |
--------------------------------------------------------------------------------
/components/MCPServerSettings.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useState } from "react";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogFooter,
9 | DialogHeader,
10 | DialogTitle
11 | } from "@/components/ui/dialog";
12 | import { Button } from "@/components/ui/button";
13 | import { Input } from "@/components/ui/input";
14 | import { Label } from "@/components/ui/label";
15 | import { Checkbox } from "@/components/ui/checkbox";
16 | import { useMCPServers, MCPServer } from "@/hooks/useMCPServers";
17 | import { AlertCircle, CheckCircle, Loader2, RefreshCw, Trash2 } from "lucide-react";
18 |
19 | interface MCPServerSettingsProps {
20 | isOpen: boolean;
21 | onClose: () => void;
22 | }
23 |
24 | export default function MCPServerSettings({ isOpen, onClose }: MCPServerSettingsProps) {
25 | const {
26 | servers,
27 | addServer,
28 | removeServer,
29 | toggleServerActive,
30 | retestServerConnection
31 | } = useMCPServers();
32 |
33 | const [newServerName, setNewServerName] = useState("");
34 | const [newServerHostname, setNewServerHostname] = useState("");
35 | const [isSubmitting, setIsSubmitting] = useState(false);
36 | const [isTestingServer, setIsTestingServer] = useState(null);
37 | const [error, setError] = useState(null);
38 |
39 | const handleAddServer = async () => {
40 | if (!newServerName || !newServerHostname) {
41 | setError("Please enter both server name and hostname.");
42 | return;
43 | }
44 |
45 | setIsSubmitting(true);
46 | setError(null);
47 |
48 | try {
49 | const result = await addServer(newServerName, newServerHostname);
50 | if (result.success) {
51 | setNewServerName("");
52 | setNewServerHostname("");
53 | } else {
54 | setError("Failed to add server.");
55 | }
56 | } catch (err) {
57 | setError("An error occurred while adding server.");
58 | } finally {
59 | setIsSubmitting(false);
60 | }
61 | };
62 |
63 | const handleServerTest = async (id: string) => {
64 | setIsTestingServer(id);
65 | try {
66 | await retestServerConnection(id);
67 | } finally {
68 | setIsTestingServer(null);
69 | }
70 | };
71 |
72 | return (
73 |
205 | );
206 | }
207 |
--------------------------------------------------------------------------------
/components/TopNavBar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Link from 'next/link';
3 | import React, { useState, useEffect } from "react";
4 | import Image from "next/image";
5 | import { useTheme } from "next-themes";
6 |
7 | interface TopNavBarProps {
8 | features?: {
9 | showDomainSelector?: boolean;
10 | showViewModeSelector?: boolean;
11 | showPromptCaching?: boolean;
12 | };
13 | onReset?: () => void;
14 | }
15 |
16 | const TopNavBar: React.FC = ({ features = {}, onReset }) => {
17 | const { theme, setTheme } = useTheme();
18 | const [mounted, setMounted] = useState(false);
19 | const [isSettingsOpen, setIsSettingsOpen] = useState(false);
20 |
21 | useEffect(() => {
22 | setMounted(true);
23 | }, []);
24 |
25 | if (!mounted) {
26 | return null;
27 | }
28 |
29 | return (
30 | <>
31 |
32 |
33 |
40 |
41 | {/* ANTHROPIC logo removed as requested */}
42 |
43 | >
44 | );
45 | };
46 |
47 | export default TopNavBar;
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | // components/theme-provider.tsx
2 | "use client";
3 |
4 | import * as React from "react";
5 | import { ThemeProvider as NextThemesProvider } from "next-themes";
6 | import { type ThemeProviderProps } from "next-themes/dist/types";
7 |
8 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
9 | return {children};
10 | }
11 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
5 | import { Check } from "lucide-react";
6 | import { cn } from "@/lib/utils";
7 |
8 | const Checkbox = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
23 |
24 |
25 |
26 | ));
27 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
28 |
29 | export { Checkbox };
30 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { X } from "lucide-react";
6 | import { cn } from "@/lib/utils";
7 |
8 | const Dialog = DialogPrimitive.Root;
9 |
10 | const DialogTrigger = DialogPrimitive.Trigger;
11 |
12 | const DialogPortal = DialogPrimitive.Portal;
13 |
14 | const DialogOverlay = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, ...props }, ref) => (
18 |
26 | ));
27 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
28 |
29 | const DialogContent = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef
32 | >(({ className, children, ...props }, ref) => (
33 |
34 |
35 |
43 | {children}
44 |
45 |
46 | Close
47 |
48 |
49 |
50 | ));
51 | DialogContent.displayName = DialogPrimitive.Content.displayName;
52 |
53 | const DialogHeader = ({
54 | className,
55 | ...props
56 | }: React.HTMLAttributes) => (
57 |
64 | );
65 | DialogHeader.displayName = "DialogHeader";
66 |
67 | const DialogFooter = ({
68 | className,
69 | ...props
70 | }: React.HTMLAttributes) => (
71 |
78 | );
79 | DialogFooter.displayName = "DialogFooter";
80 |
81 | const DialogTitle = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => (
85 |
93 | ));
94 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
95 |
96 | const DialogDescription = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, ...props }, ref) => (
100 |
105 | ));
106 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
107 |
108 | export {
109 | Dialog,
110 | DialogPortal,
111 | DialogOverlay,
112 | DialogTrigger,
113 | DialogContent,
114 | DialogHeader,
115 | DialogFooter,
116 | DialogTitle,
117 | DialogDescription,
118 | };
119 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import {
6 | CheckIcon,
7 | ChevronRightIcon,
8 | DotFilledIcon,
9 | } from "@radix-ui/react-icons"
10 |
11 | import { cn } from "@/lib/utils"
12 |
13 | const DropdownMenu = DropdownMenuPrimitive.Root
14 |
15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
16 |
17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
18 |
19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
20 |
21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
22 |
23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
24 |
25 | const DropdownMenuSubTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef & {
28 | inset?: boolean
29 | }
30 | >(({ className, inset, children, ...props }, ref) => (
31 |
40 | {children}
41 |
42 |
43 | ))
44 | DropdownMenuSubTrigger.displayName =
45 | DropdownMenuPrimitive.SubTrigger.displayName
46 |
47 | const DropdownMenuSubContent = React.forwardRef<
48 | React.ElementRef,
49 | React.ComponentPropsWithoutRef
50 | >(({ className, ...props }, ref) => (
51 |
59 | ))
60 | DropdownMenuSubContent.displayName =
61 | DropdownMenuPrimitive.SubContent.displayName
62 |
63 | const DropdownMenuContent = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, sideOffset = 4, ...props }, ref) => (
67 |
68 |
78 |
79 | ))
80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
81 |
82 | const DropdownMenuItem = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef & {
85 | inset?: boolean
86 | }
87 | >(({ className, inset, ...props }, ref) => (
88 |
97 | ))
98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
99 |
100 | const DropdownMenuCheckboxItem = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, children, checked, ...props }, ref) => (
104 |
113 |
114 |
115 |
116 |
117 |
118 | {children}
119 |
120 | ))
121 | DropdownMenuCheckboxItem.displayName =
122 | DropdownMenuPrimitive.CheckboxItem.displayName
123 |
124 | const DropdownMenuRadioItem = React.forwardRef<
125 | React.ElementRef,
126 | React.ComponentPropsWithoutRef
127 | >(({ className, children, ...props }, ref) => (
128 |
136 |
137 |
138 |
139 |
140 |
141 | {children}
142 |
143 | ))
144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
145 |
146 | const DropdownMenuLabel = React.forwardRef<
147 | React.ElementRef,
148 | React.ComponentPropsWithoutRef & {
149 | inset?: boolean
150 | }
151 | >(({ className, inset, ...props }, ref) => (
152 |
161 | ))
162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
163 |
164 | const DropdownMenuSeparator = React.forwardRef<
165 | React.ElementRef,
166 | React.ComponentPropsWithoutRef
167 | >(({ className, ...props }, ref) => (
168 |
173 | ))
174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
175 |
176 | const DropdownMenuShortcut = ({
177 | className,
178 | ...props
179 | }: React.HTMLAttributes) => {
180 | return (
181 |
185 | )
186 | }
187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
188 |
189 | export {
190 | DropdownMenu,
191 | DropdownMenuTrigger,
192 | DropdownMenuContent,
193 | DropdownMenuItem,
194 | DropdownMenuCheckboxItem,
195 | DropdownMenuRadioItem,
196 | DropdownMenuLabel,
197 | DropdownMenuSeparator,
198 | DropdownMenuShortcut,
199 | DropdownMenuGroup,
200 | DropdownMenuPortal,
201 | DropdownMenuSub,
202 | DropdownMenuSubContent,
203 | DropdownMenuSubTrigger,
204 | DropdownMenuRadioGroup,
205 | }
206 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ToastPrimitives from "@radix-ui/react-toast"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 | import { X } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 |
10 | const ToastProvider = ToastPrimitives.Provider
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ))
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
26 |
27 | const toastVariants = cva(
28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default: "border bg-background text-foreground",
33 | destructive:
34 | "destructive group border-destructive bg-destructive text-destructive-foreground",
35 | },
36 | },
37 | defaultVariants: {
38 | variant: "default",
39 | },
40 | }
41 | )
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | )
55 | })
56 | Toast.displayName = ToastPrimitives.Root.displayName
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ))
71 | ToastAction.displayName = ToastPrimitives.Action.displayName
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ))
89 | ToastClose.displayName = ToastPrimitives.Close.displayName
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ))
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef
116 |
117 | type ToastActionElement = React.ReactElement
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | }
130 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useToast } from "@/hooks/use-toast"
4 | import {
5 | Toast,
6 | ToastClose,
7 | ToastDescription,
8 | ToastProvider,
9 | ToastTitle,
10 | ToastViewport,
11 | } from "@/components/ui/toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/config.ts:
--------------------------------------------------------------------------------
1 | export const CONFIG = {
2 | API: {
3 | // Base URL without trailing /api prefix
4 | BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000',
5 | TIMEOUT: 10000,
6 | MAX_RETRIES: 3
7 | },
8 | SESSION: {
9 | STORAGE_KEY: 'session_id'
10 | }
11 | };
--------------------------------------------------------------------------------
/constants.ts:
--------------------------------------------------------------------------------
1 | // constants.ts
2 | export const models = [
3 | { id: "us.anthropic.claude-3-7-sonnet-20250219-v1:0", name: "Claude 3.7 Sonnet" },
4 | { id: "us.anthropic.claude-sonnet-4-20250514-v1:0", name: "Claude 4.0 Sonnet" },
5 | { id: "us.anthropic.claude-opus-4-20250514-v1:0", name: "Claude 4.0 Opus" },
6 | { id: "us.amazon.nova-pro-v1:0", name: "Nova Pro" },
7 | { id: "us.amazon.nova-premier-v1:0", name: "Nova Premier" }
8 | ]
9 |
10 | export const regions = [
11 | { id: "us-east-1", name: "US East (N. Virginia)" },
12 | { id: "us-west-2", name: "US West (Oregon)" },
13 | { id: "ap-northeast-1", name: "Asia (Tokyo)" },
14 | { id: "ap-northeast-2", name: "Asia (Seoul)" },
15 | { id: "eu-central-1", name: "Europe (Frankfurt)" },
16 | ];
--------------------------------------------------------------------------------
/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from "react"
5 |
6 | import type {
7 | ToastActionElement,
8 | ToastProps,
9 | } from "@/components/ui/toast"
10 |
11 | const TOAST_LIMIT = 1
12 | const TOAST_REMOVE_DELAY = 1000000
13 |
14 | type ToasterToast = ToastProps & {
15 | id: string
16 | title?: React.ReactNode
17 | description?: React.ReactNode
18 | action?: ToastActionElement
19 | }
20 |
21 | const actionTypes = {
22 | ADD_TOAST: "ADD_TOAST",
23 | UPDATE_TOAST: "UPDATE_TOAST",
24 | DISMISS_TOAST: "DISMISS_TOAST",
25 | REMOVE_TOAST: "REMOVE_TOAST",
26 | } as const
27 |
28 | let count = 0
29 |
30 | function genId() {
31 | count = (count + 1) % Number.MAX_SAFE_INTEGER
32 | return count.toString()
33 | }
34 |
35 | type ActionType = typeof actionTypes
36 |
37 | type Action =
38 | | {
39 | type: ActionType["ADD_TOAST"]
40 | toast: ToasterToast
41 | }
42 | | {
43 | type: ActionType["UPDATE_TOAST"]
44 | toast: Partial
45 | }
46 | | {
47 | type: ActionType["DISMISS_TOAST"]
48 | toastId?: ToasterToast["id"]
49 | }
50 | | {
51 | type: ActionType["REMOVE_TOAST"]
52 | toastId?: ToasterToast["id"]
53 | }
54 |
55 | interface State {
56 | toasts: ToasterToast[]
57 | }
58 |
59 | const toastTimeouts = new Map>()
60 |
61 | const addToRemoveQueue = (toastId: string) => {
62 | if (toastTimeouts.has(toastId)) {
63 | return
64 | }
65 |
66 | const timeout = setTimeout(() => {
67 | toastTimeouts.delete(toastId)
68 | dispatch({
69 | type: "REMOVE_TOAST",
70 | toastId: toastId,
71 | })
72 | }, TOAST_REMOVE_DELAY)
73 |
74 | toastTimeouts.set(toastId, timeout)
75 | }
76 |
77 | export const reducer = (state: State, action: Action): State => {
78 | switch (action.type) {
79 | case "ADD_TOAST":
80 | return {
81 | ...state,
82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83 | }
84 |
85 | case "UPDATE_TOAST":
86 | return {
87 | ...state,
88 | toasts: state.toasts.map((t) =>
89 | t.id === action.toast.id ? { ...t, ...action.toast } : t
90 | ),
91 | }
92 |
93 | case "DISMISS_TOAST": {
94 | const { toastId } = action
95 |
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/hooks/useAgentControl.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 | import { toast } from "@/hooks/use-toast";
3 | import { apiRequest } from '@/utils/api';
4 |
5 | interface AgentStatus {
6 | session_id: string;
7 | processing: boolean;
8 | can_stop: boolean;
9 | started_at?: number;
10 | details?: Record;
11 | }
12 |
13 | interface UseAgentControlReturn {
14 | canStopAgent: boolean;
15 | isLoading: boolean;
16 | isStopInProgress: boolean;
17 | stopAgent: () => Promise;
18 | setCanStopAgent: (canStop: boolean) => void;
19 | setIsStopInProgress: (inProgress: boolean) => void;
20 | }
21 |
22 | export function useAgentControl(sessionId?: string): UseAgentControlReturn {
23 | const [canStopAgent, setCanStopAgent] = useState(false);
24 | const [isLoading, setIsLoading] = useState(false);
25 | const [isStopInProgress, setIsStopInProgress] = useState(false);
26 |
27 | // No status refresh needed - managed by ThoughtProcess events
28 |
29 | const stopAgent = useCallback(async () => {
30 | if (!sessionId || !canStopAgent) {
31 | return;
32 | }
33 |
34 | setIsLoading(true);
35 | try {
36 | const response = await apiRequest(`/agent/stop/${sessionId}`, {
37 | method: 'POST',
38 | });
39 |
40 | if (response.ok) {
41 | const data = await response.json();
42 |
43 | // Set stop in progress state - blocking UI until completion
44 | setIsStopInProgress(true);
45 | // Keep canStopAgent true until actually stopped
46 |
47 | toast({
48 | title: "Stop requested",
49 | description: "Agent is finishing current task and will stop gracefully.",
50 | });
51 |
52 | // Status will be updated via ThoughtProcess events when actually stopped
53 | // UI will remain blocked until we receive 'complete' or 'stopped' event
54 | } else {
55 | const errorData = await response.json();
56 | setIsStopInProgress(false); // Reset on error
57 | toast({
58 | title: "Failed to stop agent",
59 | description: errorData.detail || "Unable to stop agent.",
60 | variant: "destructive",
61 | });
62 | }
63 | } catch (error) {
64 | console.error('Failed to stop agent:', error);
65 | setIsStopInProgress(false); // Reset on error
66 | toast({
67 | title: "Error",
68 | description: "Failed to communicate with server.",
69 | variant: "destructive",
70 | });
71 | } finally {
72 | setIsLoading(false);
73 | }
74 | }, [sessionId, canStopAgent]);
75 |
76 | return {
77 | canStopAgent,
78 | isLoading,
79 | isStopInProgress,
80 | stopAgent,
81 | setCanStopAgent,
82 | setIsStopInProgress,
83 | };
84 | }
--------------------------------------------------------------------------------
/hooks/useAutoScroll.ts:
--------------------------------------------------------------------------------
1 | // hooks/useAutoScroll.ts
2 | import { useRef, useEffect } from 'react';
3 |
4 | export const useAutoScroll = (dependencies: T) => {
5 | const endRef = useRef(null);
6 |
7 | useEffect(() => {
8 | const scrollToBottom = () => {
9 | if (!endRef.current) return;
10 |
11 | // Use requestAnimationFrame for smoother scrolling
12 | requestAnimationFrame(() => {
13 | endRef.current?.scrollIntoView({
14 | behavior: "smooth",
15 | block: "end",
16 | });
17 | });
18 | };
19 |
20 | // Delay to ensure DOM has updated
21 | const timeoutId = setTimeout(scrollToBottom, 100);
22 | return () => clearTimeout(timeoutId);
23 | }, [...dependencies]);
24 |
25 | // Watch for size changes and adjust scroll
26 | useEffect(() => {
27 | if (!endRef.current) return;
28 |
29 | const observer = new ResizeObserver(() => {
30 | endRef.current?.scrollIntoView({
31 | behavior: "smooth",
32 | block: "end",
33 | });
34 | });
35 |
36 | observer.observe(endRef.current);
37 | return () => observer.disconnect();
38 | }, []);
39 |
40 | return endRef;
41 | };
42 |
--------------------------------------------------------------------------------
/hooks/useMCPServers.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { apiRequest } from '@/utils/api';
3 | import { useState, useEffect } from "react";
4 |
5 | export interface MCPServer {
6 | id: string;
7 | name: string;
8 | hostname: string;
9 | isActive: boolean;
10 | isConnected?: boolean;
11 | }
12 |
13 | export const useMCPServers = () => {
14 | const [servers, setServers] = useState([]);
15 | const [loading, setLoading] = useState(true);
16 |
17 | useEffect(() => {
18 | const fetchServers = async () => {
19 | try {
20 | setLoading(true);
21 | const response = await apiRequest('mcp-servers');
22 | if (!response.ok) throw new Error('Failed to fetch MCP servers');
23 | const data = await response.json();
24 | setServers(data);
25 | } catch (error) {
26 | console.error('Error fetching MCP servers:', error);
27 | } finally {
28 | setLoading(false);
29 | }
30 | };
31 |
32 | fetchServers();
33 | }, []);
34 |
35 | const saveServers = async (updatedServers: MCPServer[]) => {
36 | try {
37 | const response = await apiRequest('mcp-servers', {
38 | method: 'POST',
39 | headers: {
40 | 'Content-Type': 'application/json',
41 | },
42 | body: JSON.stringify(updatedServers),
43 | });
44 |
45 | if (!response.ok) throw new Error('Failed to save MCP servers');
46 | return true;
47 | } catch (error) {
48 | console.error('Error saving MCP servers:', error);
49 | return false;
50 | }
51 | };
52 |
53 | // Add new server
54 | const addServer = async (name: string, hostname: string) => {
55 | try {
56 | // Test server connection
57 | const testResult = await testServerConnection(hostname);
58 |
59 | if (testResult) {
60 | const newServer: MCPServer = {
61 | id: Date.now().toString(),
62 | name,
63 | hostname,
64 | isActive: true,
65 | isConnected: testResult,
66 | };
67 |
68 | const updatedServers = [...servers, newServer];
69 | setServers(updatedServers);
70 | await saveServers(updatedServers);
71 |
72 | return { success: true, server: newServer };
73 | } else {
74 | return { success: false, error: "Connection test failed" };
75 | }
76 | } catch (error) {
77 | return { success: false, error };
78 | }
79 | };
80 |
81 | // Remove server
82 | const removeServer = async (id: string) => {
83 | const updatedServers = servers.filter((server) => server.id !== id);
84 | setServers(updatedServers);
85 | await saveServers(updatedServers);
86 | };
87 |
88 | // Toggle server active status
89 | const toggleServerActive = async (id: string) => {
90 | const updatedServers = servers.map((server) =>
91 | server.id === id ? { ...server, isActive: !server.isActive } : server
92 | );
93 | setServers(updatedServers);
94 | await saveServers(updatedServers);
95 | };
96 |
97 | // Test server connection
98 | const testServerConnection = async (hostname: string): Promise => {
99 | try {
100 | const response = await apiRequest('mcp-servers/test', {
101 | method: 'POST',
102 | headers: {
103 | 'Content-Type': 'application/json',
104 | },
105 | body: JSON.stringify({ hostname }),
106 | });
107 |
108 | const data = await response.json();
109 | return data.success;
110 | } catch (error) {
111 | console.error("Server connection test failed:", error);
112 | return false;
113 | }
114 | };
115 |
116 | // Retest server connection
117 | const retestServerConnection = async (id: string) => {
118 | const server = servers.find(s => s.id === id);
119 | if (!server) return false;
120 |
121 | try {
122 | const isConnected = await testServerConnection(server.hostname);
123 |
124 | const updatedServers = servers.map(s =>
125 | s.id === id ? { ...s, isConnected } : s
126 | );
127 |
128 | setServers(updatedServers);
129 | await saveServers(updatedServers);
130 |
131 | return isConnected;
132 | } catch (error) {
133 | return false;
134 | }
135 | };
136 |
137 | return {
138 | servers,
139 | loading,
140 | addServer,
141 | removeServer,
142 | toggleServerActive,
143 | testServerConnection,
144 | retestServerConnection
145 | };
146 | };
--------------------------------------------------------------------------------
/hooks/useScrollHandling.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, useState } from 'react';
2 | import type { Message } from '@/types/chat';
3 |
4 | interface UseScrollHandlingProps {
5 | messages: Message[];
6 | isLoading: boolean;
7 | }
8 |
9 | export const useScrollHandling = ({ messages, isLoading }: UseScrollHandlingProps) => {
10 | const [currentQueryIndex, setCurrentQueryIndex] = useState(0);
11 | const messagesEndRef = useRef(null);
12 |
13 | useEffect(() => {
14 | if (!messagesEndRef.current) return;
15 |
16 | const scrollToBottom = () => {
17 | requestAnimationFrame(() => {
18 | messagesEndRef.current?.scrollIntoView({
19 | behavior: "smooth",
20 | block: "end",
21 | });
22 | });
23 | };
24 |
25 | const timeoutId = setTimeout(scrollToBottom, 100);
26 | return () => clearTimeout(timeoutId);
27 | }, [messages, isLoading]);
28 |
29 | useEffect(() => {
30 | if (!messagesEndRef.current) return;
31 |
32 | const observer = new ResizeObserver(() => {
33 | messagesEndRef.current?.scrollIntoView({
34 | behavior: "smooth",
35 | block: "end",
36 | });
37 | });
38 |
39 | observer.observe(messagesEndRef.current);
40 | return () => observer.disconnect();
41 | }, []);
42 |
43 | return {
44 | currentQueryIndex,
45 | messagesEndRef,
46 | };
47 | };
48 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "analysis-assistant",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "npx concurrently \"npm run client-dev\" \"npm run server-dev\"",
7 | "build": "next build",
8 | "start": "npx concurrently \"npm run client-start\" \"npm run server-start\"",
9 | "lint": "next lint",
10 | "kill-port": "kill-port 8000",
11 | "client-dev": "cross-env NODE_OPTIONS='--no-warnings' node node_modules/next/dist/bin/next dev",
12 | "client-start": "cross-env NODE_OPTIONS='--no-warnings' node node_modules/next/dist/bin/next start",
13 | "server-dev": "npm run kill-port && cd py-backend && uvicorn app.app:app --host 0.0.0.0 --port 8000",
14 | "server-start": "cd py-backend && uvicorn app.app:app --host 0.0.0.0 --port 8000"
15 | },
16 | "engines": {
17 | "node": ">=16.0.0 <24.0.0"
18 | },
19 | "dependencies": {
20 | "@anthropic-ai/sdk": "^0.29.0",
21 | "@aws-sdk/client-bedrock-runtime": "^3.679.0",
22 | "@radix-ui/react-avatar": "^1.1.1",
23 | "@radix-ui/react-checkbox": "^1.3.0",
24 | "@radix-ui/react-dialog": "^1.1.12",
25 | "@radix-ui/react-dropdown-menu": "^2.1.2",
26 | "@radix-ui/react-icons": "^1.3.0",
27 | "@radix-ui/react-label": "^2.1.0",
28 | "@radix-ui/react-slot": "^1.1.0",
29 | "@radix-ui/react-toast": "^1.2.2",
30 | "class-variance-authority": "^0.7.0",
31 | "clsx": "^2.1.1",
32 | "html2canvas": "^1.4.1",
33 | "lucide-react": "^0.452.0",
34 | "next": "^15.3.2",
35 | "next-themes": "^0.3.0",
36 | "pdfjs-dist": "^4.7.76",
37 | "react": "^18",
38 | "react-dom": "^18",
39 | "react-icons": "^5.3.0",
40 | "recharts": "^2.13.0",
41 | "tailwind-merge": "^2.6.0",
42 | "tailwindcss-animate": "^1.0.7"
43 | },
44 | "devDependencies": {
45 | "@types/node": "^20",
46 | "@types/react": "^18",
47 | "@types/react-dom": "^18",
48 | "@types/uuid": "^10.0.0",
49 | "concurrently": "^8.2.2",
50 | "cross-env": "^7.0.3",
51 | "eslint": "^8",
52 | "eslint-config-next": "15.0.0",
53 | "kill-port": "^2.0.1",
54 | "postcss": "^8",
55 | "tailwindcss": "^3.4.1",
56 | "typescript": "^5"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/amazon-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/public/amazon-dark.png
--------------------------------------------------------------------------------
/public/amazon.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/public/amazon.webp
--------------------------------------------------------------------------------
/public/ant-logo.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/public/bedrock-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/public/bedrock-logo.png
--------------------------------------------------------------------------------
/public/wordmark-dark.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/wordmark.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/py-backend/.env.example:
--------------------------------------------------------------------------------
1 | # Nova Act API Configuration
2 | NOVA_ACT_API_KEY=your_nova_act_api_key_here
3 |
4 | # Browser Configuration
5 | NOVA_BROWSER_HEADLESS=True
6 | NOVA_BROWSER_MAX_STEPS=2
7 | NOVA_BROWSER_TIMEOUT=100
8 | NOVA_BROWSER_URL_TIMEOUT=60
9 | NOVA_BROWSER_USER_DATA_DIR=/path/to/chromium/profile
10 | NOVA_BROWSER_CLONE_USER_DATA=False
11 | NOVA_BROWSER_SCREENSHOT_QUALITY=70
12 | NOVA_BROWSER_SCREENSHOT_MAX_WIDTH=800
13 | NOVA_BROWSER_RECORD_VIDEO=False
14 | NOVA_BROWSER_LOGS_DIR=
15 | NOVA_BROWSER_QUIET=False
16 |
17 | # MCP Configuration
18 | NOVA_MCP_TRANSPORT=stdio
19 | NOVA_MCP_PORT=8000
20 | NOVA_MCP_HOST=localhost
21 | NOVA_MCP_LOG_LEVEL=INFO
--------------------------------------------------------------------------------
/py-backend/app/__init__.py:
--------------------------------------------------------------------------------
1 | # app module initialization
2 |
--------------------------------------------------------------------------------
/py-backend/app/act_agent/client/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/py-backend/app/act_agent/client/__init__.py
--------------------------------------------------------------------------------
/py-backend/app/act_agent/server/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/py-backend/app/act_agent/server/__init__.py
--------------------------------------------------------------------------------
/py-backend/app/act_agent/server/nova-act-server/__init__.py:
--------------------------------------------------------------------------------
1 | from .nova_act_server import main as run_server
2 | from .nova_act_config import DEFAULT_BROWSER_SETTINGS
--------------------------------------------------------------------------------
/py-backend/app/act_agent/server/nova-act-server/nova_act_config.py:
--------------------------------------------------------------------------------
1 | """
2 | Configuration settings for Nova Act browser automation
3 | Now imports from central config.py
4 | """
5 |
6 | import sys
7 | import os
8 |
9 | # Add the parent directory to path to allow importing config from app.libs
10 | sys.path.append(os.path.join(os.path.dirname(__file__), "../../../../"))
11 |
12 | # Import centralized settings
13 | from app.libs.config import (
14 | BROWSER_HEADLESS,
15 | BROWSER_START_URL,
16 | BROWSER_MAX_STEPS,
17 | BROWSER_TIMEOUT,
18 | BROWSER_URL_TIMEOUT,
19 | LOGS_DIRECTORY,
20 | BROWSER_RECORD_VIDEO,
21 | BROWSER_QUIET_MODE,
22 | BROWSER_USER_AGENT,
23 | BROWSER_USER_DATA_DIR,
24 | BROWSER_CLONE_USER_DATA,
25 | BROWSER_SCREENSHOT_QUALITY,
26 | BROWSER_SCREENSHOT_MAX_WIDTH,
27 | MCP_SERVER_NAME,
28 | MCP_VERSION,
29 | MCP_TRANSPORT,
30 | MCP_PORT,
31 | MCP_HOST,
32 | MCP_LOG_LEVEL
33 | )
34 |
35 | # Default browser settings
36 | DEFAULT_BROWSER_SETTINGS = {
37 | # Browser display settings
38 | "headless": BROWSER_HEADLESS,
39 | "start_url": BROWSER_START_URL,
40 |
41 | # Performance and timeout settings
42 | "max_steps": BROWSER_MAX_STEPS,
43 | "timeout": BROWSER_TIMEOUT,
44 | "go_to_url_timeout": BROWSER_URL_TIMEOUT,
45 |
46 | # Logging and debugging
47 | "logs_directory": LOGS_DIRECTORY,
48 | "record_video": BROWSER_RECORD_VIDEO,
49 | "quiet": BROWSER_QUIET_MODE,
50 |
51 | # User agent and authentication settings
52 | "user_agent": BROWSER_USER_AGENT,
53 |
54 | # Browser profile settings (for authentication)
55 | "user_data_dir": BROWSER_USER_DATA_DIR,
56 | "clone_user_data_dir": BROWSER_CLONE_USER_DATA,
57 |
58 | # Screenshot settings
59 | "screenshot_quality": BROWSER_SCREENSHOT_QUALITY,
60 | "screenshot_max_width": BROWSER_SCREENSHOT_MAX_WIDTH,
61 | }
62 |
63 | # MCP server settings
64 | MCP_SERVER_SETTINGS = {
65 | "server_name": MCP_SERVER_NAME,
66 | "version": MCP_VERSION,
67 | "transport": MCP_TRANSPORT,
68 | "port": MCP_PORT,
69 | "host": MCP_HOST,
70 | "log_level": MCP_LOG_LEVEL,
71 | }
72 |
--------------------------------------------------------------------------------
/py-backend/app/act_agent/server/nova-act-server/schemas.py:
--------------------------------------------------------------------------------
1 | # schemas.py
2 | from typing import Dict, Any, List, Optional, Union
3 | from pydantic import BaseModel, Field
4 |
5 | class BoolSchema(BaseModel):
6 | value: bool
7 |
8 | class ProductSchema(BaseModel):
9 | title: str = Field(..., description="Product title or name")
10 | price: str = Field(..., description="Product price with currency symbol")
11 | rating: Optional[str] = Field(None, description="Product rating (e.g. 4.5/5)")
12 | available: Optional[bool] = Field(None, description="Whether the product is available/in stock")
13 | description: Optional[str] = Field(None, description="Brief product description")
14 |
15 | class SearchResultItem(BaseModel):
16 | title: str = Field(..., description="Result title")
17 | url: Optional[str] = Field(None, description="Result URL")
18 | snippet: Optional[str] = Field(None, description="Result snippet or description")
19 |
20 | class SearchResultSchema(BaseModel):
21 | results: List[SearchResultItem] = Field(..., description="List of search results")
22 | total_count: Optional[int] = Field(None, description="Total number of results")
23 |
24 | class FormFieldOption(BaseModel):
25 | name: str = Field(..., description="Option name")
26 | value: Optional[str] = Field(None, description="Option value")
27 |
28 | class FormField(BaseModel):
29 | name: str = Field(..., description="Field name")
30 | type: str = Field(..., description="Field type (text, select, checkbox, radio, date, file, password, other)")
31 | required: Optional[bool] = Field(False, description="Whether the field is required")
32 | options: Optional[List[FormFieldOption]] = Field(None, description="Options for select, radio, checkbox fields")
33 |
34 | class FormFieldsSchema(BaseModel):
35 | fields: List[FormField] = Field(..., description="List of form fields")
36 |
37 | class NavigationLink(BaseModel):
38 | text: str = Field(..., description="Link text")
39 | url: Optional[str] = Field(None, description="Link URL")
40 |
41 | class NavigationSchema(BaseModel):
42 | current_url: str = Field(..., description="Current page URL")
43 | page_title: str = Field(..., description="Current page title")
44 | navigation_links: Optional[List[NavigationLink]] = Field(None, description="Navigation links on the page")
45 |
--------------------------------------------------------------------------------
/py-backend/app/act_agent/test-run.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import sys
3 | import os
4 | import argparse
5 | from pathlib import Path
6 |
7 | current_file = Path(__file__).absolute()
8 | actual_project_root = current_file.parent.parent.parent.parent
9 | app_dir = actual_project_root / "py-backend"
10 | act_agent_dir = app_dir / "app" / "act_agent"
11 |
12 | sys.path.insert(0, str(actual_project_root))
13 | sys.path.insert(0, str(app_dir))
14 | sys.path.insert(0, str(act_agent_dir))
15 | sys.path.insert(0, str(act_agent_dir.parent))
16 |
17 | from app.act_agent.client.agent import ActAgent
18 |
19 | async def main():
20 | parser = argparse.ArgumentParser(description="Run Act Agent with Nova Act Server")
21 | parser.add_argument("--headless", action="store_true", help="Run browser in headless mode")
22 | parser.add_argument("--url", default="https://www.google.com", help="Starting URL")
23 | parser.add_argument("--max-steps", type=int, default=30, help="Max steps for actions")
24 | parser.add_argument("--timeout", type=int, default=300, help="Timeout for actions")
25 | args = parser.parse_args()
26 | server_path = str(act_agent_dir / "server" / "nova-act-server" / "nova_act_server.py")
27 |
28 | try:
29 | agent = ActAgent()
30 | await agent.connect_to_server(server_path)
31 | await agent.chat_loop()
32 | finally:
33 | if 'agent' in locals():
34 | await agent.cleanup()
35 |
36 | if __name__ == "__main__":
37 | asyncio.run(main())
38 |
--------------------------------------------------------------------------------
/py-backend/app/api_routes/__init__.py:
--------------------------------------------------------------------------------
1 | # api_routes package initialization
2 |
--------------------------------------------------------------------------------
/py-backend/app/api_routes/agent_control.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, HTTPException
2 | from app.libs.core.agent_manager import get_agent_manager
3 | import logging
4 | import time
5 |
6 | logger = logging.getLogger(__name__)
7 |
8 | router = APIRouter()
9 |
10 | # Status endpoint removed - status is now managed via ThoughtProcess events
11 |
12 | @router.post("/agent/stop/{session_id}")
13 | async def stop_agent(session_id: str):
14 | """Request to stop agent processing for session"""
15 | try:
16 | agent_manager = get_agent_manager()
17 |
18 | # Request stop (now async) - no pre-validation needed
19 | success = await agent_manager.request_agent_stop(session_id)
20 |
21 | if success:
22 | logger.info(f"Stop requested for agent in session {session_id}")
23 |
24 | # Send immediate callback that stop request was accepted
25 | # Use a dedicated event type that doesn't interfere with thinking state
26 | from app.libs.utils.decorators import log_thought
27 | log_thought(
28 | session_id=session_id,
29 | type_name="stop_notification",
30 | category="status",
31 | node="System",
32 | content="🛑 Stop request accepted - Agent will terminate gracefully",
33 | technical_details={
34 | "stop_request_accepted": True,
35 | "timestamp": time.time()
36 | }
37 | )
38 |
39 | return {
40 | "session_id": session_id,
41 | "status": "stop_requested",
42 | "message": "Agent stop has been requested"
43 | }
44 | else:
45 | # No active processing found, but not an error
46 | logger.info(f"No active processing found for session {session_id}")
47 | return {
48 | "session_id": session_id,
49 | "status": "no_processing",
50 | "message": "No active processing to stop"
51 | }
52 |
53 | except Exception as e:
54 | logger.error(f"Error stopping agent for session {session_id}: {e}")
55 | raise HTTPException(status_code=500, detail=str(e))
--------------------------------------------------------------------------------
/py-backend/app/api_routes/mcp_servers.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, HTTPException, Body
2 | from typing import List, Dict, Optional, Any
3 | import json
4 | import os
5 | import logging
6 | from pydantic import BaseModel
7 |
8 | logger = logging.getLogger(__name__)
9 |
10 | router = APIRouter()
11 |
12 | # Path to store server configuration
13 | MCP_SERVER_CONFIG_PATH = "mcp_server_config.json"
14 |
15 | # Model for server information
16 | class MCPServer(BaseModel):
17 | id: str
18 | name: str
19 | hostname: str
20 | isActive: bool
21 | isConnected: Optional[bool] = None
22 |
23 | class ServerTestRequest(BaseModel):
24 | hostname: str
25 |
26 | # Initial default values
27 | DEFAULT_SERVERS = [
28 | {
29 | "id": "default-mcp-1",
30 | "name": "MCP Server (Local)",
31 | "hostname": "localhost:8000",
32 | "isActive": True,
33 | "isConnected": True
34 | }
35 | ]
36 |
37 | def load_server_config() -> List[Dict[str, Any]]:
38 | try:
39 | if os.path.exists(MCP_SERVER_CONFIG_PATH):
40 | with open(MCP_SERVER_CONFIG_PATH, "r") as f:
41 | return json.load(f)
42 | else:
43 | # Save default values if config file doesn't exist
44 | save_server_config(DEFAULT_SERVERS)
45 | return DEFAULT_SERVERS
46 | except Exception as e:
47 | logger.error("Error loading MCP server config", extra={"error": str(e)})
48 | return DEFAULT_SERVERS
49 |
50 | def save_server_config(servers: List[Dict[str, Any]]) -> None:
51 | try:
52 | with open(MCP_SERVER_CONFIG_PATH, "w") as f:
53 | json.dump(servers, f, indent=2)
54 | except Exception as e:
55 | logger.error("Error saving MCP server config", extra={"error": str(e)})
56 |
57 | @router.get("/", response_model=List[MCPServer])
58 | async def get_mcp_servers():
59 | """Returns the currently configured MCP server list."""
60 | return load_server_config()
61 |
62 | @router.post("/", response_model=List[MCPServer])
63 | async def update_mcp_servers(servers: List[MCPServer] = Body(...)):
64 | """Updates the MCP server list."""
65 | server_data = [server.dict() for server in servers]
66 | save_server_config(server_data)
67 | return server_data
68 |
69 | @router.post("/test", response_model=Dict[str, Any])
70 | async def test_mcp_server(request: ServerTestRequest = Body(...)):
71 | """Tests connection to a specific MCP server."""
72 | import asyncio
73 |
74 | try:
75 | # Test server connection with 3 second timeout
76 | hostname = request.hostname
77 | if not hostname.startswith('http'):
78 | hostname = f"http://{hostname}"
79 |
80 | # Add a health endpoint if not present
81 | if not hostname.endswith('/health'):
82 | if hostname.endswith('/'):
83 | hostname = f"{hostname}health"
84 | else:
85 | hostname = f"{hostname}/health"
86 |
87 | async with asyncio.timeout(3):
88 | from httpx import AsyncClient
89 | async with AsyncClient() as client:
90 | response = await client.get(hostname)
91 | return {"success": response.status_code < 400}
92 | except Exception as e:
93 | logger.error("MCP server connection test failed", extra={"error": str(e), "hostname": hostname})
94 | return {"success": False, "error": str(e)}
--------------------------------------------------------------------------------
/py-backend/app/api_routes/thought_stream.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Request, HTTPException
2 | from fastapi.responses import StreamingResponse
3 | from app.libs.utils.thought_stream import thought_handler
4 | from app.libs.data.session_manager import get_session_manager
5 | import logging
6 |
7 | logger = logging.getLogger("thought_stream_api")
8 |
9 | router = APIRouter()
10 |
11 | @router.get("/thoughts/{session_id}")
12 | async def stream_thoughts(session_id: str):
13 | """Stream thought processes for a specific session"""
14 | try:
15 | logger.info(f"SSE connection request for session: {session_id}")
16 |
17 | # Create session if it doesn't exist (simple prototype approach)
18 | session_manager = get_session_manager()
19 | await session_manager.get_or_create_session(session_id)
20 | logger.info(f"Session ready for SSE: {session_id}")
21 |
22 | # Register session in thought handler if needed
23 | if session_id not in thought_handler.queues:
24 | logger.info(f"Registering valid session: {session_id}")
25 | thought_handler.register_session(session_id)
26 | else:
27 | logger.info(f"Valid session found with {thought_handler.queues[session_id].qsize()} thoughts queued")
28 |
29 | return StreamingResponse(
30 | thought_handler.stream_generator(session_id),
31 | media_type="text/event-stream",
32 | headers={
33 | "Cache-Control": "no-cache",
34 | "Connection": "keep-alive",
35 | "X-Accel-Buffering": "no",
36 | "Access-Control-Allow-Origin": "*",
37 | "Access-Control-Allow-Methods": "GET",
38 | "Access-Control-Allow-Headers": "Content-Type"
39 | }
40 | )
41 | except Exception as e:
42 | logger.error(f"Error setting up thought stream: {e}")
43 | raise HTTPException(status_code=500, detail=f"Error setting up thought stream: {str(e)}")
44 |
--------------------------------------------------------------------------------
/py-backend/app/app.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from fastapi.middleware.cors import CORSMiddleware
3 | from app.api_routes import thought_stream, router, mcp_servers, browser_control, agent_control
4 | from app.libs.utils.utils import setup_paths, register_session_and_thought_handler
5 | from app.libs.config.config import BROWSER_USER_DATA_DIR
6 | from app.libs.utils.shutdown_manager import shutdown_manager
7 | from app.libs.core.agent_manager import get_agent_manager
8 | from app.libs.data.session_manager import configure_session_manager
9 |
10 | import logging
11 | import sys
12 | import os
13 | import subprocess
14 | import asyncio
15 | from pathlib import Path
16 | import traceback
17 |
18 | log_dir = Path("logs")
19 | log_dir.mkdir(exist_ok=True)
20 |
21 | logging.basicConfig(
22 | level=logging.INFO,
23 | format='%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s',
24 | handlers=[
25 | logging.FileHandler(log_dir / "app.log"),
26 | logging.StreamHandler(sys.stdout)
27 | ]
28 | )
29 | logger = logging.getLogger("app")
30 | logging.getLogger('router_api').setLevel(logging.WARNING)
31 | logging.getLogger('act_agent_api').setLevel(logging.WARNING)
32 | logging.getLogger('thought_stream').setLevel(logging.WARNING)
33 |
34 | app = FastAPI(title="Nova Act Agent API")
35 |
36 | # Dictionary for tracking MCP processes - needs to be global
37 | mcp_processes = {}
38 |
39 | app.include_router(thought_stream.router, prefix="/api/assistant", tags=["Thought Stream"])
40 | app.include_router(router.router, prefix="/api/router", tags=["Router"])
41 | app.include_router(browser_control.router, prefix="/api/browser", tags=["Browser Control"])
42 | app.include_router(mcp_servers.router, prefix="/api/mcp-servers", tags=["MCP Servers"])
43 | app.include_router(agent_control.router, prefix="/api", tags=["Agent Control"])
44 |
45 | app.add_middleware(
46 | CORSMiddleware,
47 | allow_origins=["*"],
48 | allow_credentials=True,
49 | allow_methods=["*"],
50 | allow_headers=["*"],
51 | )
52 |
53 | @app.get("/health")
54 | async def health_check():
55 | return {"status": "healthy"}
56 |
57 | @app.on_event("shutdown")
58 | async def shutdown_event():
59 | logger.info("Application shutting down - cleaning up resources...")
60 | try:
61 | await shutdown_manager.graceful_shutdown()
62 | except Exception as e:
63 | logger.error(f"Error during graceful shutdown: {e}")
64 | shutdown_manager.force_cleanup()
65 |
66 | async def safe_shutdown():
67 | try:
68 | await asyncio.wait_for(get_agent_manager().close_all_managers(), timeout=3.0)
69 | except asyncio.TimeoutError:
70 | logger.warning("Agent manager close timed out")
71 |
72 | for process_id, process in list(mcp_processes.items()):
73 | try:
74 | if process and process.poll() is None:
75 | process.terminate()
76 | try:
77 | await asyncio.wait_for(
78 | asyncio.to_thread(process.wait),
79 | timeout=1.0
80 | )
81 | except asyncio.TimeoutError:
82 | logger.warning(f"Process {process_id} termination timed out, killing")
83 | process.kill()
84 | except Exception as e:
85 | logger.error(f"Error terminating process {process_id}: {str(e)}")
86 |
87 | @app.on_event("startup")
88 | async def startup_event():
89 | global_session_id = "global-startup"
90 | try:
91 | logger.info("Initializing Nova Act Agent on server startup...")
92 |
93 | # Check and setup browser profile path configuration
94 | if BROWSER_USER_DATA_DIR == "/path/to/chromium/profile":
95 | logger.info("Browser profile path not configured. Creating default base directory.")
96 | default_base_dir = os.path.expanduser("~/.nova_browser_profiles/base")
97 | os.makedirs(default_base_dir, exist_ok=True)
98 | os.environ["NOVA_BROWSER_USER_DATA_DIR"] = default_base_dir
99 | logger.info(f"Created and set browser base profile directory: {default_base_dir}")
100 | else:
101 | # Ensure the configured directory exists
102 | os.makedirs(BROWSER_USER_DATA_DIR, exist_ok=True)
103 | logger.info(f"Using browser base profile directory: {BROWSER_USER_DATA_DIR}")
104 |
105 | # Configure session manager
106 | session_manager = configure_session_manager(
107 | store_type="file", # Use file store for persistence
108 | ttl=7200, # 2 hours default TTL
109 | storage_dir="./data/sessions"
110 | )
111 | logger.info("Session manager configured")
112 |
113 | # Initialize the shutdown manager with references
114 | shutdown_manager.register_mcp_processes(mcp_processes)
115 | shutdown_manager.register_agent_manager(get_agent_manager())
116 | shutdown_manager.register_session_manager(session_manager)
117 |
118 | # Register profile manager for cleanup
119 | from app.libs.utils.profile_manager import profile_manager
120 | shutdown_manager.register_profile_manager(profile_manager)
121 |
122 | # Setup signal handlers and exit handler
123 | shutdown_manager.setup_signal_handlers()
124 | shutdown_manager.register_exit_handler()
125 |
126 | paths = setup_paths()
127 | try:
128 | register_session_and_thought_handler(global_session_id)
129 | except Exception as reg_error:
130 | logger.error(f"Failed to register session: {reg_error}")
131 |
132 | if not os.path.exists(paths["server_path"]):
133 | error_msg = f"Server script not found at: {paths['server_path']}"
134 | logger.error(error_msg)
135 | return
136 |
137 | try:
138 | server_path = paths["server_path"]
139 | logger.info(f"Starting Nova Act Server (streamable HTTP) from {server_path}")
140 |
141 | # Start Nova Act server with streamable HTTP transport on port 8001
142 | server_process = subprocess.Popen(
143 | [sys.executable, server_path, "--transport", "streamable-http", "--port", "8001"],
144 | stdout=subprocess.PIPE,
145 | stderr=subprocess.PIPE,
146 | bufsize=0,
147 | start_new_session=True
148 | )
149 |
150 | mcp_processes["nova-act-server-main"] = server_process
151 | logger.info(f"Nova Act Server (streamable HTTP) started with PID {server_process.pid}")
152 |
153 | # Wait a bit and check if server started successfully
154 | await asyncio.sleep(2)
155 |
156 | # Check if process is still running
157 | if server_process.poll() is not None:
158 | # Process has terminated, read error output
159 | stdout, stderr = server_process.communicate()
160 | error_msg = f"Nova Act Server failed to start. Return code: {server_process.returncode}"
161 | if stderr:
162 | error_msg += f"\nStderr: {stderr.decode()}"
163 | if stdout:
164 | error_msg += f"\nStdout: {stdout.decode()}"
165 | logger.error(error_msg)
166 | raise RuntimeError(error_msg)
167 | else:
168 | logger.info("Nova Act Server is running successfully")
169 | except Exception as e:
170 | error_msg = f"Failed to start Nova Act Server: {str(e)}"
171 | logger.error(error_msg)
172 | logger.error(traceback.format_exc())
173 | raise RuntimeError(error_msg)
174 |
175 | logger.info("Nova Act Server started and ready for session-based agents")
176 |
177 | except Exception as e:
178 | error_msg = f"Error initializing Nova Act Agent: {str(e)}"
179 | logger.error(error_msg)
180 | logger.error(traceback.format_exc())
181 |
182 | if __name__ == "__main__":
183 | import uvicorn
184 | uvicorn.run("app.app:app", host="0.0.0.0", port=8000, reload=False)
185 |
--------------------------------------------------------------------------------
/py-backend/app/libs/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/py-backend/app/libs/__init__.py
--------------------------------------------------------------------------------
/py-backend/app/libs/config/__init__.py:
--------------------------------------------------------------------------------
1 | from .config import *
--------------------------------------------------------------------------------
/py-backend/app/libs/config/config.py:
--------------------------------------------------------------------------------
1 | # Global configuration settings for the application
2 | import os
3 | from dotenv import load_dotenv
4 |
5 | # Load environment variables from .env file
6 | load_dotenv()
7 |
8 | # LLM Model settings
9 | DEFAULT_MODEL_ID = "us.anthropic.claude-3-7-sonnet-20250219-v1:0"
10 |
11 | # Conversation flow settings
12 | MAX_SUPERVISOR_TURNS = 10 # Maximum conversation turns between supervisor and agent
13 | MAX_AGENT_TURNS = 6 # Maximum turns between agent and MCP tools
14 | BROWSER_MAX_STEPS = int(os.environ.get("NOVA_BROWSER_MAX_STEPS", "2")) # Maximum turns between Nova Act and Browser
15 |
16 | # Browser settings - Core
17 | BROWSER_HEADLESS = os.environ.get("NOVA_BROWSER_HEADLESS", "True").lower() in ("true", "1", "yes")
18 | BROWSER_START_URL = "https://www.google.com" # Default starting URL
19 | BROWSER_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
20 |
21 | # Browser settings - Performance
22 | BROWSER_TIMEOUT = int(os.environ.get("NOVA_BROWSER_TIMEOUT", "100"))
23 | BROWSER_URL_TIMEOUT = int(os.environ.get("NOVA_BROWSER_URL_TIMEOUT", "60"))
24 |
25 | # Browser settings - Profiles
26 | BROWSER_USER_DATA_DIR = os.environ.get("NOVA_BROWSER_USER_DATA_DIR", "/path/to/chromium/profile")
27 | BROWSER_CLONE_USER_DATA = os.environ.get("NOVA_BROWSER_CLONE_USER_DATA", "False").lower() in ("true", "1", "yes")
28 |
29 | # Browser settings - Media
30 | BROWSER_SCREENSHOT_QUALITY = int(os.environ.get("NOVA_BROWSER_SCREENSHOT_QUALITY", "70"))
31 | BROWSER_SCREENSHOT_MAX_WIDTH = int(os.environ.get("NOVA_BROWSER_SCREENSHOT_MAX_WIDTH", "800"))
32 | BROWSER_RECORD_VIDEO = os.environ.get("NOVA_BROWSER_RECORD_VIDEO", "False").lower() in ("true", "1", "yes")
33 |
34 | # API settings
35 | API_TIMEOUT_SECONDS = 60
36 |
37 | # Conversation memory settings
38 | MAX_CONVERSATION_MESSAGES = 50 # Maximum number of messages to keep in conversation history
39 | CONVERSATION_STORAGE_TYPE = "memory" # Options: "memory" or "file"
40 | CONVERSATION_FILE_TTL_DAYS = 7 # Number of days to keep conversation files
41 | CONVERSATION_CLEANUP_INTERVAL = 3600 # Cleanup interval in seconds
42 |
43 | # Logging settings
44 | DEBUG_LOGGING = True
45 | LOGS_DIRECTORY = os.environ.get("NOVA_BROWSER_LOGS_DIR", None)
46 | BROWSER_QUIET_MODE = os.environ.get("NOVA_BROWSER_QUIET", "False").lower() in ("true", "1", "yes")
47 |
48 | # MCP server settings
49 | MCP_SERVER_NAME = "nova-browser-automation"
50 | MCP_VERSION = "0.1.0"
51 | MCP_TRANSPORT = os.environ.get("NOVA_MCP_TRANSPORT", "stdio")
52 | MCP_PORT = int(os.environ.get("NOVA_MCP_PORT", "8000"))
53 | MCP_HOST = os.environ.get("NOVA_MCP_HOST", "localhost")
54 | MCP_LOG_LEVEL = os.environ.get("NOVA_MCP_LOG_LEVEL", "INFO")
--------------------------------------------------------------------------------
/py-backend/app/libs/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/py-backend/app/libs/core/__init__.py
--------------------------------------------------------------------------------
/py-backend/app/libs/core/browser_utils.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import boto3
3 | import logging
4 | from typing import Dict, Any, Optional, List
5 |
6 | logger = logging.getLogger("browser_utils")
7 |
8 | class BrowserUtils:
9 | @staticmethod
10 | async def get_browser_state(browser_manager, session_id=None, include_log=True):
11 | """
12 | Get browser state including URL, title, and screenshot using the take_screenshot tool
13 | """
14 | state = {
15 | "browser_initialized": False,
16 | "current_url": "",
17 | "page_title": "",
18 | "screenshot": None
19 | }
20 |
21 | if not browser_manager or not browser_manager.browser_initialized or not browser_manager.session:
22 | logger.warning("Cannot get browser state: browser not initialized")
23 | return state
24 |
25 | try:
26 | # Direct tool call for all browser state information
27 | result = await browser_manager.session.call_tool("take_screenshot", {})
28 | response_data = browser_manager.parse_response(result.content[0].text)
29 |
30 | if isinstance(response_data, dict):
31 | state["browser_initialized"] = True
32 | state["current_url"] = response_data.get("current_url", "")
33 | state["page_title"] = response_data.get("page_title", "")
34 | state["screenshot"] = response_data.get("screenshot")
35 |
36 |
37 | except Exception as e:
38 | logger.error(f"Error getting browser state: {e}")
39 |
40 | return state
41 |
42 | @staticmethod
43 | def create_tool_result_with_screenshot(tool_use_id, response_data, screenshot_data=None):
44 | """
45 | Create a formatted tool result message with optional screenshot
46 | """
47 | from app.libs.data.message import Message
48 | message_content = []
49 |
50 | # Create a clean version of response_data without screenshot
51 | clean_data = response_data.copy() if response_data else {}
52 | if "screenshot" in clean_data:
53 | del clean_data["screenshot"]
54 |
55 | if clean_data:
56 | message_content.append({"json": clean_data})
57 |
58 | # Add screenshot as separate image component
59 | if screenshot_data and isinstance(screenshot_data, dict) and 'data' in screenshot_data:
60 | try:
61 | screenshot_bytes = base64.b64decode(screenshot_data['data'])
62 | message_content.append({
63 | "image": {
64 | "format": screenshot_data.get('format', 'jpeg'),
65 | "source": {
66 | "bytes": screenshot_bytes
67 | }
68 | }
69 | })
70 | except Exception as e:
71 | logger.error(f"Error decoding screenshot: {e}")
72 |
73 | return Message(
74 | role="user",
75 | content=[{
76 | "toolResult": {
77 | "toolUseId": tool_use_id,
78 | "content": message_content,
79 | "status": "success"
80 | }
81 | }]
82 | )
83 |
84 |
85 | class BedrockClient:
86 | def __init__(self, model_id, region):
87 | self.model_id = model_id
88 | self.region = region
89 | self.client = boto3.client('bedrock-runtime', region_name=region)
90 |
91 | def update_config(self, model_id=None, region=None):
92 | if model_id:
93 | self.model_id = model_id
94 | if region:
95 | self.region = region
96 | self.client = boto3.client('bedrock-runtime', region_name=region)
97 |
98 | def converse(self, messages, system_prompt, tools=None, temperature=0.1):
99 | # Filter messages for Bedrock API compatibility if needed
100 | from app.libs.data.conversation_manager import prepare_messages_for_bedrock
101 | filtered_messages = prepare_messages_for_bedrock(messages)
102 |
103 | # Debug logging for Bedrock API call
104 | logger.debug(f"Bedrock API call with {len(filtered_messages)} messages")
105 |
106 | request_params = {
107 | "modelId": self.model_id,
108 | "messages": filtered_messages,
109 | "system": [{'text': system_prompt}],
110 | "inferenceConfig": {"temperature": temperature}
111 | }
112 |
113 | if tools and len(tools) > 0:
114 | if isinstance(tools, dict) and 'tools' in tools:
115 | request_params["toolConfig"] = {"tools": tools['tools']}
116 | else:
117 | request_params["toolConfig"] = {"tools": tools}
118 |
119 | return self.client.converse(**request_params)
120 |
--------------------------------------------------------------------------------
/py-backend/app/libs/data/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/py-backend/app/libs/data/__init__.py
--------------------------------------------------------------------------------
/py-backend/app/libs/data/message.py:
--------------------------------------------------------------------------------
1 | import base64
2 | from typing import Dict, List, Any
3 | from dataclasses import dataclass
4 |
5 | @dataclass
6 | class Message:
7 | role: str
8 | content: List[Dict[str, Any]]
9 |
10 | @classmethod
11 | def user(cls, text: str) -> 'Message':
12 | return cls(role="user", content=[{"text": text}])
13 |
14 | @classmethod
15 | def assistant(cls, text: str) -> 'Message':
16 | return cls(role="assistant", content=[{"text": text}])
17 |
18 | @classmethod
19 | def tool_result(cls, tool_use_id: str, content: dict) -> 'Message':
20 | message_content = []
21 |
22 | # Create a clean version of content without screenshot for JSON
23 | if isinstance(content, dict):
24 | clean_content = content.copy()
25 | if "screenshot" in clean_content:
26 | del clean_content["screenshot"]
27 | message_content.append({"json": clean_content})
28 |
29 | # Add screenshot as a separate image component
30 | if "screenshot" in content and isinstance(content["screenshot"], dict) and "data" in content["screenshot"]:
31 | try:
32 | screenshot_data = content["screenshot"]
33 | screenshot_bytes = base64.b64decode(screenshot_data["data"])
34 | message_content.append({
35 | "image": {
36 | "format": screenshot_data.get("format", "jpeg"),
37 | "source": {
38 | "bytes": screenshot_bytes
39 | }
40 | }
41 | })
42 | except Exception as e:
43 | print(f"Error decoding screenshot: {e}")
44 |
45 | return cls(
46 | role="user",
47 | content=[{
48 | "toolResult": {
49 | "toolUseId": tool_use_id,
50 | "content": message_content,
51 | "status": "success"
52 | }
53 | }]
54 | )
55 |
56 | @classmethod
57 | def tool_request(cls, tool_use_id: str, name: str, input_data: dict) -> 'Message':
58 | return cls(
59 | role="assistant",
60 | content=[{
61 | "toolUse": {
62 | "toolUseId": tool_use_id,
63 | "name": name,
64 | "input": input_data
65 | }
66 | }]
67 | )
68 |
69 | @staticmethod
70 | def to_bedrock_format(tools_list: List[Dict]) -> List[Dict]:
71 | results = []
72 | for tool in tools_list:
73 | tool_spec = {
74 | "toolSpec": {
75 | "name": tool["name"],
76 | "description": tool["description"],
77 | "inputSchema": {
78 | "json": {
79 | "type": "object",
80 | "properties": tool["input_schema"]["properties"],
81 | }
82 | }
83 | }
84 | }
85 |
86 | if "required" in tool["input_schema"] and tool["input_schema"]["required"]:
87 | tool_spec["toolSpec"]["inputSchema"]["json"]["required"] = tool["input_schema"]["required"]
88 |
89 | results.append(tool_spec)
90 | return results
91 |
92 | def to_dict(self):
93 | return {
94 | "role": self.role,
95 | "content": self.content
96 | }
--------------------------------------------------------------------------------
/py-backend/app/libs/data/session_models.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 | from datetime import datetime, timedelta
3 | from enum import Enum
4 | from typing import Dict, Any, List, Optional
5 | import uuid
6 |
7 |
8 | class SessionState(Enum):
9 | """Session state enumeration"""
10 | ACTIVE = "active"
11 | EXPIRED = "expired"
12 | TERMINATED = "terminated"
13 |
14 |
15 | @dataclass
16 | class SessionData:
17 | """Session data model"""
18 | id: str
19 | created_at: datetime
20 | last_accessed: datetime
21 | expires_at: datetime
22 | state: SessionState = SessionState.ACTIVE
23 | metadata: Dict[str, Any] = field(default_factory=dict)
24 | resources: List[str] = field(default_factory=list)
25 |
26 | @classmethod
27 | def create_new(cls, session_id: Optional[str] = None, ttl_seconds: int = 3600) -> 'SessionData':
28 | """Create new session data"""
29 | now = datetime.utcnow()
30 | return cls(
31 | id=session_id or str(uuid.uuid4()),
32 | created_at=now,
33 | last_accessed=now,
34 | expires_at=now + timedelta(seconds=ttl_seconds)
35 | )
36 |
37 | def is_expired(self) -> bool:
38 | """Check if session is expired"""
39 | return datetime.utcnow() > self.expires_at or self.state == SessionState.EXPIRED
40 |
41 | def refresh(self, ttl_seconds: int = 3600) -> None:
42 | """Refresh session expiration"""
43 | now = datetime.utcnow()
44 | self.last_accessed = now
45 | self.expires_at = now + timedelta(seconds=ttl_seconds)
46 | if self.state == SessionState.EXPIRED:
47 | self.state = SessionState.ACTIVE
48 |
49 | def add_resource(self, resource_id: str) -> None:
50 | """Add resource to session"""
51 | if resource_id not in self.resources:
52 | self.resources.append(resource_id)
53 |
54 | def remove_resource(self, resource_id: str) -> None:
55 | """Remove resource from session"""
56 | if resource_id in self.resources:
57 | self.resources.remove(resource_id)
58 |
59 | def terminate(self) -> None:
60 | """Terminate session"""
61 | self.state = SessionState.TERMINATED
62 |
63 | def to_dict(self) -> Dict[str, Any]:
64 | """Convert to dictionary"""
65 | return {
66 | "id": self.id,
67 | "created_at": self.created_at.isoformat(),
68 | "last_accessed": self.last_accessed.isoformat(),
69 | "expires_at": self.expires_at.isoformat(),
70 | "state": self.state.value,
71 | "metadata": self.metadata,
72 | "resources": self.resources
73 | }
74 |
75 | @classmethod
76 | def from_dict(cls, data: Dict[str, Any]) -> 'SessionData':
77 | """Restore from dictionary"""
78 | return cls(
79 | id=data["id"],
80 | created_at=datetime.fromisoformat(data["created_at"]),
81 | last_accessed=datetime.fromisoformat(data["last_accessed"]),
82 | expires_at=datetime.fromisoformat(data["expires_at"]),
83 | state=SessionState(data["state"]),
84 | metadata=data.get("metadata", {}),
85 | resources=data.get("resources", [])
86 | )
--------------------------------------------------------------------------------
/py-backend/app/libs/data/session_store.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import asyncio
4 | from abc import ABC, abstractmethod
5 | from typing import Dict, List, Optional, Set
6 | from datetime import datetime
7 | import logging
8 |
9 | from .session_models import SessionData, SessionState
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | class SessionStore(ABC):
15 |
16 | @abstractmethod
17 | async def get(self, session_id: str) -> Optional[SessionData]:
18 | pass
19 |
20 | @abstractmethod
21 | async def set(self, session_data: SessionData) -> bool:
22 | pass
23 |
24 | @abstractmethod
25 | async def delete(self, session_id: str) -> bool:
26 | pass
27 |
28 | @abstractmethod
29 | async def list_active_sessions(self) -> List[str]:
30 | pass
31 |
32 | @abstractmethod
33 | async def cleanup_expired(self) -> int:
34 | pass
35 |
36 |
37 | class MemorySessionStore(SessionStore):
38 |
39 | def __init__(self):
40 | self._sessions: Dict[str, SessionData] = {}
41 | self._lock = asyncio.Lock()
42 |
43 | async def get(self, session_id: str) -> Optional[SessionData]:
44 | async with self._lock:
45 | session = self._sessions.get(session_id)
46 | if session and not session.is_expired():
47 | return session
48 | elif session and session.is_expired():
49 | del self._sessions[session_id]
50 | return None
51 |
52 | async def set(self, session_data: SessionData) -> bool:
53 | async with self._lock:
54 | self._sessions[session_data.id] = session_data
55 | return True
56 |
57 | async def delete(self, session_id: str) -> bool:
58 | async with self._lock:
59 | if session_id in self._sessions:
60 | del self._sessions[session_id]
61 | return True
62 | return False
63 |
64 | async def list_active_sessions(self) -> List[str]:
65 | async with self._lock:
66 | return [
67 | session_id for session_id, session in self._sessions.items()
68 | if not session.is_expired()
69 | ]
70 |
71 | async def cleanup_expired(self) -> int:
72 | async with self._lock:
73 | expired_sessions = [
74 | session_id for session_id, session in self._sessions.items()
75 | if session.is_expired()
76 | ]
77 |
78 | for session_id in expired_sessions:
79 | del self._sessions[session_id]
80 |
81 | logger.info(f"Cleaned up {len(expired_sessions)} expired sessions")
82 | return len(expired_sessions)
83 |
84 |
85 | class FileSessionStore(SessionStore):
86 |
87 | def __init__(self, storage_dir: str = "sessions"):
88 | self.storage_dir = storage_dir
89 | self._lock = asyncio.Lock()
90 |
91 | os.makedirs(storage_dir, exist_ok=True)
92 |
93 | def _get_session_file_path(self, session_id: str) -> str:
94 | return os.path.join(self.storage_dir, f"{session_id}.json")
95 |
96 | async def get(self, session_id: str) -> Optional[SessionData]:
97 | async with self._lock:
98 | file_path = self._get_session_file_path(session_id)
99 |
100 | if not os.path.exists(file_path):
101 | return None
102 |
103 | try:
104 | with open(file_path, 'r', encoding='utf-8') as f:
105 | data = json.load(f)
106 |
107 | session = SessionData.from_dict(data)
108 |
109 | if session.is_expired():
110 | await self.delete(session_id)
111 | return None
112 |
113 | return session
114 |
115 | except (json.JSONDecodeError, KeyError, ValueError) as e:
116 | logger.error(f"Error loading session {session_id}: {e}")
117 | await self.delete(session_id)
118 | return None
119 |
120 | async def set(self, session_data: SessionData) -> bool:
121 | async with self._lock:
122 | file_path = self._get_session_file_path(session_data.id)
123 |
124 | try:
125 | with open(file_path, 'w', encoding='utf-8') as f:
126 | json.dump(session_data.to_dict(), f, indent=2)
127 | return True
128 |
129 | except (OSError, json.JSONEncodeError) as e:
130 | logger.error(f"Error saving session {session_data.id}: {e}")
131 | return False
132 |
133 | async def delete(self, session_id: str) -> bool:
134 | async with self._lock:
135 | file_path = self._get_session_file_path(session_id)
136 |
137 | try:
138 | if os.path.exists(file_path):
139 | os.remove(file_path)
140 | return True
141 | return False
142 |
143 | except OSError as e:
144 | logger.error(f"Error deleting session {session_id}: {e}")
145 | return False
146 |
147 | async def list_active_sessions(self) -> List[str]:
148 | async with self._lock:
149 | active_sessions = []
150 |
151 | if not os.path.exists(self.storage_dir):
152 | return active_sessions
153 |
154 | for filename in os.listdir(self.storage_dir):
155 | if filename.endswith('.json'):
156 | session_id = filename[:-5]
157 | session = await self.get(session_id)
158 | if session and not session.is_expired():
159 | active_sessions.append(session_id)
160 |
161 | return active_sessions
162 |
163 | async def cleanup_expired(self) -> int:
164 | async with self._lock:
165 | cleanup_count = 0
166 |
167 | if not os.path.exists(self.storage_dir):
168 | return cleanup_count
169 |
170 | for filename in os.listdir(self.storage_dir):
171 | if filename.endswith('.json'):
172 | session_id = filename[:-5]
173 | file_path = self._get_session_file_path(session_id)
174 |
175 | try:
176 | with open(file_path, 'r', encoding='utf-8') as f:
177 | data = json.load(f)
178 |
179 | session = SessionData.from_dict(data)
180 |
181 | if session.is_expired():
182 | os.remove(file_path)
183 | cleanup_count += 1
184 |
185 | except (json.JSONDecodeError, KeyError, ValueError, OSError):
186 | try:
187 | os.remove(file_path)
188 | cleanup_count += 1
189 | except OSError:
190 | pass
191 |
192 | logger.info(f"Cleaned up {cleanup_count} expired session files")
193 | return cleanup_count
194 |
--------------------------------------------------------------------------------
/py-backend/app/libs/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/browser-control-with-nova-act/752ac679d1ba3fefaf3a11ce11cc50620145d610/py-backend/app/libs/utils/__init__.py
--------------------------------------------------------------------------------
/py-backend/app/libs/utils/decorators.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import functools
3 | import asyncio
4 | import time
5 | from typing import Dict, Callable, Any, Optional, Type, Union
6 | from app.libs.utils.thought_stream import thought_handler
7 |
8 | logger = logging.getLogger(__name__)
9 |
10 | def with_thought_callback(category: str, node_name: Optional[str] = None):
11 | def decorator(func: Callable):
12 | func_node_name = node_name or func.__name__
13 | is_async = asyncio.iscoroutinefunction(func)
14 |
15 | if is_async:
16 | @functools.wraps(func)
17 | async def async_wrapper(*args, **kwargs):
18 | session_id = kwargs.get('session_id', 'global')
19 |
20 | _send_thought(
21 | session_id=session_id,
22 | type_name="process",
23 | category=category,
24 | node=func_node_name,
25 | content=f"Processing in {func_node_name}"
26 | )
27 |
28 | try:
29 | result = await func(*args, **kwargs)
30 | return result
31 | except Exception as e:
32 | _send_thought(
33 | session_id=session_id,
34 | type_name="error",
35 | category="error",
36 | node=func_node_name,
37 | content=f"Error in {func_node_name}: {str(e)}",
38 | technical_details={"error": str(e)}
39 | )
40 | raise
41 |
42 | return async_wrapper
43 | else:
44 | @functools.wraps(func)
45 | def sync_wrapper(*args, **kwargs):
46 | session_id = kwargs.get('session_id', 'global')
47 |
48 | _send_thought(
49 | session_id=session_id,
50 | type_name="process",
51 | category=category,
52 | node=func_node_name,
53 | content=f"Processing in {func_node_name}"
54 | )
55 |
56 | try:
57 | result = func(*args, **kwargs)
58 | return result
59 | except Exception as e:
60 | _send_thought(
61 | session_id=session_id,
62 | type_name="error",
63 | category="error",
64 | node=func_node_name,
65 | content=f"Error in {func_node_name}: {str(e)}",
66 | technical_details={"error": str(e)}
67 | )
68 | raise
69 |
70 | return sync_wrapper
71 |
72 | return decorator
73 |
74 | def _send_thought(session_id: Optional[str], type_name: str, category: str, node: str,
75 | content: Union[str, Dict[str, Any]], **kwargs) -> None:
76 | if not session_id:
77 | return
78 |
79 | thought_cb = thought_handler.get_callback(session_id)
80 | if thought_cb:
81 | thought = {
82 | "type": type_name,
83 | "category": category,
84 | "node": node,
85 | "content": content
86 | }
87 | thought.update(kwargs)
88 | thought_cb(thought)
89 |
90 | # Task start event
91 | if node == "Supervisor" and type_name == "processing" and category == "status":
92 | task_status_event = {
93 | "type": "task_status",
94 | "status": "start",
95 | "session_id": session_id
96 | }
97 | thought_cb(task_status_event)
98 |
99 | # Task completion event
100 | is_final_answer = (
101 | (node == "Answer" and category == "result") or
102 | kwargs.get("final_answer") == True
103 | )
104 |
105 | if is_final_answer:
106 | task_status_event = {
107 | "type": "task_status",
108 | "status": "complete",
109 | "session_id": session_id,
110 | "final_answer": True
111 | }
112 | thought_cb(task_status_event)
113 |
114 |
115 |
116 | def log_thought(session_id: Optional[str], type_name: str, category: str, node: str,
117 | content: Union[str, Dict[str, Any]], **kwargs) -> None:
118 | _send_thought(session_id, type_name, category, node, content, **kwargs)
119 |
--------------------------------------------------------------------------------
/py-backend/app/libs/utils/error_handler.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import traceback
3 | from typing import Dict, Any, Optional
4 | from app.libs.utils.decorators import log_thought
5 |
6 | logger = logging.getLogger("error_handler")
7 |
8 | class ErrorHandler:
9 | """Centralized error handling for the application.
10 |
11 | This class provides consistent error logging and response formatting
12 | to avoid code duplication across the codebase.
13 | """
14 |
15 | @staticmethod
16 | def log_error(e: Exception, context: str, session_id: Optional[str] = None) -> Dict[str, Any]:
17 | """Log an error with consistent formatting and return a standard error response."""
18 | error_message = f"Error in {context}: {str(e)}"
19 | logger.error(error_message)
20 | logger.error(traceback.format_exc())
21 |
22 | if session_id:
23 | log_thought(
24 | session_id=session_id,
25 | type_name="error",
26 | category="error",
27 | node="System",
28 | content=error_message
29 | )
30 |
31 | return {
32 | "status": "error",
33 | "error": str(e),
34 | "context": context
35 | }
36 |
37 | @staticmethod
38 | def format_user_error(error_dict: Dict[str, Any]) -> Dict[str, Any]:
39 | """Format an error response suitable for end users with appropriate messaging."""
40 | user_friendly_message = "An error occurred while processing your request."
41 |
42 | context = error_dict.get("context", "")
43 | if "browser" in context.lower():
44 | user_friendly_message = "There was an issue with the browser operation. Please try again."
45 | elif "connect" in context.lower():
46 | user_friendly_message = "Could not connect to the required service. Please check your connection."
47 | elif "timeout" in str(error_dict.get("error", "")).lower():
48 | user_friendly_message = "The operation timed out. Please try again or try with a simpler request."
49 |
50 | return {
51 | "status": "error",
52 | "message": user_friendly_message,
53 | "technical_details": error_dict.get("error", "Unknown error")
54 | }
55 |
56 | @staticmethod
57 | def handle_conversation_error(e: Exception, session_id: str, conversation_store=None) -> Dict[str, Any]:
58 | """Handle errors in conversation flow and update conversation history if available."""
59 | error_dict = ErrorHandler.log_error(e, "conversation processing", session_id)
60 |
61 | # Try to update conversation history if store is available
62 | if conversation_store:
63 | try:
64 | error_response = {
65 | "role": "assistant",
66 | "content": [{"text": f"I'm sorry, an error occurred: {str(e)}"}]
67 | }
68 | conversation_store.save_message(session_id, error_response)
69 | except Exception as save_error:
70 | logger.error(f"Failed to save error to conversation: {save_error}")
71 |
72 | return ErrorHandler.format_user_error(error_dict)
73 |
74 | @staticmethod
75 | def handle_browser_error(e: Exception, session_id: Optional[str] = None) -> Dict[str, Any]:
76 | """Handle browser-specific errors with appropriate error information."""
77 | error_dict = ErrorHandler.log_error(e, "browser operation", session_id)
78 |
79 | # Add screenshot if possible
80 | screenshot_data = None
81 | try:
82 | from app.libs.browser_utils import BrowserUtils
83 | from app.libs.agent_manager_instance import get_agent_manager
84 |
85 | agent_manager = get_agent_manager()
86 | if session_id in agent_manager._browser_managers:
87 | browser_manager = agent_manager._browser_managers[session_id]
88 | if browser_manager.browser_initialized and browser_manager.session:
89 | # Attempt to capture screenshot of error state
90 | screenshot_data = BrowserUtils.capture_screenshot_sync(browser_manager)
91 | except Exception:
92 | pass
93 |
94 | error_response = ErrorHandler.format_user_error(error_dict)
95 |
96 | if screenshot_data:
97 | error_response["screenshot"] = screenshot_data
98 |
99 | return error_response
100 |
101 | # Singleton instance for easy import
102 | error_handler = ErrorHandler()
--------------------------------------------------------------------------------
/py-backend/app/libs/utils/profile_manager.py:
--------------------------------------------------------------------------------
1 | import os
2 | import shutil
3 | import tempfile
4 | import logging
5 | from pathlib import Path
6 | from typing import Dict, Optional
7 |
8 | logger = logging.getLogger("profile_manager")
9 |
10 | class ProfileManager:
11 | """Manages browser profile directories for session isolation"""
12 |
13 | def __init__(self):
14 | self.session_profiles: Dict[str, str] = {}
15 | self.temp_dir = Path(tempfile.gettempdir()) / "nova_browser_sessions"
16 | self.temp_dir.mkdir(exist_ok=True)
17 |
18 | def get_profile_for_session(self, session_id: str, base_profile_dir: str, clone_enabled: bool = True) -> str:
19 | """
20 | Get profile directory for a session
21 |
22 | Args:
23 | session_id: Unique session identifier
24 | base_profile_dir: Base profile directory to clone from
25 | clone_enabled: If True, clone to temporary directory. If False, use base directly
26 |
27 | Returns:
28 | Path to profile directory for this session
29 | """
30 | if not clone_enabled:
31 | logger.info(f"Session {session_id}: Using base profile directly (no cloning)")
32 | return base_profile_dir
33 |
34 | if session_id in self.session_profiles:
35 | existing_profile = self.session_profiles[session_id]
36 | if os.path.exists(existing_profile):
37 | logger.info(f"Session {session_id}: Reusing existing cloned profile: {existing_profile}")
38 | return existing_profile
39 | else:
40 | logger.warning(f"Session {session_id}: Cached profile path no longer exists, creating new one")
41 |
42 | # Create session-specific temporary profile directory
43 | session_profile_dir = self.temp_dir / f"session_{session_id}"
44 |
45 | try:
46 | # Remove existing directory if it exists
47 | if session_profile_dir.exists():
48 | shutil.rmtree(session_profile_dir, ignore_errors=True)
49 |
50 | # Clone base profile if it exists and has content
51 | if os.path.exists(base_profile_dir) and os.listdir(base_profile_dir):
52 | logger.info(f"Session {session_id}: Cloning base profile from {base_profile_dir}")
53 | shutil.copytree(base_profile_dir, session_profile_dir)
54 | logger.info(f"Session {session_id}: Profile cloned to {session_profile_dir}")
55 | else:
56 | # Create empty profile directory
57 | session_profile_dir.mkdir(parents=True, exist_ok=True)
58 | logger.info(f"Session {session_id}: Created empty profile directory: {session_profile_dir}")
59 |
60 | # Cache the profile path
61 | self.session_profiles[session_id] = str(session_profile_dir)
62 | return str(session_profile_dir)
63 |
64 | except Exception as e:
65 | logger.error(f"Session {session_id}: Failed to create profile directory: {e}")
66 | # Fallback to a basic temp directory
67 | fallback_dir = self.temp_dir / f"fallback_{session_id}"
68 | fallback_dir.mkdir(parents=True, exist_ok=True)
69 | self.session_profiles[session_id] = str(fallback_dir)
70 | return str(fallback_dir)
71 |
72 | def cleanup_session_profile(self, session_id: str) -> bool:
73 | """
74 | Clean up temporary profile directory for a session
75 |
76 | Args:
77 | session_id: Session identifier
78 |
79 | Returns:
80 | True if cleanup successful, False otherwise
81 | """
82 | if session_id not in self.session_profiles:
83 | logger.debug(f"Session {session_id}: No profile to cleanup")
84 | return True
85 |
86 | profile_path = self.session_profiles[session_id]
87 |
88 | try:
89 | if os.path.exists(profile_path):
90 | shutil.rmtree(profile_path, ignore_errors=True)
91 | logger.info(f"Session {session_id}: Cleaned up profile directory: {profile_path}")
92 |
93 | del self.session_profiles[session_id]
94 | return True
95 |
96 | except Exception as e:
97 | logger.error(f"Session {session_id}: Failed to cleanup profile directory {profile_path}: {e}")
98 | return False
99 |
100 | def cleanup_all_profiles(self):
101 | """Clean up all temporary profile directories"""
102 | logger.info("Cleaning up all session profiles...")
103 |
104 | for session_id in list(self.session_profiles.keys()):
105 | self.cleanup_session_profile(session_id)
106 |
107 | # Clean up the main temp directory if empty
108 | try:
109 | if self.temp_dir.exists() and not any(self.temp_dir.iterdir()):
110 | self.temp_dir.rmdir()
111 | logger.info("Removed empty temporary profiles directory")
112 | except Exception as e:
113 | logger.warning(f"Failed to remove temporary profiles directory: {e}")
114 |
115 | def get_active_sessions(self) -> list:
116 | """Get list of active session IDs with profiles"""
117 | return list(self.session_profiles.keys())
118 |
119 | # Global instance
120 | profile_manager = ProfileManager()
--------------------------------------------------------------------------------
/py-backend/app/libs/utils/thought_stream.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 | import logging
4 | import time
5 | from queue import Queue
6 | from threading import Event
7 | from typing import Dict, Any, Callable, AsyncIterator, Optional
8 |
9 | logger = logging.getLogger("thought_stream")
10 |
11 | class ThoughtHandler:
12 | def __init__(self):
13 | self.queues = {}
14 | self.events = {}
15 | self.callbacks = {}
16 |
17 | def register_session(self, session_id: str) -> Queue:
18 | """Register a new session for thought streaming"""
19 | logger.info(f"Registering thought stream session: {session_id}")
20 | if session_id in self.queues:
21 | return self.queues[session_id]
22 |
23 | self.queues[session_id] = Queue()
24 | self.events[session_id] = Event()
25 | return self.queues[session_id]
26 |
27 | def is_connected(self, session_id: str) -> bool:
28 | """Check if session is connected and ready"""
29 | return (session_id in self.queues and
30 | session_id in self.events and
31 | session_id in self.callbacks)
32 |
33 | def unregister_session(self, session_id: str):
34 | """Remove a session and clean up resources"""
35 | if session_id in self.queues:
36 | del self.queues[session_id]
37 | if session_id in self.events:
38 | del self.events[session_id]
39 | if session_id in self.callbacks:
40 | del self.callbacks[session_id]
41 |
42 | def get_callback(self, session_id: str) -> Callable[[Dict[str, Any]], None]:
43 | """Get or create a callback for the given session"""
44 | if session_id not in self.callbacks:
45 | def _callback(thought: Dict[str, Any]) -> None:
46 | thought_type = thought.get('type', 'unknown')
47 |
48 | # Log thought details (shortened for clarity)
49 | content = thought.get('content', {})
50 | content_summary = str(content)[:100] + "..." if len(str(content)) > 100 else str(content)
51 | logger.info(f"Received thought for session {session_id}: Type={thought_type}, Content={content_summary}")
52 |
53 | # Add thought to the queue for streaming
54 | if session_id in self.queues:
55 | self.queues[session_id].put(thought)
56 | else:
57 | logger.warning(f"Attempted to add thought to non-existent session: {session_id}")
58 |
59 | self.callbacks[session_id] = _callback
60 |
61 | return self.callbacks[session_id]
62 |
63 | def add_thought(self, session_id: str, thought: Dict[str, Any]):
64 | """Add a thought to a session's queue"""
65 | if session_id in self.queues:
66 | logger.debug(f"Adding thought to queue for session {session_id}")
67 | self.queues[session_id].put(thought)
68 | else:
69 | logger.warning(f"Attempted to add thought to non-existent session: {session_id}")
70 |
71 | def add_special_callback(self, session_id: str, event_data: Dict[str, Any]):
72 | """Add a special event like task_status to the queue"""
73 | self.add_thought(session_id, event_data)
74 |
75 | def mark_session_complete(self, session_id: str):
76 | """Mark a session as completed"""
77 | if session_id in self.events:
78 | logger.debug(f"Marking session complete: {session_id}")
79 | self.events[session_id].set()
80 | else:
81 | logger.warning(f"Attempted to mark non-existent session as complete: {session_id}")
82 |
83 | def is_session_complete(self, session_id: str) -> bool:
84 | """Check if a session is marked as complete"""
85 | return session_id in self.events and self.events[session_id].is_set()
86 |
87 | async def stream_generator(self, session_id: str) -> AsyncIterator[str]:
88 | """Generate an SSE stream for the given session"""
89 | logger.info(f"Setting up SSE stream generator for session: {session_id}")
90 |
91 | # Register session if not already registered
92 | if session_id not in self.queues:
93 | self.register_session(session_id)
94 |
95 | queue = self.queues[session_id]
96 |
97 | def format_sse(data: dict) -> str:
98 | return f"data: {json.dumps(data)}\n\n"
99 |
100 | # Send initial connection message
101 | yield format_sse({"type": "connected", "message": "Thought process stream connected"})
102 | await asyncio.sleep(0.01)
103 |
104 | # Send any cached thoughts
105 | thought_count = 0
106 | while not queue.empty():
107 | try:
108 | thought = queue.get_nowait()
109 | thought_count += 1
110 | if "id" not in thought:
111 | thought["id"] = f"{session_id}-thought-{thought_count}"
112 | yield format_sse(thought)
113 | await asyncio.sleep(0.01)
114 | except:
115 | break
116 |
117 | # Stream new thoughts as they arrive
118 | ping_count = 0
119 | while not self.is_session_complete(session_id) or not queue.empty():
120 | try:
121 | if not queue.empty():
122 | thought = queue.get_nowait()
123 | thought_count += 1
124 |
125 | if "id" not in thought:
126 | thought["id"] = f"{session_id}-thought-{thought_count}"
127 |
128 | logger.info(f"Streaming thought #{thought_count} for session {session_id}: {thought.get('type', 'unknown')}")
129 | yield format_sse(thought)
130 | await asyncio.sleep(0.01)
131 | else:
132 | ping_count += 1
133 | if ping_count >= 10:
134 | ping_count = 0
135 | yield format_sse({"type": "ping", "timestamp": f"{time.time()}"})
136 |
137 | await asyncio.sleep(1.0)
138 | except Exception as e:
139 | logger.error(f"Error in thought stream for session {session_id}: {e}")
140 | yield format_sse({"type": "error", "message": str(e)})
141 | await asyncio.sleep(0.5)
142 |
143 | # Send completion message
144 | yield format_sse({"type": "complete", "message": "Thought process complete"})
145 |
146 | # Clean up resources
147 | self.unregister_session(session_id)
148 |
149 | # Create a singleton instance
150 | thought_handler = ThoughtHandler()
151 |
--------------------------------------------------------------------------------
/py-backend/app/libs/utils/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 | import random
4 | import string
5 | import logging
6 | from typing import Dict, Optional, Tuple
7 |
8 | logger = logging.getLogger("utils")
9 |
10 | class PathManager:
11 | _instance = None
12 | _initialized = False
13 |
14 | def __new__(cls):
15 | if cls._instance is None:
16 | cls._instance = super(PathManager, cls).__new__(cls)
17 | return cls._instance
18 |
19 | def __init__(self):
20 | if not self._initialized:
21 | self._initialized = True
22 | # Current file: /py-backend/app/libs/utils/utils.py
23 | # Need to go up to /py-backend/app/
24 | self.current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
25 | self.project_root = self.current_dir # This is /py-backend/app/
26 | self.act_agent_dir = os.path.join(self.project_root, "act_agent")
27 | self.server_path = os.path.join(self.project_root, "act_agent/server/nova-act-server/nova_act_server.py")
28 |
29 | logger.info(f"PathManager initialized: project_root={self.project_root}")
30 |
31 | def get_paths(self) -> Dict[str, str]:
32 | return {
33 | "current_dir": self.current_dir,
34 | "project_root": self.project_root,
35 | "act_agent_dir": self.act_agent_dir,
36 | "server_path": self.server_path
37 | }
38 |
39 | def get_or_create_session_id(session_id: Optional[str] = None, prefix: str = "session") -> str:
40 | if session_id:
41 | return session_id
42 |
43 | random_suffix = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10))
44 | new_session_id = f"{prefix}-{int(time.time())}-{random_suffix}"
45 | logger.info(f"Created new session ID: {new_session_id}")
46 | return new_session_id
47 |
48 | def register_session_and_thought_handler(session_id: str) -> str:
49 | from app.libs.utils.thought_stream import thought_handler
50 | thought_handler.register_session(session_id)
51 | logger.info(f"Registered session in thought handler: {session_id}")
52 | return session_id
53 |
54 | def setup_paths():
55 | import sys
56 | path_manager = PathManager()
57 | paths = path_manager.get_paths()
58 |
59 | for path_name in ["project_root", "act_agent_dir"]:
60 | path = paths.get(path_name)
61 | if path and path not in sys.path:
62 | sys.path.insert(0, path)
63 | logger.debug(f"Added {path_name} to sys.path: {path}")
64 |
65 | return paths
66 |
--------------------------------------------------------------------------------
/py-backend/mcp_server_config.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "default-mcp-1",
4 | "name": "Nova Act",
5 | "hostname": "localhost:8001",
6 | "isActive": true,
7 | "isConnected": true
8 | }
9 | ]
--------------------------------------------------------------------------------
/py-backend/requirements.txt:
--------------------------------------------------------------------------------
1 | boto3
2 | mcp
3 | asyncio
4 | pydantic
5 | typing-extensions
6 | fastmcp>=2.5.2
7 | fastapi
8 | uvicorn
9 | fastapi-cors
10 | setuptools
11 | nova-act
12 | playwright
13 | python-dotenv
14 | psutil
--------------------------------------------------------------------------------
/py-backend/test_concurrent_browsers.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import asyncio
4 | import json
5 | import time
6 | from contextlib import AsyncExitStack
7 | from mcp import ClientSession
8 | from mcp.client.streamable_http import streamablehttp_client
9 |
10 | async def test_single_browser(session_id: str, url: str = "https://www.google.com"):
11 | """Test single browser initialization"""
12 |
13 | try:
14 | async with AsyncExitStack() as exit_stack:
15 | # Connect to Nova Act server with unique session ID
16 | headers = {"X-Session-ID": session_id}
17 |
18 | print(f"[{session_id}] Connecting to Nova Act server...")
19 | transport = await exit_stack.enter_async_context(
20 | streamablehttp_client("http://localhost:8001/mcp/", headers=headers)
21 | )
22 | read_stream, write_stream, _ = transport
23 | session = await exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
24 | await session.initialize()
25 |
26 | print(f"[{session_id}] Connected successfully!")
27 |
28 | # Test browser initialization
29 | start_time = time.time()
30 | result = await session.call_tool("initialize_browser", {
31 | "headless": True,
32 | "url": url
33 | })
34 | end_time = time.time()
35 |
36 | # Parse response
37 | try:
38 | response_data = json.loads(result.content[0].text)
39 | status = response_data.get('status')
40 | message = response_data.get('message')
41 |
42 | print(f"[{session_id}] Status: {status} (took {end_time - start_time:.2f}s)")
43 | print(f"[{session_id}] Message: {message}")
44 |
45 | if status == "success":
46 | # Take a screenshot to verify browser is working
47 | screenshot_result = await session.call_tool("take_screenshot", {})
48 | screenshot_data = json.loads(screenshot_result.content[0].text)
49 | print(f"[{session_id}] Screenshot status: {screenshot_data.get('status')}")
50 |
51 | return status == "success"
52 |
53 | except json.JSONDecodeError:
54 | print(f"[{session_id}] Raw response: {result.content[0].text}")
55 | return False
56 |
57 | except Exception as e:
58 | print(f"[{session_id}] ERROR: {e}")
59 | import traceback
60 | traceback.print_exc()
61 | return False
62 |
63 | async def test_concurrent_browsers(num_sessions: int = 3):
64 | """Test multiple browser sessions concurrently"""
65 |
66 | print(f"=== Testing {num_sessions} concurrent browser sessions ===\n")
67 |
68 | # Create concurrent tasks
69 | tasks = []
70 | session_urls = [
71 | ("session-1", "https://www.google.com"),
72 | ("session-2", "https://www.amazon.com"),
73 | ("session-3", "https://www.github.com"),
74 | ("session-4", "https://www.stackoverflow.com"),
75 | ("session-5", "https://www.reddit.com"),
76 | ]
77 |
78 | for i in range(min(num_sessions, len(session_urls))):
79 | session_id, url = session_urls[i]
80 | task = asyncio.create_task(test_single_browser(session_id, url))
81 | tasks.append(task)
82 |
83 | # Wait for all tasks to complete
84 | start_time = time.time()
85 | results = await asyncio.gather(*tasks, return_exceptions=True)
86 | end_time = time.time()
87 |
88 | # Analyze results
89 | successful = sum(1 for result in results if result is True)
90 | failed = len(results) - successful
91 |
92 | print(f"\n=== Results ===")
93 | print(f"Total time: {end_time - start_time:.2f}s")
94 | print(f"Successful: {successful}/{len(results)}")
95 | print(f"Failed: {failed}/{len(results)}")
96 |
97 | if failed > 0:
98 | print(f"Success rate: {successful/len(results)*100:.1f}%")
99 | print("❌ Some sessions failed - there's a concurrency issue!")
100 | else:
101 | print("✅ All sessions succeeded!")
102 |
103 | return successful, failed
104 |
105 | async def test_sequential_vs_concurrent():
106 | """Compare sequential vs concurrent execution"""
107 |
108 | print("=== Sequential Test (should always work) ===")
109 | sequential_start = time.time()
110 | seq_results = []
111 | for i in range(3):
112 | result = await test_single_browser(f"seq-session-{i+1}", "https://www.google.com")
113 | seq_results.append(result)
114 | sequential_time = time.time() - sequential_start
115 | seq_success = sum(seq_results)
116 |
117 | print(f"Sequential: {seq_success}/3 successful in {sequential_time:.2f}s")
118 |
119 | print(f"\n{'='*50}")
120 |
121 | print("=== Concurrent Test (may have issues) ===")
122 | concurrent_start = time.time()
123 | con_success, con_failed = await test_concurrent_browsers(3)
124 | concurrent_time = time.time() - concurrent_start
125 |
126 | print(f"Concurrent: {con_success}/3 successful in {concurrent_time:.2f}s")
127 |
128 | if con_failed > 0 and seq_success == 3:
129 | print(f"\n🚨 CONCURRENCY ISSUE DETECTED!")
130 | print(f"Sequential works fine, but concurrent execution fails")
131 | print(f"This confirms there's a resource conflict between sessions")
132 |
133 | if __name__ == "__main__":
134 | print("Testing Nova Act concurrent browser initialization...")
135 | print("Make sure Nova Act server is running on localhost:8001\n")
136 |
137 | asyncio.run(test_sequential_vs_concurrent())
--------------------------------------------------------------------------------
/py-backend/test_nova_act.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import asyncio
4 | import json
5 | from contextlib import AsyncExitStack
6 | from mcp import ClientSession
7 | from mcp.client.streamable_http import streamablehttp_client
8 |
9 | async def test_browser_initialization():
10 | """Test Nova Act browser initialization directly"""
11 |
12 | async with AsyncExitStack() as exit_stack:
13 | # Connect to Nova Act server with unique session ID
14 | import uuid
15 | session_id = f"test-session-{uuid.uuid4().hex[:8]}"
16 | headers = {"X-Session-ID": session_id}
17 | print(f"Using session ID: {session_id}")
18 |
19 | print("Connecting to Nova Act server...")
20 | transport = await exit_stack.enter_async_context(
21 | streamablehttp_client("http://localhost:8001/mcp/", headers=headers)
22 | )
23 | read_stream, write_stream, _ = transport
24 | session = await exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
25 | await session.initialize()
26 |
27 | print("Connected! Available tools:")
28 | response = await session.list_tools()
29 | for tool in response.tools:
30 | print(f" - {tool.name}")
31 |
32 | print("\nTesting browser initialization...")
33 | try:
34 | # Test browser initialization
35 | result = await session.call_tool("initialize_browser", {
36 | "headless": True,
37 | "url": "https://www.google.com"
38 | })
39 |
40 | print("Raw result:")
41 | print(json.dumps(result.content[0].text, indent=2))
42 |
43 | # Parse response
44 | try:
45 | response_data = json.loads(result.content[0].text)
46 | print(f"\nStatus: {response_data.get('status')}")
47 | print(f"Message: {response_data.get('message')}")
48 | except json.JSONDecodeError:
49 | print(f"Response: {result.content[0].text}")
50 |
51 | except Exception as e:
52 | print(f"Error during browser initialization: {e}")
53 | import traceback
54 | traceback.print_exc()
55 |
56 | if __name__ == "__main__":
57 | asyncio.run(test_browser_initialization())
--------------------------------------------------------------------------------
/services/eventService.ts:
--------------------------------------------------------------------------------
1 | // Define all event types
2 | export type EventType =
3 | | 'visualization-ready'
4 | | 'thought-completion'
5 | | 'thought-stream-complete'
6 | | 'task-status-update'
7 | | 'user-message-added';
8 |
9 | // Define event detail types
10 | export interface VisualizationEventDetail {
11 | chartData: any;
12 | chartTitle?: string;
13 | }
14 |
15 | export interface ThoughtCompletionEventDetail {
16 | type: 'answer' | 'result';
17 | content: string;
18 | sessionId: string;
19 | technical_details?: Record;
20 | }
21 |
22 | export interface ThoughtStreamCompleteDetail {
23 | sessionId: string;
24 | finalAnswer?: string;
25 | }
26 |
27 | export interface TaskStatusEventDetail {
28 | status: 'start' | 'complete' | 'stopped';
29 | sessionId: string;
30 | final_answer?: boolean;
31 | }
32 |
33 | export interface UserMessageEventDetail {
34 | type: 'question';
35 | content: string;
36 | node: 'User';
37 | category: 'user_input';
38 | timestamp: string;
39 | sessionId: string;
40 | fileUpload?: any;
41 | }
42 |
43 | // Type-safe event dispatch
44 | export const dispatchEvent = {
45 | visualizationReady: (detail: VisualizationEventDetail) => {
46 | const event = new CustomEvent('visualization-ready', { detail });
47 | window.dispatchEvent(event);
48 | return event;
49 | },
50 |
51 | thoughtCompletion: (detail: ThoughtCompletionEventDetail) => {
52 | const event = new CustomEvent('thought-completion', { detail });
53 | window.dispatchEvent(event);
54 | return event;
55 | },
56 |
57 | thoughtStreamComplete: (detail: ThoughtStreamCompleteDetail) => {
58 | const event = new CustomEvent('thought-stream-complete', { detail });
59 | window.dispatchEvent(event);
60 | return event;
61 | },
62 |
63 | taskStatusUpdate: (detail: TaskStatusEventDetail) => {
64 | const event = new CustomEvent('task-status-update', { detail });
65 | window.dispatchEvent(event);
66 | return event;
67 | },
68 |
69 | userMessageAdded: (detail: UserMessageEventDetail) => {
70 | const event = new CustomEvent('user-message-added', { detail });
71 | window.dispatchEvent(event);
72 | return event;
73 | }
74 | };
75 |
76 | // Type-safe event subscription
77 | export const subscribeToEvent = {
78 | visualizationReady: (handler: (detail: VisualizationEventDetail) => void) => {
79 | const eventHandler = ((e: CustomEvent) => handler(e.detail)) as EventListener;
80 | window.addEventListener('visualization-ready', eventHandler);
81 | return () => window.removeEventListener('visualization-ready', eventHandler);
82 | },
83 |
84 | thoughtCompletion: (handler: (detail: ThoughtCompletionEventDetail) => void) => {
85 | const eventHandler = ((e: CustomEvent) => handler(e.detail)) as EventListener;
86 | window.addEventListener('thought-completion', eventHandler);
87 | return () => window.removeEventListener('thought-completion', eventHandler);
88 | },
89 |
90 | thoughtStreamComplete: (handler: (detail: ThoughtStreamCompleteDetail) => void) => {
91 | const eventHandler = ((e: CustomEvent) => handler(e.detail)) as EventListener;
92 | window.addEventListener('thought-stream-complete', eventHandler);
93 | return () => window.removeEventListener('thought-stream-complete', eventHandler);
94 | },
95 |
96 | taskStatusUpdate: (handler: (detail: TaskStatusEventDetail) => void) => {
97 | const eventHandler = ((e: CustomEvent) => handler(e.detail)) as EventListener;
98 | window.addEventListener('task-status-update', eventHandler);
99 | return () => window.removeEventListener('task-status-update', eventHandler);
100 | },
101 |
102 | userMessageAdded: (handler: (detail: UserMessageEventDetail) => void) => {
103 | const eventHandler = ((e: CustomEvent) => handler(e.detail)) as EventListener;
104 | window.addEventListener('user-message-added', eventHandler);
105 | return () => window.removeEventListener('user-message-added', eventHandler);
106 | }
107 | };
108 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
9 | ],
10 | theme: {
11 | fontFamily: {
12 | sans: ['var(--font-inter)'],
13 | mono: ['var(--font-geist-mono)'],
14 | },
15 | extend: {
16 | colors: {
17 | background: 'hsl(var(--background))',
18 | foreground: 'hsl(var(--foreground))',
19 | card: {
20 | DEFAULT: 'hsl(var(--card))',
21 | foreground: 'hsl(var(--card-foreground))'
22 | },
23 | popover: {
24 | DEFAULT: 'hsl(var(--popover))',
25 | foreground: 'hsl(var(--popover-foreground))'
26 | },
27 | primary: {
28 | DEFAULT: 'hsl(var(--primary))',
29 | foreground: 'hsl(var(--primary-foreground))'
30 | },
31 | secondary: {
32 | DEFAULT: 'hsl(var(--secondary))',
33 | foreground: 'hsl(var(--secondary-foreground))'
34 | },
35 | muted: {
36 | DEFAULT: 'hsl(var(--muted))',
37 | foreground: 'hsl(var(--muted-foreground))'
38 | },
39 | accent: {
40 | DEFAULT: 'hsl(var(--accent))',
41 | foreground: 'hsl(var(--accent-foreground))'
42 | },
43 | destructive: {
44 | DEFAULT: 'hsl(var(--destructive))',
45 | foreground: 'hsl(var(--destructive-foreground))'
46 | },
47 | border: 'hsl(var(--border))',
48 | input: 'hsl(var(--input))',
49 | ring: 'hsl(var(--ring))',
50 | chart: {
51 | '1': 'hsl(var(--chart-1))',
52 | '2': 'hsl(var(--chart-2))',
53 | '3': 'hsl(var(--chart-3))',
54 | '4': 'hsl(var(--chart-4))',
55 | '5': 'hsl(var(--chart-5))'
56 | }
57 | },
58 | borderRadius: {
59 | lg: 'var(--radius)',
60 | md: 'calc(var(--radius) - 2px)',
61 | sm: 'calc(var(--radius) - 4px)'
62 | }
63 | }
64 | },
65 | plugins: [require("tailwindcss-animate")],
66 | };
67 | export default config;
68 |
--------------------------------------------------------------------------------
/test.py:
--------------------------------------------------------------------------------
1 | from nova_act import NovaAct
2 |
3 |
4 | def test_simple_action():
5 | """Simple test to check browser start and stop functionality."""
6 | # Initialize NovaAct client
7 | nova = NovaAct(starting_page="https://www.google.com")
8 |
9 | try:
10 | # Start the browser
11 | print("Starting browser...")
12 | nova.start()
13 | print("Browser started successfully!")
14 |
15 | # Perform one simple action
16 | print("Performing search action...")
17 | nova.act("search for python")
18 | print("Action completed!")
19 |
20 | except Exception as e:
21 | print(f"Error occurred: {e}")
22 |
23 | finally:
24 | # Stop the browser
25 | print("Stopping browser...")
26 | nova.stop()
27 | print("Browser stopped successfully!")
28 |
29 |
30 | if __name__ == "__main__":
31 | test_simple_action()
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [
4 | "dom",
5 | "dom.iterable",
6 | "esnext"
7 | ],
8 | "allowJs": true,
9 | "skipLibCheck": true,
10 | // "strict": true,
11 | "noImplicitAny": false,
12 | "strictNullChecks": false,
13 | "strictFunctionTypes": true,
14 | "strictBindCallApply": true,
15 | "noEmit": true,
16 | "esModuleInterop": true,
17 | "module": "esnext",
18 | "moduleResolution": "bundler",
19 | "resolveJsonModule": true,
20 | "isolatedModules": true,
21 | "jsx": "preserve",
22 | "incremental": true,
23 | "plugins": [
24 | {
25 | "name": "next"
26 | }
27 | ],
28 | "paths": {
29 | "@/*": [
30 | "./*"
31 | ]
32 | },
33 | "strict": false,
34 | "target": "ES2017"
35 | },
36 | "include": [
37 | "next-env.d.ts",
38 | "**/*.ts",
39 | "**/*.tsx",
40 | ".next/types/**/*.ts"
41 | ],
42 | "exclude": [
43 | "node_modules"
44 | ]
45 | }
46 |
--------------------------------------------------------------------------------
/types/chart.ts:
--------------------------------------------------------------------------------
1 | // types/chart.ts
2 | export interface ChartConfig {
3 | [key: string]: {
4 | label: string;
5 | stacked?: boolean;
6 | color?: string;
7 | };
8 | }
9 |
10 | export interface ChartData {
11 | chartType: "bar" | "multiBar" | "line" | "pie" | "area" | "stackedArea";
12 | config: {
13 | title: string;
14 | description: string;
15 | trend?: {
16 | percentage: number;
17 | direction: "up" | "down";
18 | };
19 | footer?: string;
20 | totalLabel?: string;
21 | xAxisKey?: string;
22 | };
23 | data: Array>;
24 | chartConfig: ChartConfig;
25 | }
26 |
--------------------------------------------------------------------------------
/types/chat.ts:
--------------------------------------------------------------------------------
1 | // types/chat.ts
2 |
3 | import { ChartData } from './chart';
4 |
5 | export interface ContentBlock {
6 | text?: string;
7 | toolUse?: ToolUse;
8 | toolResult?: ToolResult;
9 | json?: string;
10 | }
11 |
12 | export interface ToolUse {
13 | toolUseId: string;
14 | name: string;
15 | input: {
16 | [key: string]: any;
17 | };
18 | }
19 |
20 | export interface ToolResult {
21 | toolUseId: string;
22 | content: [{text?: string; json?: string;}];
23 | status?: 'success' | 'error';
24 | }
25 | export interface Visualization {
26 | chartType: string;
27 | chartData: any;
28 | chartTitle: string;
29 | }
30 |
31 | export interface Message {
32 | id: string;
33 | role: string;
34 | content: Array<{ text: string } | { image?: any } | { toolUse?: any } | { toolResult?: any }>;
35 | file?: FileUpload;
36 | visualization?: Visualization;
37 | timestamp?: string;
38 | technical_details?: {
39 | processing_time_sec?: number;
40 | processing_time_ms?: number;
41 | [key: string]: any;
42 | };
43 | }
44 |
45 |
46 | export interface FileUpload {
47 | base64: string;
48 | fileName: string;
49 | mediaType: string;
50 | isText?: boolean;
51 | fileSize?: number;
52 | }
53 |
54 | export type Model = {
55 | id: string;
56 | name: string;
57 | };
58 |
59 | export interface APIResponse {
60 | content: string;
61 | hasToolUse: boolean;
62 | toolUse?: {
63 | type: "tool_use";
64 | id: string;
65 | name: string;
66 | input: ChartData;
67 | };
68 | chartData?: ChartData;
69 | }
70 |
71 | export interface AnalyzeAPIResponse {
72 | query?: string;
73 | explanation?: string;
74 | result?: any[];
75 | content: string;
76 | toolUseId?: string;
77 | toolName?: string;
78 | stopReason?: string;
79 | originalQuestion: string;
80 | }
--------------------------------------------------------------------------------
/utils/api.ts:
--------------------------------------------------------------------------------
1 | import { createLogger } from './logger';
2 |
3 | const logger = createLogger('API');
4 |
5 | export const API_BASE_URL = 'http://localhost:8000/api';
6 |
7 | export async function apiRequest(path: string, options = {}) {
8 | const url = `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
9 | const response = await fetch(url, options);
10 | return response;
11 | }
--------------------------------------------------------------------------------
/utils/apiClient.ts:
--------------------------------------------------------------------------------
1 | import { CONFIG } from '../config';
2 | import { createLogger } from './logger';
3 |
4 | const logger = createLogger('ApiClient');
5 |
6 | class ApiClient {
7 | private baseUrl: string;
8 | private defaultTimeout: number;
9 | private maxRetries: number;
10 |
11 | constructor() {
12 | this.baseUrl = CONFIG.API.BASE_URL;
13 | this.defaultTimeout = CONFIG.API.TIMEOUT;
14 | this.maxRetries = CONFIG.API.MAX_RETRIES;
15 |
16 | logger.info('API Client initialized', {
17 | baseUrl: this.baseUrl,
18 | timeout: this.defaultTimeout,
19 | maxRetries: this.maxRetries
20 | });
21 | }
22 |
23 | async request(path: string, options: RequestInit = {}, retries = this.maxRetries): Promise {
24 | const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
25 |
26 | try {
27 | const controller = new AbortController();
28 | const timeoutId = setTimeout(() => controller.abort('Request timeout'), this.defaultTimeout);
29 |
30 | // Add default headers if not provided
31 | const headers = {
32 | 'Content-Type': 'application/json',
33 | ...options.headers,
34 | };
35 |
36 | const response = await fetch(url, {
37 | ...options,
38 | headers,
39 | signal: controller.signal
40 | });
41 |
42 | clearTimeout(timeoutId);
43 | return response;
44 | } catch (error) {
45 | if (retries > 0 && (error instanceof Error && error.name !== 'AbortError')) {
46 | logger.warn('Retrying request', { path, retriesLeft: retries, error: error.message });
47 | await new Promise(resolve => setTimeout(resolve, 1000));
48 | return this.request(path, options, retries - 1);
49 | }
50 | throw error;
51 | }
52 | }
53 | }
54 |
55 | export const apiClient = new ApiClient();
--------------------------------------------------------------------------------
/utils/errorHandler.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Frontend error handling utilities to work with standardized backend error responses
3 | */
4 |
5 | import { createLogger } from './logger';
6 |
7 | const logger = createLogger('ErrorHandler');
8 |
9 | export interface StandardErrorResponse {
10 | success: boolean;
11 | error_code: string;
12 | message: string;
13 | details?: string;
14 | severity: 'low' | 'medium' | 'high' | 'critical';
15 | session_id?: string;
16 | timestamp: string;
17 | retry_after?: number;
18 | }
19 |
20 | export interface ErrorDisplayOptions {
21 | showDetails?: boolean;
22 | showRetry?: boolean;
23 | showSupport?: boolean;
24 | }
25 |
26 | export class ErrorHandler {
27 | /**
28 | * Extract error information from various response types
29 | */
30 | static extractError(error: any): StandardErrorResponse | null {
31 | // Handle HTTP error responses
32 | if (error.response?.data) {
33 | return this.parseErrorResponse(error.response.data);
34 | }
35 |
36 | // Handle direct error objects
37 | if (error.detail) {
38 | return this.parseErrorResponse(error.detail);
39 | }
40 |
41 | // Handle standard Error objects
42 | if (error instanceof Error) {
43 | return this.createFallbackError(error.message);
44 | }
45 |
46 | // Handle string errors
47 | if (typeof error === 'string') {
48 | return this.createFallbackError(error);
49 | }
50 |
51 | return this.createFallbackError('An unknown error occurred');
52 | }
53 |
54 | /**
55 | * Parse standardized error response from backend
56 | */
57 | static parseErrorResponse(response: any): StandardErrorResponse {
58 | if (this.isStandardErrorResponse(response)) {
59 | return response as StandardErrorResponse;
60 | }
61 |
62 | // Handle legacy error formats
63 | if (response.error || response.message) {
64 | return this.createFallbackError(response.error || response.message);
65 | }
66 |
67 | return this.createFallbackError('Unknown error format');
68 | }
69 |
70 | /**
71 | * Check if response matches our standard error format
72 | */
73 | static isStandardErrorResponse(response: any): boolean {
74 | return (
75 | response &&
76 | typeof response === 'object' &&
77 | 'success' in response &&
78 | 'error_code' in response &&
79 | 'message' in response &&
80 | 'severity' in response
81 | );
82 | }
83 |
84 | /**
85 | * Create fallback error for non-standard error formats
86 | */
87 | static createFallbackError(message: string): StandardErrorResponse {
88 | return {
89 | success: false,
90 | error_code: 'UNKNOWN_ERROR',
91 | message: message || 'An unexpected error occurred',
92 | severity: 'medium',
93 | timestamp: new Date().toISOString()
94 | };
95 | }
96 |
97 | /**
98 | * Get user-friendly error message with appropriate formatting
99 | */
100 | static getDisplayMessage(error: StandardErrorResponse, options: ErrorDisplayOptions = {}): string {
101 | let message = error.message;
102 |
103 | // Add details if requested and available
104 | if (options.showDetails && error.details) {
105 | message += `\n\nDetails: ${error.details}`;
106 | }
107 |
108 | // Add retry information if available
109 | if (options.showRetry && error.retry_after) {
110 | message += `\n\nPlease try again in ${error.retry_after} seconds.`;
111 | }
112 |
113 | // Add support information for critical errors
114 | if (options.showSupport && error.severity === 'critical') {
115 | message += '\n\nIf this problem persists, please contact support.';
116 | }
117 |
118 | return message;
119 | }
120 |
121 | /**
122 | * Determine if error should be retryable based on error code
123 | */
124 | static isRetryable(error: StandardErrorResponse): boolean {
125 | const retryableErrors = [
126 | 'AGENT_TIMEOUT_ERROR',
127 | 'SERVER_CONNECTION_ERROR',
128 | 'SERVICE_UNAVAILABLE',
129 | 'BROWSER_CONNECTION_ERROR'
130 | ];
131 |
132 | return retryableErrors.includes(error.error_code);
133 | }
134 |
135 | /**
136 | * Get appropriate toast variant based on error severity
137 | */
138 | static getToastVariant(error: StandardErrorResponse): 'default' | 'destructive' {
139 | return error.severity === 'high' || error.severity === 'critical' ? 'destructive' : 'default';
140 | }
141 |
142 | /**
143 | * Log error to console with appropriate level
144 | */
145 | static logError(error: StandardErrorResponse, context?: string): void {
146 | const logContext = {
147 | error_code: error.error_code,
148 | severity: error.severity,
149 | session_id: error.session_id,
150 | timestamp: error.timestamp,
151 | context
152 | };
153 |
154 | switch (error.severity) {
155 | case 'critical':
156 | logger.error(error.message, logContext, new Error(error.details || error.message));
157 | break;
158 | case 'high':
159 | logger.error(error.message, logContext);
160 | break;
161 | case 'medium':
162 | logger.warn(error.message, logContext);
163 | break;
164 | case 'low':
165 | logger.info(error.message, logContext);
166 | break;
167 | }
168 | }
169 |
170 | /**
171 | * Handle error with consistent logging and user notification
172 | */
173 | static handleError(
174 | error: any,
175 | context: string,
176 | options: ErrorDisplayOptions & {
177 | showToast?: boolean;
178 | toast?: (args: any) => void;
179 | } = {}
180 | ): StandardErrorResponse {
181 | const standardError = this.extractError(error);
182 |
183 | if (!standardError) {
184 | const fallbackError = this.createFallbackError('Failed to process error');
185 | this.logError(fallbackError, context);
186 | return fallbackError;
187 | }
188 |
189 | // Log the error
190 | this.logError(standardError, context);
191 |
192 | // Show toast notification if requested
193 | if (options.showToast && options.toast) {
194 | options.toast({
195 | title: "Error",
196 | description: this.getDisplayMessage(standardError, options),
197 | variant: this.getToastVariant(standardError),
198 | });
199 | }
200 |
201 | return standardError;
202 | }
203 | }
204 |
205 | // Export convenience functions
206 | export const extractError = ErrorHandler.extractError.bind(ErrorHandler);
207 | export const handleError = ErrorHandler.handleError.bind(ErrorHandler);
208 | export const isRetryable = ErrorHandler.isRetryable.bind(ErrorHandler);
--------------------------------------------------------------------------------
/utils/fileHandling.ts:
--------------------------------------------------------------------------------
1 | export const readFileAsText = (file: File): Promise => {
2 | return new Promise((resolve, reject) => {
3 | const reader = new FileReader();
4 | reader.onload = () => {
5 | try {
6 | const result = reader.result;
7 | if (typeof result === "string" && result.length > 0) {
8 | resolve(result);
9 | } else {
10 | reject(new Error("Empty or invalid text file"));
11 | }
12 | } catch (e) {
13 | reject(e);
14 | }
15 | };
16 | reader.onerror = reject;
17 | reader.readAsText(file);
18 | });
19 | };
20 |
21 | export const readFileAsBase64 = (file: File): Promise => {
22 | return new Promise((resolve, reject) => {
23 | const reader = new FileReader();
24 | reader.onload = () => {
25 | try {
26 | const base64 = (reader.result as string).split(",")[1];
27 | resolve(base64);
28 | } catch (e) {
29 | reject(e);
30 | }
31 | };
32 | reader.onerror = reject;
33 | reader.readAsDataURL(file);
34 | });
35 | };
36 |
37 | export const readFileAsPDFText = async (file: File): Promise => {
38 | return new Promise((resolve, reject) => {
39 | const script = document.createElement("script");
40 | script.src = "//cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
41 |
42 | script.onload = async () => {
43 | try {
44 | // @ts-ignore - PDF.js adds this to window
45 | const pdfjsLib = window["pdfjs-dist/build/pdf"];
46 | pdfjsLib.GlobalWorkerOptions.workerSrc =
47 | "//cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
48 |
49 | const arrayBuffer = await file.arrayBuffer();
50 | const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
51 |
52 | let fullText = "";
53 |
54 | for (let i = 1; i <= pdf.numPages; i++) {
55 | const page = await pdf.getPage(i);
56 | const textContent = await page.getTextContent();
57 |
58 | let lastY: number | null = null;
59 | let text = "";
60 |
61 | for (const item of textContent.items) {
62 | if (lastY !== null && Math.abs(lastY - item.transform[5]) > 5) {
63 | text += "\n";
64 | } else if (lastY !== null && text.length > 0) {
65 | text += " ";
66 | }
67 |
68 | text += item.str;
69 | lastY = item.transform[5];
70 | }
71 |
72 | fullText += text + "\n\n";
73 | }
74 |
75 | document.body.removeChild(script);
76 | resolve(fullText.trim());
77 | } catch (error) {
78 | document.body.removeChild(script);
79 | reject(error);
80 | }
81 | };
82 |
83 | script.onerror = () => {
84 | document.body.removeChild(script);
85 | reject(new Error("Failed to load PDF.js library"));
86 | };
87 |
88 | document.body.appendChild(script);
89 | });
90 | };
91 |
92 | export interface FileUpload {
93 | base64: string;
94 | fileName: string;
95 | mediaType: string;
96 | isText?: boolean;
97 | fileSize?: number;
98 | }
99 |
--------------------------------------------------------------------------------
/utils/logger.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Frontend logging utility with structured logging support
3 | */
4 |
5 | export enum LogLevel {
6 | DEBUG = 0,
7 | INFO = 1,
8 | WARN = 2,
9 | ERROR = 3,
10 | }
11 |
12 | interface LogEntry {
13 | timestamp: string;
14 | level: string;
15 | message: string;
16 | context?: Record;
17 | error?: Error;
18 | }
19 |
20 | class Logger {
21 | private logLevel: LogLevel;
22 | private isDevelopment: boolean;
23 |
24 | constructor(logLevel: LogLevel = LogLevel.INFO) {
25 | this.logLevel = logLevel;
26 | this.isDevelopment = process.env.NODE_ENV === 'development';
27 | }
28 |
29 | private shouldLog(level: LogLevel): boolean {
30 | return level >= this.logLevel;
31 | }
32 |
33 | private formatLogEntry(level: LogLevel, message: string, context?: Record, error?: Error): LogEntry {
34 | return {
35 | timestamp: new Date().toISOString(),
36 | level: LogLevel[level],
37 | message,
38 | context,
39 | error,
40 | };
41 | }
42 |
43 | private writeLog(logEntry: LogEntry): void {
44 | if (!this.isDevelopment) {
45 | // In production, you could send logs to external service
46 | // For now, we'll still use console but with structured format
47 | }
48 |
49 | const { timestamp, level, message, context, error } = logEntry;
50 | const logMessage = `[${timestamp}] ${level}: ${message}`;
51 |
52 | switch (level) {
53 | case 'DEBUG':
54 | if (this.isDevelopment) {
55 | console.debug(logMessage, context || '', error || '');
56 | }
57 | break;
58 | case 'INFO':
59 | console.info(logMessage, context || '');
60 | break;
61 | case 'WARN':
62 | console.warn(logMessage, context || '');
63 | break;
64 | case 'ERROR':
65 | console.error(logMessage, context || '', error || '');
66 | break;
67 | }
68 | }
69 |
70 | debug(message: string, context?: Record): void {
71 | if (this.shouldLog(LogLevel.DEBUG)) {
72 | const logEntry = this.formatLogEntry(LogLevel.DEBUG, message, context);
73 | this.writeLog(logEntry);
74 | }
75 | }
76 |
77 | info(message: string, context?: Record): void {
78 | if (this.shouldLog(LogLevel.INFO)) {
79 | const logEntry = this.formatLogEntry(LogLevel.INFO, message, context);
80 | this.writeLog(logEntry);
81 | }
82 | }
83 |
84 | warn(message: string, context?: Record): void {
85 | if (this.shouldLog(LogLevel.WARN)) {
86 | const logEntry = this.formatLogEntry(LogLevel.WARN, message, context);
87 | this.writeLog(logEntry);
88 | }
89 | }
90 |
91 | error(message: string, context?: Record, error?: Error): void {
92 | if (this.shouldLog(LogLevel.ERROR)) {
93 | const logEntry = this.formatLogEntry(LogLevel.ERROR, message, context, error);
94 | this.writeLog(logEntry);
95 | }
96 | }
97 |
98 | setLogLevel(level: LogLevel): void {
99 | this.logLevel = level;
100 | }
101 | }
102 |
103 | // Create default logger instance
104 | export const logger = new Logger(
105 | process.env.NODE_ENV === 'development' ? LogLevel.DEBUG : LogLevel.INFO
106 | );
107 |
108 | // Create context-aware loggers for different modules
109 | export const createLogger = (module: string) => {
110 | return {
111 | debug: (message: string, context?: Record) =>
112 | logger.debug(`[${module}] ${message}`, context),
113 | info: (message: string, context?: Record) =>
114 | logger.info(`[${module}] ${message}`, context),
115 | warn: (message: string, context?: Record) =>
116 | logger.warn(`[${module}] ${message}`, context),
117 | error: (message: string, context?: Record, error?: Error) =>
118 | logger.error(`[${module}] ${message}`, context, error),
119 | };
120 | };
--------------------------------------------------------------------------------
/utils/messageUtils.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from "uuid";
2 | import type { Message, FileUpload } from '@/types/chat';
3 |
4 | export const prepareApiMessages = (messages: Message[], userMessage?: Message) => {
5 | const messagesToProcess = userMessage ? [...messages, userMessage] : messages;
6 |
7 | return messagesToProcess.map((msg) => {
8 | if (msg.file) {
9 | const hasText = (content: any): content is { text: string } => {
10 | return 'text' in content && typeof content.text === 'string';
11 | };
12 |
13 | const textContent = msg.content.find(hasText);
14 | const textValue = textContent ? textContent.text : '';
15 |
16 | if (msg.file.isText) {
17 | const decodedText = decodeURIComponent(atob(msg.file.base64));
18 | return {
19 | role: msg.role,
20 | content: [{ text: `File contents of ${msg.file.fileName}:\n\n${decodedText}\n\n${textValue}` }]
21 | };
22 | } else {
23 | return {
24 | role: msg.role,
25 | content: [{
26 | image: {
27 | format: msg.file.mediaType.split('/')[1],
28 | source: {
29 | bytes: msg.file.base64
30 | }
31 | }
32 | },
33 | { text: textValue }
34 | ]};
35 | }
36 | }
37 | return {
38 | role: msg.role,
39 | content: msg.content
40 | };
41 | });
42 | };
43 |
44 | export const createUserMessage = (text: string, file?: FileUpload, timestamp?: string): Message => ({
45 | id: uuidv4(),
46 | role: "user",
47 | content: [{ text }],
48 | file: file || undefined,
49 | timestamp: timestamp || new Date().toISOString()
50 | });
51 |
52 | export const createAssistantTextMessage = (text: string, timestamp?: string): Message => ({
53 | id: uuidv4(),
54 | role: "assistant",
55 | content: [{ text }],
56 | timestamp: timestamp || new Date().toISOString()
57 | });
58 |
59 | export const createSqlToolUseMessage = (toolUseId: string, query: string, explanation: string): Message => ({
60 | id: uuidv4(),
61 | role: "assistant",
62 | content: [
63 | {
64 | toolUse: {
65 | toolUseId,
66 | name: "generate_sql_query",
67 | input: {
68 | query,
69 | explanation
70 | }
71 | }
72 | }
73 | ]
74 | });
75 |
76 | export const createToolResultMessage = (toolUseId: string, result: any): Message => ({
77 | id: uuidv4(),
78 | role: "user",
79 | content: [
80 | {
81 | toolResult: {
82 | toolUseId,
83 | content: [
84 | {
85 | text: `Visualize this data: ${JSON.stringify(result)}`
86 | }
87 | ]
88 | }
89 | }
90 | ]
91 | });
92 |
93 | export const createErrorMessage = (errorText: string = "I apologize, but I encountered an error. Please try again.", timestamp?: string): Message => ({
94 | id: uuidv4(),
95 | role: "assistant",
96 | content: [{ text: errorText }],
97 | timestamp: timestamp || new Date().toISOString()
98 | });
99 |
100 | export const createVisualizationMessage = (text: string, visualization: any, timestamp?: string): Message => {
101 | const displayText = text || "Here's the visualization based on the data.";
102 |
103 | return {
104 | id: uuidv4(),
105 | role: "assistant",
106 | content: [{ text: displayText }],
107 | visualization: {
108 | chartType: visualization.chart_type || visualization.chart_data?.chartType || "bar",
109 | chartTitle: visualization.chart_title || visualization.chart_data?.config?.title || "Chart",
110 | chartData: visualization.chart_data
111 | },
112 | timestamp: timestamp || new Date().toISOString()
113 | };
114 | };
--------------------------------------------------------------------------------
/utils/streamUtils.ts:
--------------------------------------------------------------------------------
1 | export class EventStreamManager {
2 | private eventSource: EventSource | null = null;
3 | private url: string;
4 | private onMessage: (data: any) => void;
5 | private onError: (error: Error) => void;
6 | private onConnect: () => void;
7 | private maxRetries: number = 3;
8 | private retryCount: number = 0;
9 | private retryDelay: number = 1000;
10 |
11 | constructor(
12 | url: string,
13 | onMessage: (data: any) => void,
14 | onError: (error: Error) => void,
15 | onConnect: () => void
16 | ) {
17 | this.url = url;
18 | this.onMessage = onMessage;
19 | this.onError = onError;
20 | this.onConnect = onConnect;
21 | }
22 |
23 | connect() {
24 | this.close();
25 |
26 | try {
27 | this.eventSource = new EventSource(this.url);
28 |
29 | this.eventSource.onopen = () => {
30 | this.retryCount = 0;
31 | this.onConnect();
32 | };
33 |
34 | this.eventSource.onmessage = (event) => {
35 | try {
36 | const data = JSON.parse(event.data);
37 | this.onMessage(data);
38 | } catch (err) {
39 | console.error("Error parsing event data:", err);
40 | }
41 | };
42 |
43 | this.eventSource.onerror = (error) => {
44 | if (this.eventSource?.readyState === EventSource.CLOSED) {
45 | if (this.retryCount < this.maxRetries) {
46 | this.retryCount++;
47 | const delay = this.retryDelay * Math.pow(1.5, this.retryCount);
48 | setTimeout(() => this.connect(), delay);
49 | } else {
50 | this.onError(new Error("Failed to connect after maximum retries"));
51 | }
52 | }
53 | };
54 | } catch (error) {
55 | this.onError(error as Error);
56 | }
57 | }
58 |
59 | close() {
60 | if (this.eventSource) {
61 | this.eventSource.close();
62 | this.eventSource = null;
63 | }
64 | }
65 | }
--------------------------------------------------------------------------------
/utils/thoughtProcessingUtils.ts:
--------------------------------------------------------------------------------
1 | interface Thought {
2 | type: string;
3 | content: string;
4 | timestamp?: string;
5 | node?: string;
6 | id: string;
7 | category?: 'setup' | 'analysis' | 'tool' | 'result' | 'error' | 'visualization_data' | 'screenshot' | 'user_input' | 'user_control';
8 | technical_details?: Record;
9 | }
10 |
11 | /**
12 | * Calculate processing time for answer nodes
13 | */
14 | export function calculateProcessingTime(
15 | data: any,
16 | normalizedThought: Thought,
17 | thoughts: Thought[]
18 | ): void {
19 | if ((data.node === 'Answer' || data.category === 'result') && normalizedThought.timestamp) {
20 | // Find the last user question to calculate processing time
21 | const lastUserQuestion = [...thoughts].reverse().find(t => t.node === 'User' || t.type === 'question');
22 |
23 | // Only calculate if this is the first answer for this question (no existing processing time)
24 | const hasExistingAnswer = [...thoughts].reverse().find(t =>
25 | (t.node === 'Answer' || t.category === 'result') &&
26 | t.technical_details?.processing_time_sec &&
27 | lastUserQuestion?.timestamp &&
28 | t.timestamp &&
29 | new Date(t.timestamp).getTime() > new Date(lastUserQuestion.timestamp).getTime()
30 | );
31 |
32 | if (lastUserQuestion?.timestamp && !hasExistingAnswer) {
33 | try {
34 | const startTime = new Date(lastUserQuestion.timestamp).getTime();
35 | const endTime = new Date(normalizedThought.timestamp).getTime();
36 | const processingTimeMs = endTime - startTime;
37 |
38 | if (processingTimeMs > 0) {
39 | const processingTimeSec = (processingTimeMs / 1000).toFixed(2);
40 | normalizedThought.technical_details = {
41 | ...(normalizedThought.technical_details || {}),
42 | processing_time_ms: processingTimeMs,
43 | processing_time_sec: parseFloat(processingTimeSec)
44 | };
45 | }
46 | } catch (timeError) {
47 | console.error("Error calculating processing time:", timeError);
48 | }
49 | }
50 | }
51 | }
52 |
53 | /**
54 | * Generate unique ID for thoughts
55 | */
56 | export function generateId(prefix: string = 'thought'): string {
57 | return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
58 | }
59 |
60 | /**
61 | * Check if event should be filtered out
62 | */
63 | export function shouldFilterEvent(data: any): boolean {
64 | if (data.type === 'ping' || data.type === 'heartbeat') {
65 | return true;
66 | }
67 |
68 | const validTypes = ['thought', 'reasoning', 'tool_call', 'tool_result', 'question',
69 | 'visualization', 'thinking', 'rationale', 'error', 'answer', 'result', 'browser_status', 'others', 'user_control'];
70 | const validNodes = ['User', 'Browser', 'Agent', 'NovaAct', 'Answer', 'complete', 'Router', 'Others', 'User Control'];
71 |
72 | if (!validTypes.includes(data.type) &&
73 | !validNodes.includes(data.node) &&
74 | data.category !== 'screenshot' &&
75 | data.category !== 'visualization_data' &&
76 | data.category !== 'user_control') {
77 | return true;
78 | }
79 |
80 | return false;
81 | }
--------------------------------------------------------------------------------
/utils/timerUtils.ts:
--------------------------------------------------------------------------------
1 | import type { MutableRefObject } from 'react';
2 |
3 | /**
4 | * Reset thinking timer and state
5 | */
6 | export function resetThinkingTimer(
7 | setIsThinking: (value: boolean) => void,
8 | thinkingStartTime: MutableRefObject
9 | ): void {
10 | setIsThinking(false);
11 | thinkingStartTime.current = null;
12 | }
13 |
14 | /**
15 | * Calculate processing time from start time
16 | */
17 | export function calculateProcessingTime(thinkingStartTime: MutableRefObject): string | null {
18 | if (thinkingStartTime.current) {
19 | const processingTimeMs = Date.now() - thinkingStartTime.current;
20 | return (processingTimeMs / 1000).toFixed(2);
21 | }
22 | return null;
23 | }
--------------------------------------------------------------------------------