Compare commits

...

7 Commits

Author SHA1 Message Date
Swifty
bab436231a refactor(backend): remove Langfuse tracing from chat system (#11829)
We are removing Langfuse tracing from the chat/copilot system in favor
of using OpenRouter's broadcast feature, which keeps our codebase
simpler. Langfuse prompt management is retained for fetching system
prompts.

### Changes 🏗️

**Removed Langfuse tracing:**
- Removed `@observe` decorators from all 11 chat tool files
- Removed `langfuse.openai` wrapper (now using standard `openai` client)
- Removed `start_as_current_observation` and `propagate_attributes`
context managers from `service.py`
- Removed `update_current_trace()`, `update_current_span()`,
`span.update()` calls

**Retained Langfuse prompt management:**
- `langfuse.get_prompt()` for fetching system prompts
- `_is_langfuse_configured()` check for prompt availability
- Configuration for `langfuse_prompt_name`

**Files modified:**
- `backend/api/features/chat/service.py`
- `backend/api/features/chat/tools/*.py` (11 tool files)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified `poetry run format` passes
  - [x] Verified no `@observe` decorators remain in chat tools
- [x] Verified Langfuse prompt fetching is still functional (code
preserved)
2026-01-27 13:07:42 +01:00
Zamil Majdy
859f3f8c06 feat(frontend): implement clarification questions UI for agent generation (#11833)
## Summary
Add interactive UI to collect user answers when the agent-generator
service returns clarifying questions during agent creation/editing.

Previously, when the backend asked clarifying questions, the frontend
would just display them as text with no way for users to answer. This
caused the chat to keep retrying without the necessary context.

## Changes
- **ChatMessageData type**: Add `clarification_needed` variant with
questions field
- **ClarificationQuestionsWidget**: New component with interactive form
to collect answers
- **parseToolResponse**: Detect and parse `clarification_needed`
responses from backend
- **ChatMessage**: Render the widget when clarification is needed

## How It Works
1. User requests to create/edit agent
2. Backend returns `ClarificationNeededResponse` with list of questions
3. Frontend shows interactive form with text inputs for each question
4. User fills in answers and clicks "Submit Answers"
5. Answers are sent back as context to the tool
6. Backend receives full context and continues

## UI Features
- Shows all questions with examples (if provided)
- Input validation (all questions must be answered to submit)
- Visual feedback (checkmarks when answered)
- Numbered questions for clarity
- Submit button disabled until all answered
- Follows same design pattern as `credentials_needed` flow

## Related
- Backend support for clarification was added in #11819
- Fixes the issue shown in the screenshot where users couldn't answer
clarifying questions

## Test plan
- [ ] Test creating agent that requires clarifying questions
- [ ] Verify questions are displayed in interactive form
- [ ] Verify all questions must be answered before submitting
- [ ] Verify answers are sent back to backend as context
- [ ] Verify agent creation continues with full context
2026-01-27 09:22:30 +00:00
Swifty
d5c0f5b2df refactor(backend): remove page context from chat service (#11844)
### Background
The chat service previously supported including page context (URL and
content) in user messages. This functionality is being removed.

### Changes 🏗️

- Removed page context handling from `stream_chat_completion` in the
chat service
- User messages are now passed directly without URL/content context
injection
- Removed associated logging for page context

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verify chat functionality works without page context
  - [x] Confirm no regressions in basic chat message handling
2026-01-26 16:00:48 +00:00
Ubbe
fbc2da36e6 fix(analytics): only try to init Posthog when on cloud (#11843)
## Changes 🏗️

This prevents Posthog from being initialised locally, where we should
not be collecting analytics during local development.

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Run locally and test the above
2026-01-26 22:54:19 +07:00
Swifty
75ecc4de92 fix(backend): enforce block disabled flag on execution endpoints (#11839)
## Summary
This PR adds security checks to prevent execution of disabled blocks
across all block execution endpoints.

- Add `disabled` flag check to main web API endpoint
(`/api/blocks/{block_id}/execute`)
- Add `disabled` flag check to external API endpoint
(`/api/blocks/{block_id}/execute`)
- Add `disabled` flag check to chat tool block execution

Previously, block execution endpoints only checked if a block existed
but did not verify the `disabled` flag, allowing any authenticated user
to execute disabled blocks.

## Test plan
- [x] Verify disabled blocks return 403 Forbidden on main API endpoint
- [x] Verify disabled blocks return 403 Forbidden on external API
endpoint
- [x] Verify disabled blocks return error response in chat tool
execution
- [x] Verify enabled blocks continue to execute normally
2026-01-26 13:56:24 +00:00
Abhimanyu Yadav
f0c2503608 feat(frontend): support multiple node execution results and accumulated data display (#11834)
### Changes 🏗️

- Refactored node execution results storage to maintain a history of
executions instead of just the latest result
- Added support for viewing accumulated output data across multiple
executions
- Implemented a cleaner UI for viewing historical execution results with
proper grouping
- Added functionality to clear execution results when starting a new run
- Created helper functions to normalize and process execution data
consistently
- Updated the NodeDataViewer component to display both latest and
historical execution data
- Added ability to view input data alongside output data in the
execution history

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Create and run a flow with multiple blocks that produce output
- [x] Verify that execution results are properly accumulated and
displayed
- [x] Run the same flow multiple times and confirm historical data is
preserved
- [x] Test the "View more data" functionality to ensure it displays all
execution history
- [x] Verify that execution results are properly cleared when starting a
new run
2026-01-26 12:33:22 +00:00
Swifty
cfb7dc5aca feat(backend): Add PostHog analytics and OpenRouter tracing to chat system (#11828)
Adds analytics tracking to the chat copilot system for better
observability of user interactions and agent operations.

### Changes 🏗️

**PostHog Analytics Integration:**
- Added `posthog` dependency (v7.6.0) to track chat events
- Created new tracking module (`backend/api/features/chat/tracking.py`)
with events:
  - `chat_message_sent` - When a user sends a message
  - `chat_tool_called` - When a tool is called (includes tool name)
  - `chat_agent_run_success` - When an agent runs successfully
  - `chat_agent_scheduled` - When an agent is scheduled
  - `chat_trigger_setup` - When a trigger is set up
- Added PostHog configuration to settings:
  - `POSTHOG_API_KEY` - API key for PostHog
- `POSTHOG_HOST` - PostHog host URL (defaults to
`https://us.i.posthog.com`)

**OpenRouter Tracing:**
- Added `user` and `session_id` fields to chat completion API calls for
OpenRouter tracing
- Added `posthogDistinctId` and `posthogProperties` (with environment)
to API calls

**Files Changed:**
- `backend/api/features/chat/tracking.py` - New PostHog tracking module
- `backend/api/features/chat/service.py` - Added user message tracking
and OpenRouter tracing
- `backend/api/features/chat/tools/__init__.py` - Added tool call
tracking
- `backend/api/features/chat/tools/run_agent.py` - Added agent
run/schedule tracking
- `backend/util/settings.py` - Added PostHog configuration fields
- `pyproject.toml` - Added posthog dependency

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verified code passes linting and formatting
- [x] Verified PostHog client initializes correctly when API key is
provided
- [x] Verified tracking is gracefully skipped when PostHog is not
configured

#### For configuration changes:

- [ ] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

**New environment variables (optional):**
- `POSTHOG_API_KEY` - PostHog project API key
- `POSTHOG_HOST` - PostHog host URL (optional, defaults to US cloud)
2026-01-26 12:26:15 +00:00
45 changed files with 2008 additions and 668 deletions

View File

@@ -178,5 +178,10 @@ AYRSHARE_JWT_KEY=
SMARTLEAD_API_KEY= SMARTLEAD_API_KEY=
ZEROBOUNCE_API_KEY= ZEROBOUNCE_API_KEY=
# PostHog Analytics
# Get API key from https://posthog.com - Project Settings > Project API Key
POSTHOG_API_KEY=
POSTHOG_HOST=https://eu.i.posthog.com
# Other Services # Other Services
AUTOMOD_API_KEY= AUTOMOD_API_KEY=

View File

@@ -86,6 +86,8 @@ async def execute_graph_block(
obj = backend.data.block.get_block(block_id) obj = backend.data.block.get_block(block_id)
if not obj: if not obj:
raise HTTPException(status_code=404, detail=f"Block #{block_id} not found.") raise HTTPException(status_code=404, detail=f"Block #{block_id} not found.")
if obj.disabled:
raise HTTPException(status_code=403, detail=f"Block #{block_id} is disabled.")
output = defaultdict(list) output = defaultdict(list)
async for name, data in obj.execute(data): async for name, data in obj.execute(data):

View File

@@ -5,9 +5,9 @@ from asyncio import CancelledError
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any from typing import Any
import openai
import orjson import orjson
from langfuse import get_client, propagate_attributes from langfuse import get_client
from langfuse.openai import openai # type: ignore
from openai import ( from openai import (
APIConnectionError, APIConnectionError,
APIError, APIError,
@@ -48,6 +48,7 @@ from .response_model import (
StreamUsage, StreamUsage,
) )
from .tools import execute_tool, tools from .tools import execute_tool, tools
from .tracking import track_user_message
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -103,16 +104,33 @@ async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
return compiled, understanding return compiled, understanding
async def _generate_session_title(message: str) -> str | None: async def _generate_session_title(
message: str,
user_id: str | None = None,
session_id: str | None = None,
) -> str | None:
"""Generate a concise title for a chat session based on the first message. """Generate a concise title for a chat session based on the first message.
Args: Args:
message: The first user message in the session message: The first user message in the session
user_id: User ID for OpenRouter tracing (optional)
session_id: Session ID for OpenRouter tracing (optional)
Returns: Returns:
A short title (3-6 words) or None if generation fails A short title (3-6 words) or None if generation fails
""" """
try: try:
# Build extra_body for OpenRouter tracing and PostHog analytics
extra_body: dict[str, Any] = {}
if user_id:
extra_body["user"] = user_id[:128] # OpenRouter limit
extra_body["posthogDistinctId"] = user_id
if session_id:
extra_body["session_id"] = session_id[:128] # OpenRouter limit
extra_body["posthogProperties"] = {
"environment": settings.config.app_env.value,
}
response = await client.chat.completions.create( response = await client.chat.completions.create(
model=config.title_model, model=config.title_model,
messages=[ messages=[
@@ -127,6 +145,7 @@ async def _generate_session_title(message: str) -> str | None:
{"role": "user", "content": message[:500]}, # Limit input length {"role": "user", "content": message[:500]}, # Limit input length
], ],
max_tokens=20, max_tokens=20,
extra_body=extra_body,
) )
title = response.choices[0].message.content title = response.choices[0].message.content
if title: if title:
@@ -218,18 +237,9 @@ async def stream_chat_completion(
) )
if message: if message:
# Build message content with context if provided
message_content = message
if context and context.get("url") and context.get("content"):
context_text = f"Page URL: {context['url']}\n\nPage Content:\n{context['content']}\n\n---\n\nUser Message: {message}"
message_content = context_text
logger.info(
f"Including page context: URL={context['url']}, content_length={len(context['content'])}"
)
session.messages.append( session.messages.append(
ChatMessage( ChatMessage(
role="user" if is_user_message else "assistant", content=message_content role="user" if is_user_message else "assistant", content=message
) )
) )
logger.info( logger.info(
@@ -237,6 +247,14 @@ async def stream_chat_completion(
f"new message_count={len(session.messages)}" f"new message_count={len(session.messages)}"
) )
# Track user message in PostHog
if is_user_message:
track_user_message(
user_id=user_id,
session_id=session_id,
message_length=len(message),
)
logger.info( logger.info(
f"Upserting session: {session.session_id} with user id {session.user_id}, " f"Upserting session: {session.session_id} with user id {session.user_id}, "
f"message_count={len(session.messages)}" f"message_count={len(session.messages)}"
@@ -256,10 +274,15 @@ async def stream_chat_completion(
# stale data issues when the main flow modifies the session # stale data issues when the main flow modifies the session
captured_session_id = session_id captured_session_id = session_id
captured_message = message captured_message = message
captured_user_id = user_id
async def _update_title(): async def _update_title():
try: try:
title = await _generate_session_title(captured_message) title = await _generate_session_title(
captured_message,
user_id=captured_user_id,
session_id=captured_session_id,
)
if title: if title:
# Use dedicated title update function that doesn't # Use dedicated title update function that doesn't
# touch messages, avoiding race conditions # touch messages, avoiding race conditions
@@ -276,347 +299,302 @@ async def stream_chat_completion(
# Build system prompt with business understanding # Build system prompt with business understanding
system_prompt, understanding = await _build_system_prompt(user_id) system_prompt, understanding = await _build_system_prompt(user_id)
# Create Langfuse trace for this LLM call (each call gets its own trace, grouped by session_id) # Initialize variables for streaming
# Using v3 SDK: start_observation creates a root span, update_trace sets trace-level attributes assistant_response = ChatMessage(
input = message role="assistant",
if not message and tool_call_response: content="",
input = tool_call_response )
accumulated_tool_calls: list[dict[str, Any]] = []
has_saved_assistant_message = False
has_appended_streaming_message = False
last_cache_time = 0.0
last_cache_content_len = 0
langfuse = get_client() has_yielded_end = False
with langfuse.start_as_current_observation( has_yielded_error = False
as_type="span", has_done_tool_call = False
name="user-copilot-request", has_received_text = False
input=input, text_streaming_ended = False
) as span: tool_response_messages: list[ChatMessage] = []
with propagate_attributes( should_retry = False
session_id=session_id,
user_id=user_id, # Generate unique IDs for AI SDK protocol
tags=["copilot"], import uuid as uuid_module
metadata={
"users_information": format_understanding_for_prompt(understanding)[ message_id = str(uuid_module.uuid4())
:200 text_block_id = str(uuid_module.uuid4())
] # langfuse only accepts upto to 200 chars
}, # Yield message start
yield StreamStart(messageId=message_id)
try:
async for chunk in _stream_chat_chunks(
session=session,
tools=tools,
system_prompt=system_prompt,
text_block_id=text_block_id,
): ):
# Initialize variables that will be used in finally block (must be defined before try) if isinstance(chunk, StreamTextStart):
assistant_response = ChatMessage( # Emit text-start before first text delta
role="assistant", if not has_received_text:
content="", yield chunk
) elif isinstance(chunk, StreamTextDelta):
accumulated_tool_calls: list[dict[str, Any]] = [] delta = chunk.delta or ""
has_saved_assistant_message = False assert assistant_response.content is not None
has_appended_streaming_message = False assistant_response.content += delta
last_cache_time = 0.0 has_received_text = True
last_cache_content_len = 0 if not has_appended_streaming_message:
session.messages.append(assistant_response)
# Wrap main logic in try/finally to ensure Langfuse observations are always ended has_appended_streaming_message = True
has_yielded_end = False current_time = time.monotonic()
has_yielded_error = False content_len = len(assistant_response.content)
has_done_tool_call = False if (
has_received_text = False current_time - last_cache_time >= 1.0
text_streaming_ended = False and content_len > last_cache_content_len
tool_response_messages: list[ChatMessage] = []
should_retry = False
# Generate unique IDs for AI SDK protocol
import uuid as uuid_module
message_id = str(uuid_module.uuid4())
text_block_id = str(uuid_module.uuid4())
# Yield message start
yield StreamStart(messageId=message_id)
try:
async for chunk in _stream_chat_chunks(
session=session,
tools=tools,
system_prompt=system_prompt,
text_block_id=text_block_id,
): ):
if isinstance(chunk, StreamTextStart):
# Emit text-start before first text delta
if not has_received_text:
yield chunk
elif isinstance(chunk, StreamTextDelta):
delta = chunk.delta or ""
assert assistant_response.content is not None
assistant_response.content += delta
has_received_text = True
if not has_appended_streaming_message:
session.messages.append(assistant_response)
has_appended_streaming_message = True
current_time = time.monotonic()
content_len = len(assistant_response.content)
if (
current_time - last_cache_time >= 1.0
and content_len > last_cache_content_len
):
try:
await cache_chat_session(session)
except Exception as e:
logger.warning(
f"Failed to cache partial session {session.session_id}: {e}"
)
last_cache_time = current_time
last_cache_content_len = content_len
yield chunk
elif isinstance(chunk, StreamTextEnd):
# Emit text-end after text completes
if has_received_text and not text_streaming_ended:
text_streaming_ended = True
if assistant_response.content:
logger.warn(
f"StreamTextEnd: Attempting to set output {assistant_response.content}"
)
span.update_trace(output=assistant_response.content)
span.update(output=assistant_response.content)
yield chunk
elif isinstance(chunk, StreamToolInputStart):
# Emit text-end before first tool call, but only if we've received text
if has_received_text and not text_streaming_ended:
yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True
yield chunk
elif isinstance(chunk, StreamToolInputAvailable):
# Accumulate tool calls in OpenAI format
accumulated_tool_calls.append(
{
"id": chunk.toolCallId,
"type": "function",
"function": {
"name": chunk.toolName,
"arguments": orjson.dumps(chunk.input).decode(
"utf-8"
),
},
}
)
elif isinstance(chunk, StreamToolOutputAvailable):
result_content = (
chunk.output
if isinstance(chunk.output, str)
else orjson.dumps(chunk.output).decode("utf-8")
)
tool_response_messages.append(
ChatMessage(
role="tool",
content=result_content,
tool_call_id=chunk.toolCallId,
)
)
has_done_tool_call = True
# Track if any tool execution failed
if not chunk.success:
logger.warning(
f"Tool {chunk.toolName} (ID: {chunk.toolCallId}) execution failed"
)
yield chunk
elif isinstance(chunk, StreamFinish):
if not has_done_tool_call:
# Emit text-end before finish if we received text but haven't closed it
if has_received_text and not text_streaming_ended:
yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True
# Save assistant message before yielding finish to ensure it's persisted
# even if client disconnects immediately after receiving StreamFinish
if not has_saved_assistant_message:
messages_to_save_early: list[ChatMessage] = []
if accumulated_tool_calls:
assistant_response.tool_calls = (
accumulated_tool_calls
)
if not has_appended_streaming_message and (
assistant_response.content
or assistant_response.tool_calls
):
messages_to_save_early.append(assistant_response)
messages_to_save_early.extend(tool_response_messages)
if messages_to_save_early:
session.messages.extend(messages_to_save_early)
logger.info(
f"Saving assistant message before StreamFinish: "
f"content_len={len(assistant_response.content or '')}, "
f"tool_calls={len(assistant_response.tool_calls or [])}, "
f"tool_responses={len(tool_response_messages)}"
)
if (
messages_to_save_early
or has_appended_streaming_message
):
await upsert_chat_session(session)
has_saved_assistant_message = True
has_yielded_end = True
yield chunk
elif isinstance(chunk, StreamError):
has_yielded_error = True
yield chunk
elif isinstance(chunk, StreamUsage):
session.usage.append(
Usage(
prompt_tokens=chunk.promptTokens,
completion_tokens=chunk.completionTokens,
total_tokens=chunk.totalTokens,
)
)
else:
logger.error(
f"Unknown chunk type: {type(chunk)}", exc_info=True
)
if assistant_response.content:
langfuse.update_current_trace(output=assistant_response.content)
langfuse.update_current_span(output=assistant_response.content)
elif tool_response_messages:
langfuse.update_current_trace(output=str(tool_response_messages))
langfuse.update_current_span(output=str(tool_response_messages))
except CancelledError:
if not has_saved_assistant_message:
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
if assistant_response.content:
assistant_response.content = (
f"{assistant_response.content}\n\n[interrupted]"
)
else:
assistant_response.content = "[interrupted]"
if not has_appended_streaming_message:
session.messages.append(assistant_response)
if tool_response_messages:
session.messages.extend(tool_response_messages)
try: try:
await upsert_chat_session(session) await cache_chat_session(session)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Failed to save interrupted session {session.session_id}: {e}" f"Failed to cache partial session {session.session_id}: {e}"
) )
raise last_cache_time = current_time
except Exception as e: last_cache_content_len = content_len
logger.error(f"Error during stream: {e!s}", exc_info=True) yield chunk
elif isinstance(chunk, StreamTextEnd):
# Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.) # Emit text-end after text completes
is_retryable = isinstance( if has_received_text and not text_streaming_ended:
e, (orjson.JSONDecodeError, KeyError, TypeError) text_streaming_ended = True
)
if is_retryable and retry_count < config.max_retries:
logger.info(
f"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}"
)
should_retry = True
else:
# Non-retryable error or max retries exceeded
# Save any partial progress before reporting error
messages_to_save: list[ChatMessage] = []
# Add assistant message if it has content or tool calls
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
if not has_appended_streaming_message and (
assistant_response.content or assistant_response.tool_calls
):
messages_to_save.append(assistant_response)
# Add tool response messages after assistant message
messages_to_save.extend(tool_response_messages)
if not has_saved_assistant_message:
if messages_to_save:
session.messages.extend(messages_to_save)
if messages_to_save or has_appended_streaming_message:
await upsert_chat_session(session)
if not has_yielded_error:
error_message = str(e)
if not is_retryable:
error_message = f"Non-retryable error: {error_message}"
elif retry_count >= config.max_retries:
error_message = f"Max retries ({config.max_retries}) exceeded: {error_message}"
error_response = StreamError(errorText=error_message)
yield error_response
if not has_yielded_end:
yield StreamFinish()
return
# Handle retry outside of exception handler to avoid nesting
if should_retry and retry_count < config.max_retries:
logger.info(
f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}"
)
async for chunk in stream_chat_completion(
session_id=session.session_id,
user_id=user_id,
retry_count=retry_count + 1,
session=session,
context=context,
):
yield chunk yield chunk
return # Exit after retry to avoid double-saving in finally block elif isinstance(chunk, StreamToolInputStart):
# Emit text-end before first tool call, but only if we've received text
if has_received_text and not text_streaming_ended:
yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True
yield chunk
elif isinstance(chunk, StreamToolInputAvailable):
# Accumulate tool calls in OpenAI format
accumulated_tool_calls.append(
{
"id": chunk.toolCallId,
"type": "function",
"function": {
"name": chunk.toolName,
"arguments": orjson.dumps(chunk.input).decode("utf-8"),
},
}
)
yield chunk
elif isinstance(chunk, StreamToolOutputAvailable):
result_content = (
chunk.output
if isinstance(chunk.output, str)
else orjson.dumps(chunk.output).decode("utf-8")
)
tool_response_messages.append(
ChatMessage(
role="tool",
content=result_content,
tool_call_id=chunk.toolCallId,
)
)
has_done_tool_call = True
# Track if any tool execution failed
if not chunk.success:
logger.warning(
f"Tool {chunk.toolName} (ID: {chunk.toolCallId}) execution failed"
)
yield chunk
elif isinstance(chunk, StreamFinish):
if not has_done_tool_call:
# Emit text-end before finish if we received text but haven't closed it
if has_received_text and not text_streaming_ended:
yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True
# Save assistant message before yielding finish to ensure it's persisted
# even if client disconnects immediately after receiving StreamFinish
if not has_saved_assistant_message:
messages_to_save_early: list[ChatMessage] = []
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
if not has_appended_streaming_message and (
assistant_response.content or assistant_response.tool_calls
):
messages_to_save_early.append(assistant_response)
messages_to_save_early.extend(tool_response_messages)
if messages_to_save_early:
session.messages.extend(messages_to_save_early)
logger.info(
f"Saving assistant message before StreamFinish: "
f"content_len={len(assistant_response.content or '')}, "
f"tool_calls={len(assistant_response.tool_calls or [])}, "
f"tool_responses={len(tool_response_messages)}"
)
if messages_to_save_early or has_appended_streaming_message:
await upsert_chat_session(session)
has_saved_assistant_message = True
has_yielded_end = True
yield chunk
elif isinstance(chunk, StreamError):
has_yielded_error = True
yield chunk
elif isinstance(chunk, StreamUsage):
session.usage.append(
Usage(
prompt_tokens=chunk.promptTokens,
completion_tokens=chunk.completionTokens,
total_tokens=chunk.totalTokens,
)
)
else:
logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True)
except CancelledError:
if not has_saved_assistant_message:
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
if assistant_response.content:
assistant_response.content = (
f"{assistant_response.content}\n\n[interrupted]"
)
else:
assistant_response.content = "[interrupted]"
if not has_appended_streaming_message:
session.messages.append(assistant_response)
if tool_response_messages:
session.messages.extend(tool_response_messages)
try:
await upsert_chat_session(session)
except Exception as e:
logger.warning(
f"Failed to save interrupted session {session.session_id}: {e}"
)
raise
except Exception as e:
logger.error(f"Error during stream: {e!s}", exc_info=True)
# Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.)
is_retryable = isinstance(e, (orjson.JSONDecodeError, KeyError, TypeError))
if is_retryable and retry_count < config.max_retries:
logger.info(
f"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}"
)
should_retry = True
else:
# Non-retryable error or max retries exceeded
# Save any partial progress before reporting error
messages_to_save: list[ChatMessage] = []
# Add assistant message if it has content or tool calls
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
if not has_appended_streaming_message and (
assistant_response.content or assistant_response.tool_calls
):
messages_to_save.append(assistant_response)
# Add tool response messages after assistant message
messages_to_save.extend(tool_response_messages)
# Normal completion path - save session and handle tool call continuation
# Only save if we haven't already saved when StreamFinish was received
if not has_saved_assistant_message: if not has_saved_assistant_message:
logger.info(
f"Normal completion path: session={session.session_id}, "
f"current message_count={len(session.messages)}"
)
# Build the messages list in the correct order
messages_to_save: list[ChatMessage] = []
# Add assistant message with tool_calls if any
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
logger.info(
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
)
if not has_appended_streaming_message and (
assistant_response.content or assistant_response.tool_calls
):
messages_to_save.append(assistant_response)
logger.info(
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
)
# Add tool response messages after assistant message
messages_to_save.extend(tool_response_messages)
logger.info(
f"Saving {len(tool_response_messages)} tool response messages, "
f"total_to_save={len(messages_to_save)}"
)
if messages_to_save: if messages_to_save:
session.messages.extend(messages_to_save) session.messages.extend(messages_to_save)
logger.info(
f"Extended session messages, new message_count={len(session.messages)}"
)
if messages_to_save or has_appended_streaming_message: if messages_to_save or has_appended_streaming_message:
await upsert_chat_session(session) await upsert_chat_session(session)
else:
logger.info(
"Assistant message already saved when StreamFinish was received, "
"skipping duplicate save"
)
# If we did a tool call, stream the chat completion again to get the next response if not has_yielded_error:
if has_done_tool_call: error_message = str(e)
logger.info( if not is_retryable:
"Tool call executed, streaming chat completion again to get assistant response" error_message = f"Non-retryable error: {error_message}"
) elif retry_count >= config.max_retries:
async for chunk in stream_chat_completion( error_message = (
session_id=session.session_id, f"Max retries ({config.max_retries}) exceeded: {error_message}"
user_id=user_id, )
session=session, # Pass session object to avoid Redis refetch
context=context, error_response = StreamError(errorText=error_message)
tool_call_response=str(tool_response_messages), yield error_response
): if not has_yielded_end:
yield chunk yield StreamFinish()
return
# Handle retry outside of exception handler to avoid nesting
if should_retry and retry_count < config.max_retries:
logger.info(
f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}"
)
async for chunk in stream_chat_completion(
session_id=session.session_id,
user_id=user_id,
retry_count=retry_count + 1,
session=session,
context=context,
):
yield chunk
return # Exit after retry to avoid double-saving in finally block
# Normal completion path - save session and handle tool call continuation
# Only save if we haven't already saved when StreamFinish was received
if not has_saved_assistant_message:
logger.info(
f"Normal completion path: session={session.session_id}, "
f"current message_count={len(session.messages)}"
)
# Build the messages list in the correct order
messages_to_save: list[ChatMessage] = []
# Add assistant message with tool_calls if any
if accumulated_tool_calls:
assistant_response.tool_calls = accumulated_tool_calls
logger.info(
f"Added {len(accumulated_tool_calls)} tool calls to assistant message"
)
if not has_appended_streaming_message and (
assistant_response.content or assistant_response.tool_calls
):
messages_to_save.append(assistant_response)
logger.info(
f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}"
)
# Add tool response messages after assistant message
messages_to_save.extend(tool_response_messages)
logger.info(
f"Saving {len(tool_response_messages)} tool response messages, "
f"total_to_save={len(messages_to_save)}"
)
if messages_to_save:
session.messages.extend(messages_to_save)
logger.info(
f"Extended session messages, new message_count={len(session.messages)}"
)
if messages_to_save or has_appended_streaming_message:
await upsert_chat_session(session)
else:
logger.info(
"Assistant message already saved when StreamFinish was received, "
"skipping duplicate save"
)
# If we did a tool call, stream the chat completion again to get the next response
if has_done_tool_call:
logger.info(
"Tool call executed, streaming chat completion again to get assistant response"
)
async for chunk in stream_chat_completion(
session_id=session.session_id,
user_id=user_id,
session=session, # Pass session object to avoid Redis refetch
context=context,
tool_call_response=str(tool_response_messages),
):
yield chunk
# Retry configuration for OpenAI API calls # Retry configuration for OpenAI API calls
@@ -698,6 +676,20 @@ async def _stream_chat_chunks(
f"{f' (retry {retry_count}/{MAX_RETRIES})' if retry_count > 0 else ''}" f"{f' (retry {retry_count}/{MAX_RETRIES})' if retry_count > 0 else ''}"
) )
# Build extra_body for OpenRouter tracing and PostHog analytics
extra_body: dict[str, Any] = {
"posthogProperties": {
"environment": settings.config.app_env.value,
},
}
if session.user_id:
extra_body["user"] = session.user_id[:128] # OpenRouter limit
extra_body["posthogDistinctId"] = session.user_id
if session.session_id:
extra_body["session_id"] = session.session_id[
:128
] # OpenRouter limit
# Create the stream with proper types # Create the stream with proper types
stream = await client.chat.completions.create( stream = await client.chat.completions.create(
model=model, model=model,
@@ -706,6 +698,7 @@ async def _stream_chat_chunks(
tool_choice="auto", tool_choice="auto",
stream=True, stream=True,
stream_options={"include_usage": True}, stream_options={"include_usage": True},
extra_body=extra_body,
) )
# Variables to accumulate tool calls # Variables to accumulate tool calls

View File

@@ -1,8 +1,10 @@
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from openai.types.chat import ChatCompletionToolParam from openai.types.chat import ChatCompletionToolParam
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from backend.api.features.chat.tracking import track_tool_called
from .add_understanding import AddUnderstandingTool from .add_understanding import AddUnderstandingTool
from .agent_output import AgentOutputTool from .agent_output import AgentOutputTool
@@ -20,6 +22,8 @@ from .search_docs import SearchDocsTool
if TYPE_CHECKING: if TYPE_CHECKING:
from backend.api.features.chat.response_model import StreamToolOutputAvailable from backend.api.features.chat.response_model import StreamToolOutputAvailable
logger = logging.getLogger(__name__)
# Single source of truth for all tools # Single source of truth for all tools
TOOL_REGISTRY: dict[str, BaseTool] = { TOOL_REGISTRY: dict[str, BaseTool] = {
"add_understanding": AddUnderstandingTool(), "add_understanding": AddUnderstandingTool(),
@@ -56,4 +60,17 @@ async def execute_tool(
tool = TOOL_REGISTRY.get(tool_name) tool = TOOL_REGISTRY.get(tool_name)
if not tool: if not tool:
raise ValueError(f"Tool {tool_name} not found") raise ValueError(f"Tool {tool_name} not found")
# Track tool call in PostHog
logger.info(
f"Tracking tool call: tool={tool_name}, user={user_id}, "
f"session={session.session_id}, call_id={tool_call_id}"
)
track_tool_called(
user_id=user_id,
session_id=session.session_id,
tool_name=tool_name,
tool_call_id=tool_call_id,
)
return await tool.execute(user_id, session, tool_call_id, **parameters) return await tool.execute(user_id, session, tool_call_id, **parameters)

View File

@@ -3,8 +3,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from backend.data.understanding import ( from backend.data.understanding import (
BusinessUnderstandingInput, BusinessUnderstandingInput,
@@ -61,7 +59,6 @@ and automations for the user's specific needs."""
"""Requires authentication to store user-specific data.""" """Requires authentication to store user-specific data."""
return True return True
@observe(as_type="tool", name="add_understanding")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -5,7 +5,6 @@ import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from langfuse import observe
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
@@ -329,7 +328,6 @@ class AgentOutputTool(BaseTool):
total_executions=len(available_executions) if available_executions else 1, total_executions=len(available_executions) if available_executions else 1,
) )
@observe(as_type="tool", name="view_agent_output")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -3,8 +3,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from .agent_generator import ( from .agent_generator import (
@@ -75,7 +73,6 @@ class CreateAgentTool(BaseTool):
"required": ["description"], "required": ["description"],
} }
@observe(as_type="tool", name="create_agent")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -3,8 +3,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from .agent_generator import ( from .agent_generator import (
@@ -81,7 +79,6 @@ class EditAgentTool(BaseTool):
"required": ["agent_id", "changes"], "required": ["agent_id", "changes"],
} }
@observe(as_type="tool", name="edit_agent")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -2,8 +2,6 @@
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from .agent_search import search_agents from .agent_search import search_agents
@@ -37,7 +35,6 @@ class FindAgentTool(BaseTool):
"required": ["query"], "required": ["query"],
} }
@observe(as_type="tool", name="find_agent")
async def _execute( async def _execute(
self, user_id: str | None, session: ChatSession, **kwargs self, user_id: str | None, session: ChatSession, **kwargs
) -> ToolResponseBase: ) -> ToolResponseBase:

View File

@@ -1,7 +1,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from prisma.enums import ContentType from prisma.enums import ContentType
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
@@ -56,7 +55,6 @@ class FindBlockTool(BaseTool):
def requires_auth(self) -> bool: def requires_auth(self) -> bool:
return True return True
@observe(as_type="tool", name="find_block")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -2,8 +2,6 @@
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from .agent_search import search_agents from .agent_search import search_agents
@@ -43,7 +41,6 @@ class FindLibraryAgentTool(BaseTool):
def requires_auth(self) -> bool: def requires_auth(self) -> bool:
return True return True
@observe(as_type="tool", name="find_library_agent")
async def _execute( async def _execute(
self, user_id: str | None, session: ChatSession, **kwargs self, user_id: str | None, session: ChatSession, **kwargs
) -> ToolResponseBase: ) -> ToolResponseBase:

View File

@@ -4,8 +4,6 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from backend.api.features.chat.tools.base import BaseTool from backend.api.features.chat.tools.base import BaseTool
from backend.api.features.chat.tools.models import ( from backend.api.features.chat.tools.models import (
@@ -73,7 +71,6 @@ class GetDocPageTool(BaseTool):
url_path = path.rsplit(".", 1)[0] if "." in path else path url_path = path.rsplit(".", 1)[0] if "." in path else path
return f"{DOCS_BASE_URL}/{url_path}" return f"{DOCS_BASE_URL}/{url_path}"
@observe(as_type="tool", name="get_doc_page")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -3,11 +3,14 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from backend.api.features.chat.config import ChatConfig from backend.api.features.chat.config import ChatConfig
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from backend.api.features.chat.tracking import (
track_agent_run_success,
track_agent_scheduled,
)
from backend.api.features.library import db as library_db from backend.api.features.library import db as library_db
from backend.data.graph import GraphModel from backend.data.graph import GraphModel
from backend.data.model import CredentialsMetaInput from backend.data.model import CredentialsMetaInput
@@ -155,7 +158,6 @@ class RunAgentTool(BaseTool):
"""All operations require authentication.""" """All operations require authentication."""
return True return True
@observe(as_type="tool", name="run_agent")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,
@@ -453,6 +455,16 @@ class RunAgentTool(BaseTool):
session.successful_agent_runs.get(library_agent.graph_id, 0) + 1 session.successful_agent_runs.get(library_agent.graph_id, 0) + 1
) )
# Track in PostHog
track_agent_run_success(
user_id=user_id,
session_id=session_id,
graph_id=library_agent.graph_id,
graph_name=library_agent.name,
execution_id=execution.id,
library_agent_id=library_agent.id,
)
library_agent_link = f"/library/agents/{library_agent.id}" library_agent_link = f"/library/agents/{library_agent.id}"
return ExecutionStartedResponse( return ExecutionStartedResponse(
message=( message=(
@@ -534,6 +546,18 @@ class RunAgentTool(BaseTool):
session.successful_agent_schedules.get(library_agent.graph_id, 0) + 1 session.successful_agent_schedules.get(library_agent.graph_id, 0) + 1
) )
# Track in PostHog
track_agent_scheduled(
user_id=user_id,
session_id=session_id,
graph_id=library_agent.graph_id,
graph_name=library_agent.name,
schedule_id=result.id,
schedule_name=schedule_name,
cron=cron,
library_agent_id=library_agent.id,
)
library_agent_link = f"/library/agents/{library_agent.id}" library_agent_link = f"/library/agents/{library_agent.id}"
return ExecutionStartedResponse( return ExecutionStartedResponse(
message=( message=(

View File

@@ -4,8 +4,6 @@ import logging
from collections import defaultdict from collections import defaultdict
from typing import Any from typing import Any
from langfuse import observe
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
from backend.data.block import get_block from backend.data.block import get_block
from backend.data.execution import ExecutionContext from backend.data.execution import ExecutionContext
@@ -130,7 +128,6 @@ class RunBlockTool(BaseTool):
return matched_credentials, missing_credentials return matched_credentials, missing_credentials
@observe(as_type="tool", name="run_block")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,
@@ -179,6 +176,11 @@ class RunBlockTool(BaseTool):
message=f"Block '{block_id}' not found", message=f"Block '{block_id}' not found",
session_id=session_id, session_id=session_id,
) )
if block.disabled:
return ErrorResponse(
message=f"Block '{block_id}' is disabled",
session_id=session_id,
)
logger.info(f"Executing block {block.name} ({block_id}) for user {user_id}") logger.info(f"Executing block {block.name} ({block_id}) for user {user_id}")

View File

@@ -3,7 +3,6 @@
import logging import logging
from typing import Any from typing import Any
from langfuse import observe
from prisma.enums import ContentType from prisma.enums import ContentType
from backend.api.features.chat.model import ChatSession from backend.api.features.chat.model import ChatSession
@@ -88,7 +87,6 @@ class SearchDocsTool(BaseTool):
url_path = path.rsplit(".", 1)[0] if "." in path else path url_path = path.rsplit(".", 1)[0] if "." in path else path
return f"{DOCS_BASE_URL}/{url_path}" return f"{DOCS_BASE_URL}/{url_path}"
@observe(as_type="tool", name="search_docs")
async def _execute( async def _execute(
self, self,
user_id: str | None, user_id: str | None,

View File

@@ -0,0 +1,250 @@
"""PostHog analytics tracking for the chat system."""
import atexit
import logging
from typing import Any
from posthog import Posthog
from backend.util.settings import Settings
logger = logging.getLogger(__name__)
settings = Settings()
# PostHog client instance (lazily initialized)
_posthog_client: Posthog | None = None
def _shutdown_posthog() -> None:
"""Flush and shutdown PostHog client on process exit."""
if _posthog_client is not None:
_posthog_client.flush()
_posthog_client.shutdown()
atexit.register(_shutdown_posthog)
def _get_posthog_client() -> Posthog | None:
"""Get or create the PostHog client instance."""
global _posthog_client
if _posthog_client is not None:
return _posthog_client
if not settings.secrets.posthog_api_key:
logger.debug("PostHog API key not configured, analytics disabled")
return None
_posthog_client = Posthog(
settings.secrets.posthog_api_key,
host=settings.secrets.posthog_host,
)
logger.info(
f"PostHog client initialized with host: {settings.secrets.posthog_host}"
)
return _posthog_client
def _get_base_properties() -> dict[str, Any]:
"""Get base properties included in all events."""
return {
"environment": settings.config.app_env.value,
"source": "chat_copilot",
}
def track_user_message(
user_id: str | None,
session_id: str,
message_length: int,
) -> None:
"""Track when a user sends a message in chat.
Args:
user_id: The user's ID (or None for anonymous)
session_id: The chat session ID
message_length: Length of the user's message
"""
client = _get_posthog_client()
if not client:
return
try:
properties = {
**_get_base_properties(),
"session_id": session_id,
"message_length": message_length,
}
client.capture(
distinct_id=user_id or f"anonymous_{session_id}",
event="copilot_message_sent",
properties=properties,
)
except Exception as e:
logger.warning(f"Failed to track user message: {e}")
def track_tool_called(
user_id: str | None,
session_id: str,
tool_name: str,
tool_call_id: str,
) -> None:
"""Track when a tool is called in chat.
Args:
user_id: The user's ID (or None for anonymous)
session_id: The chat session ID
tool_name: Name of the tool being called
tool_call_id: Unique ID of the tool call
"""
client = _get_posthog_client()
if not client:
logger.info("PostHog client not available for tool tracking")
return
try:
properties = {
**_get_base_properties(),
"session_id": session_id,
"tool_name": tool_name,
"tool_call_id": tool_call_id,
}
distinct_id = user_id or f"anonymous_{session_id}"
logger.info(
f"Sending copilot_tool_called event to PostHog: distinct_id={distinct_id}, "
f"tool_name={tool_name}"
)
client.capture(
distinct_id=distinct_id,
event="copilot_tool_called",
properties=properties,
)
except Exception as e:
logger.warning(f"Failed to track tool call: {e}")
def track_agent_run_success(
user_id: str,
session_id: str,
graph_id: str,
graph_name: str,
execution_id: str,
library_agent_id: str,
) -> None:
"""Track when an agent is successfully run.
Args:
user_id: The user's ID
session_id: The chat session ID
graph_id: ID of the agent graph
graph_name: Name of the agent
execution_id: ID of the execution
library_agent_id: ID of the library agent
"""
client = _get_posthog_client()
if not client:
return
try:
properties = {
**_get_base_properties(),
"session_id": session_id,
"graph_id": graph_id,
"graph_name": graph_name,
"execution_id": execution_id,
"library_agent_id": library_agent_id,
}
client.capture(
distinct_id=user_id,
event="copilot_agent_run_success",
properties=properties,
)
except Exception as e:
logger.warning(f"Failed to track agent run: {e}")
def track_agent_scheduled(
user_id: str,
session_id: str,
graph_id: str,
graph_name: str,
schedule_id: str,
schedule_name: str,
cron: str,
library_agent_id: str,
) -> None:
"""Track when an agent is successfully scheduled.
Args:
user_id: The user's ID
session_id: The chat session ID
graph_id: ID of the agent graph
graph_name: Name of the agent
schedule_id: ID of the schedule
schedule_name: Name of the schedule
cron: Cron expression for the schedule
library_agent_id: ID of the library agent
"""
client = _get_posthog_client()
if not client:
return
try:
properties = {
**_get_base_properties(),
"session_id": session_id,
"graph_id": graph_id,
"graph_name": graph_name,
"schedule_id": schedule_id,
"schedule_name": schedule_name,
"cron": cron,
"library_agent_id": library_agent_id,
}
client.capture(
distinct_id=user_id,
event="copilot_agent_scheduled",
properties=properties,
)
except Exception as e:
logger.warning(f"Failed to track agent schedule: {e}")
def track_trigger_setup(
user_id: str,
session_id: str,
graph_id: str,
graph_name: str,
trigger_type: str,
library_agent_id: str,
) -> None:
"""Track when a trigger is set up for an agent.
Args:
user_id: The user's ID
session_id: The chat session ID
graph_id: ID of the agent graph
graph_name: Name of the agent
trigger_type: Type of trigger (e.g., 'webhook')
library_agent_id: ID of the library agent
"""
client = _get_posthog_client()
if not client:
return
try:
properties = {
**_get_base_properties(),
"session_id": session_id,
"graph_id": graph_id,
"graph_name": graph_name,
"trigger_type": trigger_type,
"library_agent_id": library_agent_id,
}
client.capture(
distinct_id=user_id,
event="copilot_trigger_setup",
properties=properties,
)
except Exception as e:
logger.warning(f"Failed to track trigger setup: {e}")

View File

@@ -364,6 +364,8 @@ async def execute_graph_block(
obj = get_block(block_id) obj = get_block(block_id)
if not obj: if not obj:
raise HTTPException(status_code=404, detail=f"Block #{block_id} not found.") raise HTTPException(status_code=404, detail=f"Block #{block_id} not found.")
if obj.disabled:
raise HTTPException(status_code=403, detail=f"Block #{block_id} is disabled.")
user = await get_user_by_id(user_id) user = await get_user_by_id(user_id)
if not user: if not user:

View File

@@ -138,6 +138,7 @@ def test_execute_graph_block(
"""Test execute block endpoint""" """Test execute block endpoint"""
# Mock block # Mock block
mock_block = Mock() mock_block = Mock()
mock_block.disabled = False
async def mock_execute(*args, **kwargs): async def mock_execute(*args, **kwargs):
yield "output1", {"data": "result1"} yield "output1", {"data": "result1"}

View File

@@ -679,6 +679,12 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
default="https://cloud.langfuse.com", description="Langfuse host URL" default="https://cloud.langfuse.com", description="Langfuse host URL"
) )
# PostHog analytics
posthog_api_key: str = Field(default="", description="PostHog API key")
posthog_host: str = Field(
default="https://eu.i.posthog.com", description="PostHog host URL"
)
# Add more secret fields as needed # Add more secret fields as needed
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View File

@@ -4204,14 +4204,14 @@ strenum = {version = ">=0.4.9,<0.5.0", markers = "python_version < \"3.11\""}
[[package]] [[package]]
name = "posthog" name = "posthog"
version = "6.1.1" version = "7.6.0"
description = "Integrate PostHog into any python application." description = "Integrate PostHog into any python application."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "posthog-6.1.1-py3-none-any.whl", hash = "sha256:329fd3d06b4d54cec925f47235bd8e327c91403c2f9ec38f1deb849535934dba"}, {file = "posthog-7.6.0-py3-none-any.whl", hash = "sha256:c4dd78cf77c4fecceb965f86066e5ac37886ef867d68ffe75a1db5d681d7d9ad"},
{file = "posthog-6.1.1.tar.gz", hash = "sha256:b453f54c4a2589da859fd575dd3bf86fcb40580727ec399535f268b1b9f318b8"}, {file = "posthog-7.6.0.tar.gz", hash = "sha256:941dfd278ee427c9b14640f09b35b5bb52a71bdf028d7dbb7307e1838fd3002e"},
] ]
[package.dependencies] [package.dependencies]
@@ -4225,7 +4225,7 @@ typing-extensions = ">=4.2.0"
[package.extras] [package.extras]
dev = ["django-stubs", "lxml", "mypy", "mypy-baseline", "packaging", "pre-commit", "pydantic", "ruff", "setuptools", "tomli", "tomli_w", "twine", "types-mock", "types-python-dateutil", "types-requests", "types-setuptools", "types-six", "wheel"] dev = ["django-stubs", "lxml", "mypy", "mypy-baseline", "packaging", "pre-commit", "pydantic", "ruff", "setuptools", "tomli", "tomli_w", "twine", "types-mock", "types-python-dateutil", "types-requests", "types-setuptools", "types-six", "wheel"]
langchain = ["langchain (>=0.2.0)"] langchain = ["langchain (>=0.2.0)"]
test = ["anthropic", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=0.3.15)", "langchain-community (>=0.3.25)", "langchain-core (>=0.3.65)", "langchain-openai (>=0.3.22)", "langgraph (>=0.4.8)", "mock (>=2.0.0)", "openai", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"] test = ["anthropic (>=0.72)", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=1.0)", "langchain-community (>=0.4)", "langchain-core (>=1.0)", "langchain-openai (>=1.0)", "langgraph (>=1.0)", "mock (>=2.0.0)", "openai (>=2.0)", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"]
[[package]] [[package]]
name = "postmarker" name = "postmarker"
@@ -7512,4 +7512,4 @@ cffi = ["cffi (>=1.11)"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<3.14" python-versions = ">=3.10,<3.14"
content-hash = "18b92e09596298c82432e4d0a85cb6d80a40b4229bee0a0c15f0529fd6cb21a4" content-hash = "ee5742dc1a9df50dfc06d4b26a1682cbb2b25cab6b79ce5625ec272f93e4f4bf"

View File

@@ -85,6 +85,7 @@ exa-py = "^1.14.20"
croniter = "^6.0.0" croniter = "^6.0.0"
stagehand = "^0.5.1" stagehand = "^0.5.1"
gravitas-md2gdocs = "^0.1.0" gravitas-md2gdocs = "^0.1.0"
posthog = "^7.6.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
aiohappyeyeballs = "^2.6.1" aiohappyeyeballs = "^2.6.1"

View File

@@ -30,3 +30,7 @@ NEXT_PUBLIC_TURNSTILE=disabled
# PR previews # PR previews
NEXT_PUBLIC_PREVIEW_STEALING_DEV= NEXT_PUBLIC_PREVIEW_STEALING_DEV=
# PostHog Analytics
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com

View File

@@ -34,6 +34,7 @@
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
"@next/third-parties": "15.4.6", "@next/third-parties": "15.4.6",
"@phosphor-icons/react": "2.1.10", "@phosphor-icons/react": "2.1.10",
"@posthog/react": "1.7.0",
"@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-avatar": "1.1.10",
@@ -91,6 +92,7 @@
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nuqs": "2.7.2", "nuqs": "2.7.2",
"party-js": "2.2.0", "party-js": "2.2.0",
"posthog-js": "1.334.1",
"react": "18.3.1", "react": "18.3.1",
"react-currency-input-field": "4.0.3", "react-currency-input-field": "4.0.3",
"react-day-picker": "9.11.1", "react-day-picker": "9.11.1",
@@ -120,7 +122,6 @@
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "4.1.2", "@chromatic-com/storybook": "4.1.2",
"happy-dom": "20.3.4",
"@opentelemetry/instrumentation": "0.209.0", "@opentelemetry/instrumentation": "0.209.0",
"@playwright/test": "1.56.1", "@playwright/test": "1.56.1",
"@storybook/addon-a11y": "9.1.5", "@storybook/addon-a11y": "9.1.5",
@@ -148,6 +149,7 @@
"eslint": "8.57.1", "eslint": "8.57.1",
"eslint-config-next": "15.5.7", "eslint-config-next": "15.5.7",
"eslint-plugin-storybook": "9.1.5", "eslint-plugin-storybook": "9.1.5",
"happy-dom": "20.3.4",
"import-in-the-middle": "2.0.2", "import-in-the-middle": "2.0.2",
"msw": "2.11.6", "msw": "2.11.6",
"msw-storybook-addon": "2.0.6", "msw-storybook-addon": "2.0.6",

View File

@@ -23,6 +23,9 @@ importers:
'@phosphor-icons/react': '@phosphor-icons/react':
specifier: 2.1.10 specifier: 2.1.10
version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@posthog/react':
specifier: 1.7.0
version: 1.7.0(@types/react@18.3.17)(posthog-js@1.334.1)(react@18.3.1)
'@radix-ui/react-accordion': '@radix-ui/react-accordion':
specifier: 1.2.12 specifier: 1.2.12
version: 1.2.12(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.2.12(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -194,6 +197,9 @@ importers:
party-js: party-js:
specifier: 2.2.0 specifier: 2.2.0
version: 2.2.0 version: 2.2.0
posthog-js:
specifier: 1.334.1
version: 1.334.1
react: react:
specifier: 18.3.1 specifier: 18.3.1
version: 18.3.1 version: 18.3.1
@@ -1794,6 +1800,10 @@ packages:
'@open-draft/until@2.1.0': '@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opentelemetry/api-logs@0.208.0':
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.209.0': '@opentelemetry/api-logs@0.209.0':
resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==} resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -1814,6 +1824,12 @@ packages:
peerDependencies: peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0' '@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/exporter-logs-otlp-http@0.208.0':
resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation-amqplib@0.55.0': '@opentelemetry/instrumentation-amqplib@0.55.0':
resolution: {integrity: sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA==} resolution: {integrity: sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
@@ -1952,6 +1968,18 @@ packages:
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.3.0 '@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-exporter-base@0.208.0':
resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/otlp-transformer@0.208.0':
resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/redis-common@0.38.2': '@opentelemetry/redis-common@0.38.2':
resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
@@ -1962,6 +1990,18 @@ packages:
peerDependencies: peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0' '@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/sdk-logs@0.208.0':
resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.4.0 <1.10.0'
'@opentelemetry/sdk-metrics@2.2.0':
resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.9.0 <1.10.0'
'@opentelemetry/sdk-trace-base@2.2.0': '@opentelemetry/sdk-trace-base@2.2.0':
resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
@@ -2050,11 +2090,57 @@ packages:
webpack-plugin-serve: webpack-plugin-serve:
optional: true optional: true
'@posthog/core@1.13.0':
resolution: {integrity: sha512-knjncrk7qRmssFRbGzBl1Tunt21GRpe0Wv+uVelyL0Rh7PdQUsgguulzXFTps8hA6wPwTU4kq85qnbAJ3eH6Wg==}
'@posthog/react@1.7.0':
resolution: {integrity: sha512-pM7GL7z/rKjiIwosbRiQA3buhLI6vUo+wg+T/ZrVZC7O5bVU07TfgNZTcuOj8E9dx7vDbfNrc1kjDN7PKMM8ug==}
peerDependencies:
'@types/react': '>=16.8.0'
posthog-js: '>=1.257.2'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
'@posthog/types@1.334.1':
resolution: {integrity: sha512-ypFnwTO7qbV7icylLbujbamPdQXbJq0a61GUUBnJAeTbBw/qYPIss5IRYICcbCj0uunQrwD7/CGxVb5TOYKWgA==}
'@prisma/instrumentation@6.19.0': '@prisma/instrumentation@6.19.0':
resolution: {integrity: sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg==} resolution: {integrity: sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg==}
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.8 '@opentelemetry/api': ^1.8
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.4':
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
'@protobufjs/eventemitter@1.1.0':
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
'@protobufjs/fetch@1.1.0':
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/inquire@1.1.0':
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@radix-ui/number@1.1.1': '@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -3401,6 +3487,9 @@ packages:
'@types/tedious@4.0.14': '@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@2.0.11': '@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -4278,6 +4367,9 @@ packages:
core-js-pure@3.47.0: core-js-pure@3.47.0:
resolution: {integrity: sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==} resolution: {integrity: sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==}
core-js@3.48.0:
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
core-util-is@1.0.3: core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -4569,6 +4661,9 @@ packages:
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
domutils@2.8.0: domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
@@ -4939,6 +5034,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
@@ -5745,6 +5843,9 @@ packages:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'} engines: {node: '>= 0.6.0'}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
longest-streak@3.1.0: longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -6534,6 +6635,12 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
posthog-js@1.334.1:
resolution: {integrity: sha512-5cDzLICr2afnwX/cR9fwoLC0vN0Nb5gP5HiCigzHkgHdO+E3WsYefla3EFMQz7U4r01CBPZ+nZ9/srkzeACxtQ==}
preact@10.28.2:
resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -6622,6 +6729,10 @@ packages:
property-information@7.1.0: property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
proxy-from-env@1.1.0: proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
@@ -6643,6 +6754,9 @@ packages:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
query-selector-shadow-dom@1.0.1:
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
querystring-es3@0.2.1: querystring-es3@0.2.1:
resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==}
engines: {node: '>=0.4.x'} engines: {node: '>=0.4.x'}
@@ -7821,6 +7935,9 @@ packages:
web-namespaces@2.0.1: web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
web-vitals@5.1.0:
resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
webidl-conversions@3.0.1: webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -9420,6 +9537,10 @@ snapshots:
'@open-draft/until@2.1.0': {} '@open-draft/until@2.1.0': {}
'@opentelemetry/api-logs@0.208.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs@0.209.0': '@opentelemetry/api-logs@0.209.0':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@@ -9435,6 +9556,15 @@ snapshots:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/semantic-conventions': 1.38.0
'@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation-amqplib@0.55.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-amqplib@0.55.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@@ -9629,6 +9759,23 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
protobufjs: 7.5.4
'@opentelemetry/redis-common@0.38.2': {} '@opentelemetry/redis-common@0.38.2': {}
'@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)':
@@ -9637,6 +9784,19 @@ snapshots:
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/semantic-conventions': 1.38.0
'@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@@ -9801,6 +9961,19 @@ snapshots:
type-fest: 4.41.0 type-fest: 4.41.0
webpack-hot-middleware: 2.26.1 webpack-hot-middleware: 2.26.1
'@posthog/core@1.13.0':
dependencies:
cross-spawn: 7.0.6
'@posthog/react@1.7.0(@types/react@18.3.17)(posthog-js@1.334.1)(react@18.3.1)':
dependencies:
posthog-js: 1.334.1
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.17
'@posthog/types@1.334.1': {}
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)': '@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@@ -9808,6 +9981,29 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.4': {}
'@protobufjs/eventemitter@1.1.0': {}
'@protobufjs/fetch@1.1.0':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/float@1.0.2': {}
'@protobufjs/inquire@1.1.0': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.0': {}
'@radix-ui/number@1.1.1': {} '@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.3': {}
@@ -11426,6 +11622,9 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.10.0 '@types/node': 24.10.0
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@2.0.11': {} '@types/unist@2.0.11': {}
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
@@ -12327,6 +12526,8 @@ snapshots:
core-js-pure@3.47.0: {} core-js-pure@3.47.0: {}
core-js@3.48.0: {}
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
cosmiconfig@7.1.0: cosmiconfig@7.1.0:
@@ -12636,6 +12837,10 @@ snapshots:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@2.8.0: domutils@2.8.0:
dependencies: dependencies:
dom-serializer: 1.4.1 dom-serializer: 1.4.1
@@ -13205,6 +13410,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fflate@0.4.8: {}
file-entry-cache@6.0.1: file-entry-cache@6.0.1:
dependencies: dependencies:
flat-cache: 3.2.0 flat-cache: 3.2.0
@@ -14092,6 +14299,8 @@ snapshots:
loglevel@1.9.2: {} loglevel@1.9.2: {}
long@5.3.2: {}
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
loose-envify@1.4.0: loose-envify@1.4.0:
@@ -15154,6 +15363,24 @@ snapshots:
dependencies: dependencies:
xtend: 4.0.2 xtend: 4.0.2
posthog-js@1.334.1:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
'@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
'@posthog/core': 1.13.0
'@posthog/types': 1.334.1
core-js: 3.48.0
dompurify: 3.3.1
fflate: 0.4.8
preact: 10.28.2
query-selector-shadow-dom: 1.0.1
web-vitals: 5.1.0
preact@10.28.2: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-plugin-tailwindcss@0.7.1(prettier@3.6.2): prettier-plugin-tailwindcss@0.7.1(prettier@3.6.2):
@@ -15187,6 +15414,21 @@ snapshots:
property-information@7.1.0: {} property-information@7.1.0: {}
protobufjs@7.5.4:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.4
'@protobufjs/eventemitter': 1.1.0
'@protobufjs/fetch': 1.1.0
'@protobufjs/float': 1.0.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 24.10.0
long: 5.3.2
proxy-from-env@1.1.0: {} proxy-from-env@1.1.0: {}
public-encrypt@4.0.3: public-encrypt@4.0.3:
@@ -15208,6 +15450,8 @@ snapshots:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0
query-selector-shadow-dom@1.0.1: {}
querystring-es3@0.2.1: {} querystring-es3@0.2.1: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
@@ -16619,6 +16863,8 @@ snapshots:
web-namespaces@2.0.1: {} web-namespaces@2.0.1: {}
web-vitals@5.1.0: {}
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}
webidl-conversions@8.0.1: webidl-conversions@8.0.1:

View File

@@ -38,8 +38,12 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
return outputNodes return outputNodes
.map((node) => { .map((node) => {
const executionResult = node.data.nodeExecutionResult; const executionResults = node.data.nodeExecutionResults || [];
const outputData = executionResult?.output_data?.output; const latestResult =
executionResults.length > 0
? executionResults[executionResults.length - 1]
: undefined;
const outputData = latestResult?.output_data?.output;
const renderer = globalRegistry.getRenderer(outputData); const renderer = globalRegistry.getRenderer(outputData);

View File

@@ -153,6 +153,9 @@ export const useRunInputDialog = ({
Object.entries(credentialValues).filter(([_, cred]) => cred && cred.id), Object.entries(credentialValues).filter(([_, cred]) => cred && cred.id),
); );
useNodeStore.getState().clearAllNodeExecutionResults();
useNodeStore.getState().cleanNodesStatuses();
await executeGraph({ await executeGraph({
graphId: flowID ?? "", graphId: flowID ?? "",
graphVersion: flowVersion || null, graphVersion: flowVersion || null,

View File

@@ -34,7 +34,7 @@ export type CustomNodeData = {
uiType: BlockUIType; uiType: BlockUIType;
block_id: string; block_id: string;
status?: AgentExecutionStatus; status?: AgentExecutionStatus;
nodeExecutionResult?: NodeExecutionResult; nodeExecutionResults?: NodeExecutionResult[];
staticOutput?: boolean; staticOutput?: boolean;
// TODO : We need better type safety for the following backend fields. // TODO : We need better type safety for the following backend fields.
costs: BlockCost[]; costs: BlockCost[];
@@ -75,7 +75,11 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
(value) => value !== null && value !== undefined && value !== "", (value) => value !== null && value !== undefined && value !== "",
); );
const outputData = data.nodeExecutionResult?.output_data; const latestResult =
data.nodeExecutionResults && data.nodeExecutionResults.length > 0
? data.nodeExecutionResults[data.nodeExecutionResults.length - 1]
: undefined;
const outputData = latestResult?.output_data;
const hasOutputError = const hasOutputError =
typeof outputData === "object" && typeof outputData === "object" &&
outputData !== null && outputData !== null &&

View File

@@ -14,10 +14,15 @@ import { useNodeOutput } from "./useNodeOutput";
import { ViewMoreData } from "./components/ViewMoreData"; import { ViewMoreData } from "./components/ViewMoreData";
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => { export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
const { outputData, copiedKey, handleCopy, executionResultId, inputData } = const {
useNodeOutput(nodeId); latestOutputData,
copiedKey,
handleCopy,
executionResultId,
latestInputData,
} = useNodeOutput(nodeId);
if (Object.keys(outputData).length === 0) { if (Object.keys(latestOutputData).length === 0) {
return null; return null;
} }
@@ -41,18 +46,19 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
<div className="space-y-2"> <div className="space-y-2">
<Text variant="small-medium">Input</Text> <Text variant="small-medium">Input</Text>
<ContentRenderer value={inputData} shortContent={false} /> <ContentRenderer value={latestInputData} shortContent={false} />
<div className="mt-1 flex justify-end gap-1"> <div className="mt-1 flex justify-end gap-1">
<NodeDataViewer <NodeDataViewer
data={inputData}
pinName="Input" pinName="Input"
nodeId={nodeId}
execId={executionResultId} execId={executionResultId}
dataType="input"
/> />
<Button <Button
variant="secondary" variant="secondary"
size="small" size="small"
onClick={() => handleCopy("input", inputData)} onClick={() => handleCopy("input", latestInputData)}
className={cn( className={cn(
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900", "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
copiedKey === "input" && copiedKey === "input" &&
@@ -68,70 +74,72 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
</div> </div>
</div> </div>
{Object.entries(outputData) {Object.entries(latestOutputData)
.slice(0, 2) .slice(0, 2)
.map(([key, value]) => ( .map(([key, value]) => {
<div key={key} className="flex flex-col gap-2"> return (
<div className="flex items-center gap-2"> <div key={key} className="flex flex-col gap-2">
<Text <div className="flex items-center gap-2">
variant="small-medium" <Text
className="!font-semibold text-slate-600" variant="small-medium"
> className="!font-semibold text-slate-600"
Pin: >
</Text> Pin:
<Text variant="small" className="text-slate-700"> </Text>
{beautifyString(key)} <Text variant="small" className="text-slate-700">
</Text> {beautifyString(key)}
</div> </Text>
<div className="w-full space-y-2"> </div>
<Text <div className="w-full space-y-2">
variant="small" <Text
className="!font-semibold text-slate-600" variant="small"
> className="!font-semibold text-slate-600"
Data: >
</Text> Data:
<div className="relative space-y-2"> </Text>
{value.map((item, index) => ( <div className="relative space-y-2">
<div key={index}> {value.map((item, index) => (
<ContentRenderer value={item} shortContent={true} /> <div key={index}>
</div> <ContentRenderer
))} value={item}
shortContent={true}
/>
</div>
))}
<div className="mt-1 flex justify-end gap-1"> <div className="mt-1 flex justify-end gap-1">
<NodeDataViewer <NodeDataViewer
data={value} pinName={key}
pinName={key} nodeId={nodeId}
execId={executionResultId} execId={executionResultId}
/> />
<Button <Button
variant="secondary" variant="secondary"
size="small" size="small"
onClick={() => handleCopy(key, value)} onClick={() => handleCopy(key, value)}
className={cn( className={cn(
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900", "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
copiedKey === key && copiedKey === key &&
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200", "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
)} )}
> >
{copiedKey === key ? ( {copiedKey === key ? (
<CheckIcon size={12} className="text-green-600" /> <CheckIcon
) : ( size={12}
<CopyIcon size={12} /> className="text-green-600"
)} />
</Button> ) : (
<CopyIcon size={12} />
)}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> );
))} })}
</div> </div>
<ViewMoreData nodeId={nodeId} />
{Object.keys(outputData).length > 2 && (
<ViewMoreData
outputData={outputData}
execId={executionResultId}
/>
)}
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>

View File

@@ -19,22 +19,51 @@ import {
CopyIcon, CopyIcon,
DownloadIcon, DownloadIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { FC } from "react"; import React, { FC } from "react";
import { useNodeDataViewer } from "./useNodeDataViewer"; import { useNodeDataViewer } from "./useNodeDataViewer";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useShallow } from "zustand/react/shallow";
import { NodeDataType } from "../../helpers";
interface NodeDataViewerProps { export interface NodeDataViewerProps {
data: any; data?: any;
pinName: string; pinName: string;
nodeId?: string;
execId?: string; execId?: string;
isViewMoreData?: boolean; isViewMoreData?: boolean;
dataType?: NodeDataType;
} }
export const NodeDataViewer: FC<NodeDataViewerProps> = ({ export const NodeDataViewer: FC<NodeDataViewerProps> = ({
data, data,
pinName, pinName,
nodeId,
execId = "N/A", execId = "N/A",
isViewMoreData = false, isViewMoreData = false,
dataType = "output",
}) => { }) => {
const executionResults = useNodeStore(
useShallow((state) =>
nodeId ? state.getNodeExecutionResults(nodeId) : [],
),
);
const latestInputData = useNodeStore(
useShallow((state) =>
nodeId ? state.getLatestNodeInputData(nodeId) : undefined,
),
);
const accumulatedOutputData = useNodeStore(
useShallow((state) =>
nodeId ? state.getAccumulatedNodeOutputData(nodeId) : {},
),
);
const resolvedData =
data ??
(dataType === "input"
? (latestInputData ?? {})
: (accumulatedOutputData[pinName] ?? []));
const { const {
outputItems, outputItems,
copyExecutionId, copyExecutionId,
@@ -42,7 +71,20 @@ export const NodeDataViewer: FC<NodeDataViewerProps> = ({
handleDownloadItem, handleDownloadItem,
dataArray, dataArray,
copiedIndex, copiedIndex,
} = useNodeDataViewer(data, pinName, execId); groupedExecutions,
totalGroupedItems,
handleCopyGroupedItem,
handleDownloadGroupedItem,
copiedKey,
} = useNodeDataViewer(
resolvedData,
pinName,
execId,
executionResults,
dataType,
);
const shouldGroupExecutions = groupedExecutions.length > 0;
return ( return (
<Dialog styling={{ width: "600px" }}> <Dialog styling={{ width: "600px" }}>
<TooltipProvider> <TooltipProvider>
@@ -68,44 +110,141 @@ export const NodeDataViewer: FC<NodeDataViewerProps> = ({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text variant="large-medium" className="text-slate-900"> <Text variant="large-medium" className="text-slate-900">
Full Output Preview Full {dataType === "input" ? "Input" : "Output"} Preview
</Text> </Text>
</div> </div>
<div className="rounded-full border border-slate-300 bg-slate-100 px-3 py-1.5 text-xs font-medium text-black"> <div className="rounded-full border border-slate-300 bg-slate-100 px-3 py-1.5 text-xs font-medium text-black">
{dataArray.length} item{dataArray.length !== 1 ? "s" : ""} total {shouldGroupExecutions ? totalGroupedItems : dataArray.length}{" "}
item
{shouldGroupExecutions
? totalGroupedItems !== 1
? "s"
: ""
: dataArray.length !== 1
? "s"
: ""}{" "}
total
</div> </div>
</div> </div>
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
<div className="flex items-center gap-2"> {shouldGroupExecutions ? (
<Text variant="body" className="text-slate-600"> <div>
Execution ID: Pin:{" "}
</Text> <span className="font-semibold">{beautifyString(pinName)}</span>
<Text </div>
variant="body-medium" ) : (
className="rounded-full border border-gray-300 bg-gray-50 px-2 py-1 font-mono text-xs" <>
> <div className="flex items-center gap-2">
{execId} <Text variant="body" className="text-slate-600">
</Text> Execution ID:
<Button </Text>
variant="ghost" <Text
size="small" variant="body-medium"
onClick={copyExecutionId} className="rounded-full border border-gray-300 bg-gray-50 px-2 py-1 font-mono text-xs"
className="h-6 w-6 min-w-0 p-0" >
> {execId}
<CopyIcon size={14} /> </Text>
</Button> <Button
</div> variant="ghost"
<div className="mt-2"> size="small"
Pin:{" "} onClick={copyExecutionId}
<span className="font-semibold">{beautifyString(pinName)}</span> className="h-6 w-6 min-w-0 p-0"
</div> >
<CopyIcon size={14} />
</Button>
</div>
<div className="mt-2">
Pin:{" "}
<span className="font-semibold">
{beautifyString(pinName)}
</span>
</div>
</>
)}
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="my-4"> <div className="my-4">
{dataArray.length > 0 ? ( {shouldGroupExecutions ? (
<div className="space-y-4">
{groupedExecutions.map((execution) => (
<div
key={execution.execId}
className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm"
>
<div className="flex items-center gap-2">
<Text variant="body" className="text-slate-600">
Execution ID:
</Text>
<Text
variant="body-medium"
className="rounded-full border border-gray-300 bg-gray-50 px-2 py-1 font-mono text-xs"
>
{execution.execId}
</Text>
</div>
<div className="mt-2 space-y-4">
{execution.outputItems.length > 0 ? (
execution.outputItems.map((item, index) => (
<div
key={item.key}
className="group flex items-start gap-4"
>
<div className="w-full flex-1">
<OutputItem
value={item.value}
metadata={item.metadata}
renderer={item.renderer}
/>
</div>
<div className="flex w-fit gap-3">
<Button
variant="secondary"
className="min-w-0 p-1"
size="icon"
onClick={() =>
handleCopyGroupedItem(
execution.execId,
index,
item,
)
}
aria-label="Copy item"
>
{copiedKey ===
`${execution.execId}-${index}` ? (
<CheckIcon className="size-4 text-green-600" />
) : (
<CopyIcon className="size-4 text-black" />
)}
</Button>
<Button
variant="secondary"
size="icon"
className="min-w-0 p-1"
onClick={() =>
handleDownloadGroupedItem(item)
}
aria-label="Download item"
>
<DownloadIcon className="size-4 text-black" />
</Button>
</div>
</div>
))
) : (
<div className="py-4 text-center text-gray-500">
No data available
</div>
)}
</div>
</div>
))}
</div>
) : dataArray.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{outputItems.map((item, index) => ( {outputItems.map((item, index) => (
<div key={item.key} className="group relative"> <div key={item.key} className="group relative">

View File

@@ -1,82 +1,70 @@
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
import { globalRegistry } from "@/components/contextual/OutputRenderers";
import { downloadOutputs } from "@/components/contextual/OutputRenderers/utils/download"; import { downloadOutputs } from "@/components/contextual/OutputRenderers/utils/download";
import { useToast } from "@/components/molecules/Toast/use-toast"; import { useToast } from "@/components/molecules/Toast/use-toast";
import { beautifyString } from "@/lib/utils"; import { beautifyString } from "@/lib/utils";
import React, { useMemo, useState } from "react"; import { useState } from "react";
import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import {
NodeDataType,
createOutputItems,
getExecutionData,
normalizeToArray,
type OutputItem,
} from "../../helpers";
export type GroupedExecution = {
execId: string;
outputItems: Array<OutputItem>;
};
export const useNodeDataViewer = ( export const useNodeDataViewer = (
data: any, data: any,
pinName: string, pinName: string,
execId: string, execId: string,
executionResults?: NodeExecutionResult[],
dataType?: NodeDataType,
) => { ) => {
const { toast } = useToast(); const { toast } = useToast();
const [copiedIndex, setCopiedIndex] = useState<number | null>(null); const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Normalize data to array format const dataArray = Array.isArray(data) ? data : [data];
const dataArray = useMemo(() => {
return Array.isArray(data) ? data : [data];
}, [data]);
// Prepare items for the enhanced renderer system const outputItems =
const outputItems = useMemo(() => { !dataArray || dataArray.length === 0
if (!dataArray) return []; ? []
: createOutputItems(dataArray).map((item, index) => ({
const items: Array<{ ...item,
key: string;
label: string;
value: unknown;
metadata?: OutputMetadata;
renderer: any;
}> = [];
dataArray.forEach((value, index) => {
const metadata: OutputMetadata = {};
// Extract metadata from the value if it's an object
if (
typeof value === "object" &&
value !== null &&
!React.isValidElement(value)
) {
const objValue = value as any;
if (objValue.type) metadata.type = objValue.type;
if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
if (objValue.filename) metadata.filename = objValue.filename;
if (objValue.language) metadata.language = objValue.language;
}
const renderer = globalRegistry.getRenderer(value, metadata);
if (renderer) {
items.push({
key: `item-${index}`,
label: index === 0 ? beautifyString(pinName) : "", label: index === 0 ? beautifyString(pinName) : "",
value, }));
metadata,
renderer,
});
} else {
// Fallback to text renderer
const textRenderer = globalRegistry
.getAllRenderers()
.find((r) => r.name === "TextRenderer");
if (textRenderer) {
items.push({
key: `item-${index}`,
label: index === 0 ? beautifyString(pinName) : "",
value:
typeof value === "string"
? value
: JSON.stringify(value, null, 2),
metadata,
renderer: textRenderer,
});
}
}
});
return items; const groupedExecutions =
}, [dataArray, pinName]); !executionResults || executionResults.length === 0
? []
: [...executionResults].reverse().map((result) => {
const rawData = getExecutionData(
result,
dataType || "output",
pinName,
);
let dataArray: unknown[];
if (dataType === "input") {
dataArray =
rawData !== undefined && rawData !== null ? [rawData] : [];
} else {
dataArray = normalizeToArray(rawData);
}
const outputItems = createOutputItems(dataArray);
return {
execId: result.node_exec_id,
outputItems,
};
});
const totalGroupedItems = groupedExecutions.reduce(
(total, execution) => total + execution.outputItems.length,
0,
);
const copyExecutionId = () => { const copyExecutionId = () => {
navigator.clipboard.writeText(execId).then(() => { navigator.clipboard.writeText(execId).then(() => {
@@ -122,6 +110,45 @@ export const useNodeDataViewer = (
]); ]);
}; };
const handleCopyGroupedItem = async (
execId: string,
index: number,
item: OutputItem,
) => {
const copyContent = item.renderer.getCopyContent(item.value, item.metadata);
if (!copyContent) {
return;
}
try {
let text: string;
if (typeof copyContent.data === "string") {
text = copyContent.data;
} else if (copyContent.fallbackText) {
text = copyContent.fallbackText;
} else {
return;
}
await navigator.clipboard.writeText(text);
setCopiedKey(`${execId}-${index}`);
setTimeout(() => setCopiedKey(null), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
const handleDownloadGroupedItem = (item: OutputItem) => {
downloadOutputs([
{
value: item.value,
metadata: item.metadata,
renderer: item.renderer,
},
]);
};
return { return {
outputItems, outputItems,
dataArray, dataArray,
@@ -129,5 +156,10 @@ export const useNodeDataViewer = (
handleCopyItem, handleCopyItem,
handleDownloadItem, handleDownloadItem,
copiedIndex, copiedIndex,
groupedExecutions,
totalGroupedItems,
handleCopyGroupedItem,
handleDownloadGroupedItem,
copiedKey,
}; };
}; };

View File

@@ -8,16 +8,28 @@ import { useState } from "react";
import { NodeDataViewer } from "./NodeDataViewer/NodeDataViewer"; import { NodeDataViewer } from "./NodeDataViewer/NodeDataViewer";
import { useToast } from "@/components/molecules/Toast/use-toast"; import { useToast } from "@/components/molecules/Toast/use-toast";
import { CheckIcon, CopyIcon } from "@phosphor-icons/react"; import { CheckIcon, CopyIcon } from "@phosphor-icons/react";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useShallow } from "zustand/react/shallow";
import {
NodeDataType,
getExecutionEntries,
normalizeToArray,
} from "../helpers";
export const ViewMoreData = ({ export const ViewMoreData = ({
outputData, nodeId,
execId, dataType = "output",
}: { }: {
outputData: Record<string, Array<any>>; nodeId: string;
execId?: string; dataType?: NodeDataType;
}) => { }) => {
const [copiedKey, setCopiedKey] = useState<string | null>(null); const [copiedKey, setCopiedKey] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const executionResults = useNodeStore(
useShallow((state) => state.getNodeExecutionResults(nodeId)),
);
const reversedExecutionResults = [...executionResults].reverse();
const handleCopy = (key: string, value: any) => { const handleCopy = (key: string, value: any) => {
const textToCopy = const textToCopy =
@@ -29,8 +41,8 @@ export const ViewMoreData = ({
setTimeout(() => setCopiedKey(null), 2000); setTimeout(() => setCopiedKey(null), 2000);
}; };
const copyExecutionId = () => { const copyExecutionId = (executionId: string) => {
navigator.clipboard.writeText(execId || "N/A").then(() => { navigator.clipboard.writeText(executionId || "N/A").then(() => {
toast({ toast({
title: "Execution ID copied to clipboard!", title: "Execution ID copied to clipboard!",
duration: 2000, duration: 2000,
@@ -42,7 +54,7 @@ export const ViewMoreData = ({
<Dialog styling={{ width: "600px", paddingRight: "16px" }}> <Dialog styling={{ width: "600px", paddingRight: "16px" }}>
<Dialog.Trigger> <Dialog.Trigger>
<Button <Button
variant="primary" variant="secondary"
size="small" size="small"
className="h-fit w-fit min-w-0 !text-xs" className="h-fit w-fit min-w-0 !text-xs"
> >
@@ -52,83 +64,114 @@ export const ViewMoreData = ({
<Dialog.Content> <Dialog.Content>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Text variant="h4" className="text-slate-900"> <Text variant="h4" className="text-slate-900">
Complete Output Data Complete {dataType === "input" ? "Input" : "Output"} Data
</Text> </Text>
<div className="flex items-center gap-2">
<Text variant="body" className="text-slate-600">
Execution ID:
</Text>
<Text
variant="body-medium"
className="rounded-full border border-gray-300 bg-gray-50 px-2 py-1 font-mono text-xs"
>
{execId}
</Text>
<Button
variant="ghost"
size="small"
onClick={copyExecutionId}
className="h-6 w-6 min-w-0 p-0"
>
<CopyIcon size={14} />
</Button>
</div>
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{Object.entries(outputData).map(([key, value]) => ( {reversedExecutionResults.map((result) => (
<div key={key} className="flex flex-col gap-2"> <div
key={result.node_exec_id}
className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm"
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text variant="body" className="text-slate-600">
Execution ID:
</Text>
<Text <Text
variant="body-medium" variant="body-medium"
className="!font-semibold text-slate-600" className="rounded-full border border-gray-300 bg-gray-50 px-2 py-1 font-mono text-xs"
> >
Pin: {result.node_exec_id}
</Text>
<Text variant="body-medium" className="text-slate-700">
{beautifyString(key)}
</Text> </Text>
<Button
variant="ghost"
size="small"
onClick={() => copyExecutionId(result.node_exec_id)}
className="h-6 w-6 min-w-0 p-0"
>
<CopyIcon size={14} />
</Button>
</div> </div>
<div className="w-full space-y-2">
<Text
variant="body-medium"
className="!font-semibold text-slate-600"
>
Data:
</Text>
<div className="relative space-y-2">
{value.map((item, index) => (
<div key={index}>
<ContentRenderer value={item} shortContent={false} />
</div>
))}
<div className="mt-1 flex justify-end gap-1"> <div className="mt-4 flex flex-col gap-4">
<NodeDataViewer {getExecutionEntries(result, dataType).map(
data={value} ([key, value]) => {
pinName={key} const normalizedValue = normalizeToArray(value);
execId={execId} return (
isViewMoreData={true} <div key={key} className="flex flex-col gap-2">
/> <div className="flex items-center gap-2">
<Button <Text
variant="secondary" variant="body-medium"
size="small" className="!font-semibold text-slate-600"
onClick={() => handleCopy(key, value)} >
className={cn( Pin:
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900", </Text>
copiedKey === key && <Text
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200", variant="body-medium"
)} className="text-slate-700"
> >
{copiedKey === key ? ( {beautifyString(key)}
<CheckIcon size={16} className="text-green-600" /> </Text>
) : ( </div>
<CopyIcon size={16} /> <div className="w-full space-y-2">
)} <Text
</Button> variant="body-medium"
</div> className="!font-semibold text-slate-600"
</div> >
Data:
</Text>
<div className="relative space-y-2">
{normalizedValue.map((item, index) => (
<div key={index}>
<ContentRenderer
value={item}
shortContent={false}
/>
</div>
))}
<div className="mt-1 flex justify-end gap-1">
<NodeDataViewer
data={normalizedValue}
pinName={key}
execId={result.node_exec_id}
isViewMoreData={true}
dataType={dataType}
/>
<Button
variant="secondary"
size="small"
onClick={() =>
handleCopy(
`${result.node_exec_id}-${key}`,
normalizedValue,
)
}
className={cn(
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
copiedKey ===
`${result.node_exec_id}-${key}` &&
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
)}
>
{copiedKey ===
`${result.node_exec_id}-${key}` ? (
<CheckIcon
size={16}
className="text-green-600"
/>
) : (
<CopyIcon size={16} />
)}
</Button>
</div>
</div>
</div>
</div>
);
},
)}
</div> </div>
</div> </div>
))} ))}

View File

@@ -0,0 +1,83 @@
import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
import { globalRegistry } from "@/components/contextual/OutputRenderers";
import React from "react";
export type NodeDataType = "input" | "output";
export type OutputItem = {
key: string;
value: unknown;
metadata?: OutputMetadata;
renderer: any;
};
export const normalizeToArray = (value: unknown) => {
if (value === undefined) return [];
return Array.isArray(value) ? value : [value];
};
export const getExecutionData = (
result: NodeExecutionResult,
dataType: NodeDataType,
pinName: string,
) => {
if (dataType === "input") {
return result.input_data;
}
return result.output_data?.[pinName];
};
export const createOutputItems = (dataArray: unknown[]): Array<OutputItem> => {
const items: Array<OutputItem> = [];
dataArray.forEach((value, index) => {
const metadata: OutputMetadata = {};
if (
typeof value === "object" &&
value !== null &&
!React.isValidElement(value)
) {
const objValue = value as any;
if (objValue.type) metadata.type = objValue.type;
if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
if (objValue.filename) metadata.filename = objValue.filename;
if (objValue.language) metadata.language = objValue.language;
}
const renderer = globalRegistry.getRenderer(value, metadata);
if (renderer) {
items.push({
key: `item-${index}`,
value,
metadata,
renderer,
});
} else {
const textRenderer = globalRegistry
.getAllRenderers()
.find((r) => r.name === "TextRenderer");
if (textRenderer) {
items.push({
key: `item-${index}`,
value:
typeof value === "string" ? value : JSON.stringify(value, null, 2),
metadata,
renderer: textRenderer,
});
}
}
});
return items;
};
export const getExecutionEntries = (
result: NodeExecutionResult,
dataType: NodeDataType,
) => {
const data = dataType === "input" ? result.input_data : result.output_data;
return Object.entries(data || {});
};

View File

@@ -7,15 +7,18 @@ export const useNodeOutput = (nodeId: string) => {
const [copiedKey, setCopiedKey] = useState<string | null>(null); const [copiedKey, setCopiedKey] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const nodeExecutionResult = useNodeStore( const latestResult = useNodeStore(
useShallow((state) => state.getNodeExecutionResult(nodeId)), useShallow((state) => state.getLatestNodeExecutionResult(nodeId)),
); );
const inputData = nodeExecutionResult?.input_data; const latestInputData = useNodeStore(
useShallow((state) => state.getLatestNodeInputData(nodeId)),
);
const latestOutputData: Record<string, Array<any>> = useNodeStore(
useShallow((state) => state.getLatestNodeOutputData(nodeId) || {}),
);
const outputData: Record<string, Array<any>> = {
...nodeExecutionResult?.output_data,
};
const handleCopy = async (key: string, value: any) => { const handleCopy = async (key: string, value: any) => {
try { try {
const text = JSON.stringify(value, null, 2); const text = JSON.stringify(value, null, 2);
@@ -35,11 +38,12 @@ export const useNodeOutput = (nodeId: string) => {
}); });
} }
}; };
return { return {
outputData, latestOutputData,
inputData, latestInputData,
copiedKey, copiedKey,
handleCopy, handleCopy,
executionResultId: nodeExecutionResult?.node_exec_id, executionResultId: latestResult?.node_exec_id,
}; };
}; };

View File

@@ -1,10 +1,7 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore"; import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
useNodeStore,
NodeResolutionData,
} from "@/app/(platform)/build/stores/nodeStore";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { import {
useSubAgentUpdate, useSubAgentUpdate,
@@ -13,6 +10,7 @@ import {
} from "@/app/(platform)/build/hooks/useSubAgentUpdate"; } from "@/app/(platform)/build/hooks/useSubAgentUpdate";
import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api"; import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
import { CustomNodeData } from "../../CustomNode"; import { CustomNodeData } from "../../CustomNode";
import { NodeResolutionData } from "@/app/(platform)/build/stores/types";
// Stable empty set to avoid creating new references in selectors // Stable empty set to avoid creating new references in selectors
const EMPTY_SET: Set<string> = new Set(); const EMPTY_SET: Set<string> = new Set();

View File

@@ -1,5 +1,5 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus"; import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { NodeResolutionData } from "@/app/(platform)/build/stores/nodeStore"; import { NodeResolutionData } from "@/app/(platform)/build/stores/types";
import { RJSFSchema } from "@rjsf/utils"; import { RJSFSchema } from "@rjsf/utils";
export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = { export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {

View File

@@ -0,0 +1,16 @@
export const accumulateExecutionData = (
accumulated: Record<string, unknown[]>,
data: Record<string, unknown> | undefined,
) => {
if (!data) return { ...accumulated };
const next = { ...accumulated };
Object.entries(data).forEach(([key, values]) => {
const nextValues = Array.isArray(values) ? values : [values];
if (next[key]) {
next[key] = [...next[key], ...nextValues];
} else {
next[key] = [...nextValues];
}
});
return next;
};

View File

@@ -10,6 +10,8 @@ import {
import { Node } from "@/app/api/__generated__/models/node"; import { Node } from "@/app/api/__generated__/models/node";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus"; import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult"; import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import { NodeExecutionResultInputData } from "@/app/api/__generated__/models/nodeExecutionResultInputData";
import { NodeExecutionResultOutputData } from "@/app/api/__generated__/models/nodeExecutionResultOutputData";
import { useHistoryStore } from "./historyStore"; import { useHistoryStore } from "./historyStore";
import { useEdgeStore } from "./edgeStore"; import { useEdgeStore } from "./edgeStore";
import { BlockUIType } from "../components/types"; import { BlockUIType } from "../components/types";
@@ -18,31 +20,10 @@ import {
ensurePathExists, ensurePathExists,
parseHandleIdToPath, parseHandleIdToPath,
} from "@/components/renderers/InputRenderer/helpers"; } from "@/components/renderers/InputRenderer/helpers";
import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/types"; import { accumulateExecutionData } from "./helpers";
import { NodeResolutionData } from "./types";
// Resolution mode data stored per node
export type NodeResolutionData = {
incompatibilities: IncompatibilityInfo;
// The NEW schema from the update (what we're updating TO)
pendingUpdate: {
input_schema: Record<string, unknown>;
output_schema: Record<string, unknown>;
};
// The OLD schema before the update (what we're updating FROM)
// Needed to merge and show removed inputs during resolution
currentSchema: {
input_schema: Record<string, unknown>;
output_schema: Record<string, unknown>;
};
// The full updated hardcoded values to apply when resolution completes
pendingHardcodedValues: Record<string, unknown>;
};
// Minimum movement (in pixels) required before logging position change to history
// Prevents spamming history with small movements when clicking on inputs inside blocks
const MINIMUM_MOVE_BEFORE_LOG = 50; const MINIMUM_MOVE_BEFORE_LOG = 50;
// Track initial positions when drag starts (outside store to avoid re-renders)
const dragStartPositions: Record<string, XYPosition> = {}; const dragStartPositions: Record<string, XYPosition> = {};
let dragStartState: { nodes: CustomNode[]; edges: CustomEdge[] } | null = null; let dragStartState: { nodes: CustomNode[]; edges: CustomEdge[] } | null = null;
@@ -52,6 +33,15 @@ type NodeStore = {
nodeCounter: number; nodeCounter: number;
setNodeCounter: (nodeCounter: number) => void; setNodeCounter: (nodeCounter: number) => void;
nodeAdvancedStates: Record<string, boolean>; nodeAdvancedStates: Record<string, boolean>;
latestNodeInputData: Record<string, NodeExecutionResultInputData | undefined>;
latestNodeOutputData: Record<
string,
NodeExecutionResultOutputData | undefined
>;
accumulatedNodeInputData: Record<string, Record<string, unknown[]>>;
accumulatedNodeOutputData: Record<string, Record<string, unknown[]>>;
setNodes: (nodes: CustomNode[]) => void; setNodes: (nodes: CustomNode[]) => void;
onNodesChange: (changes: NodeChange<CustomNode>[]) => void; onNodesChange: (changes: NodeChange<CustomNode>[]) => void;
addNode: (node: CustomNode) => void; addNode: (node: CustomNode) => void;
@@ -72,12 +62,26 @@ type NodeStore = {
updateNodeStatus: (nodeId: string, status: AgentExecutionStatus) => void; updateNodeStatus: (nodeId: string, status: AgentExecutionStatus) => void;
getNodeStatus: (nodeId: string) => AgentExecutionStatus | undefined; getNodeStatus: (nodeId: string) => AgentExecutionStatus | undefined;
cleanNodesStatuses: () => void;
updateNodeExecutionResult: ( updateNodeExecutionResult: (
nodeId: string, nodeId: string,
result: NodeExecutionResult, result: NodeExecutionResult,
) => void; ) => void;
getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined; getNodeExecutionResults: (nodeId: string) => NodeExecutionResult[];
getLatestNodeInputData: (
nodeId: string,
) => NodeExecutionResultInputData | undefined;
getLatestNodeOutputData: (
nodeId: string,
) => NodeExecutionResultOutputData | undefined;
getAccumulatedNodeInputData: (nodeId: string) => Record<string, unknown[]>;
getAccumulatedNodeOutputData: (nodeId: string) => Record<string, unknown[]>;
getLatestNodeExecutionResult: (
nodeId: string,
) => NodeExecutionResult | undefined;
clearAllNodeExecutionResults: () => void;
getNodeBlockUIType: (nodeId: string) => BlockUIType; getNodeBlockUIType: (nodeId: string) => BlockUIType;
hasWebhookNodes: () => boolean; hasWebhookNodes: () => boolean;
@@ -122,6 +126,10 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
nodeCounter: 0, nodeCounter: 0,
setNodeCounter: (nodeCounter) => set({ nodeCounter }), setNodeCounter: (nodeCounter) => set({ nodeCounter }),
nodeAdvancedStates: {}, nodeAdvancedStates: {},
latestNodeInputData: {},
latestNodeOutputData: {},
accumulatedNodeInputData: {},
accumulatedNodeOutputData: {},
incrementNodeCounter: () => incrementNodeCounter: () =>
set((state) => ({ set((state) => ({
nodeCounter: state.nodeCounter + 1, nodeCounter: state.nodeCounter + 1,
@@ -317,17 +325,162 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
return get().nodes.find((n) => n.id === nodeId)?.data?.status; return get().nodes.find((n) => n.id === nodeId)?.data?.status;
}, },
updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => { cleanNodesStatuses: () => {
set((state) => ({ set((state) => ({
nodes: state.nodes.map((n) => nodes: state.nodes.map((n) => ({
n.id === nodeId ...n,
? { ...n, data: { ...n.data, nodeExecutionResult: result } } data: { ...n.data, status: undefined },
: n, })),
),
})); }));
}, },
getNodeExecutionResult: (nodeId: string) => {
return get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResult; updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => {
set((state) => {
let latestNodeInputData = state.latestNodeInputData;
let latestNodeOutputData = state.latestNodeOutputData;
let accumulatedNodeInputData = state.accumulatedNodeInputData;
let accumulatedNodeOutputData = state.accumulatedNodeOutputData;
const nodes = state.nodes.map((n) => {
if (n.id !== nodeId) return n;
const existingResults = n.data.nodeExecutionResults || [];
const duplicateIndex = existingResults.findIndex(
(r) => r.node_exec_id === result.node_exec_id,
);
if (duplicateIndex !== -1) {
const oldResult = existingResults[duplicateIndex];
const inputDataChanged =
JSON.stringify(oldResult.input_data) !==
JSON.stringify(result.input_data);
const outputDataChanged =
JSON.stringify(oldResult.output_data) !==
JSON.stringify(result.output_data);
if (!inputDataChanged && !outputDataChanged) {
return n;
}
const updatedResults = [...existingResults];
updatedResults[duplicateIndex] = result;
const recomputedAccumulatedInput = updatedResults.reduce(
(acc, r) => accumulateExecutionData(acc, r.input_data),
{} as Record<string, unknown[]>,
);
const recomputedAccumulatedOutput = updatedResults.reduce(
(acc, r) => accumulateExecutionData(acc, r.output_data),
{} as Record<string, unknown[]>,
);
const mostRecentResult = updatedResults[updatedResults.length - 1];
latestNodeInputData = {
...latestNodeInputData,
[nodeId]: mostRecentResult.input_data,
};
latestNodeOutputData = {
...latestNodeOutputData,
[nodeId]: mostRecentResult.output_data,
};
accumulatedNodeInputData = {
...accumulatedNodeInputData,
[nodeId]: recomputedAccumulatedInput,
};
accumulatedNodeOutputData = {
...accumulatedNodeOutputData,
[nodeId]: recomputedAccumulatedOutput,
};
return {
...n,
data: {
...n.data,
nodeExecutionResults: updatedResults,
},
};
}
accumulatedNodeInputData = {
...accumulatedNodeInputData,
[nodeId]: accumulateExecutionData(
accumulatedNodeInputData[nodeId] || {},
result.input_data,
),
};
accumulatedNodeOutputData = {
...accumulatedNodeOutputData,
[nodeId]: accumulateExecutionData(
accumulatedNodeOutputData[nodeId] || {},
result.output_data,
),
};
latestNodeInputData = {
...latestNodeInputData,
[nodeId]: result.input_data,
};
latestNodeOutputData = {
...latestNodeOutputData,
[nodeId]: result.output_data,
};
return {
...n,
data: {
...n.data,
nodeExecutionResults: [...existingResults, result],
},
};
});
return {
nodes,
latestNodeInputData,
latestNodeOutputData,
accumulatedNodeInputData,
accumulatedNodeOutputData,
};
});
},
getNodeExecutionResults: (nodeId: string) => {
return (
get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResults || []
);
},
getLatestNodeInputData: (nodeId: string) => {
return get().latestNodeInputData[nodeId];
},
getLatestNodeOutputData: (nodeId: string) => {
return get().latestNodeOutputData[nodeId];
},
getAccumulatedNodeInputData: (nodeId: string) => {
return get().accumulatedNodeInputData[nodeId] || {};
},
getAccumulatedNodeOutputData: (nodeId: string) => {
return get().accumulatedNodeOutputData[nodeId] || {};
},
getLatestNodeExecutionResult: (nodeId: string) => {
const results =
get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResults ||
[];
return results.length > 0 ? results[results.length - 1] : undefined;
},
clearAllNodeExecutionResults: () => {
set((state) => ({
nodes: state.nodes.map((n) => ({
...n,
data: {
...n.data,
nodeExecutionResults: [],
},
})),
latestNodeInputData: {},
latestNodeOutputData: {},
accumulatedNodeInputData: {},
accumulatedNodeOutputData: {},
}));
}, },
getNodeBlockUIType: (nodeId: string) => { getNodeBlockUIType: (nodeId: string) => {
return ( return (

View File

@@ -0,0 +1,14 @@
import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/types";
export type NodeResolutionData = {
incompatibilities: IncompatibilityInfo;
pendingUpdate: {
input_schema: Record<string, unknown>;
output_schema: Record<string, unknown>;
};
currentSchema: {
input_schema: Record<string, unknown>;
output_schema: Record<string, unknown>;
};
pendingHardcodedValues: Record<string, unknown>;
};

View File

@@ -6,28 +6,40 @@ import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { getQueryClient } from "@/lib/react-query/queryClient"; import { getQueryClient } from "@/lib/react-query/queryClient";
import CredentialsProvider from "@/providers/agent-credentials/credentials-provider"; import CredentialsProvider from "@/providers/agent-credentials/credentials-provider";
import OnboardingProvider from "@/providers/onboarding/onboarding-provider"; import OnboardingProvider from "@/providers/onboarding/onboarding-provider";
import {
PostHogPageViewTracker,
PostHogProvider,
PostHogUserTracker,
} from "@/providers/posthog/posthog-provider";
import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider"; import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider, ThemeProviderProps } from "next-themes"; import { ThemeProvider, ThemeProviderProps } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app"; import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Suspense } from "react";
export function Providers({ children, ...props }: ThemeProviderProps) { export function Providers({ children, ...props }: ThemeProviderProps) {
const queryClient = getQueryClient(); const queryClient = getQueryClient();
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<NuqsAdapter> <NuqsAdapter>
<BackendAPIProvider> <PostHogProvider>
<SentryUserTracker /> <BackendAPIProvider>
<CredentialsProvider> <SentryUserTracker />
<LaunchDarklyProvider> <PostHogUserTracker />
<OnboardingProvider> <Suspense fallback={null}>
<ThemeProvider forcedTheme="light" {...props}> <PostHogPageViewTracker />
<TooltipProvider>{children}</TooltipProvider> </Suspense>
</ThemeProvider> <CredentialsProvider>
</OnboardingProvider> <LaunchDarklyProvider>
</LaunchDarklyProvider> <OnboardingProvider>
</CredentialsProvider> <ThemeProvider forcedTheme="light" {...props}>
</BackendAPIProvider> <TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</CredentialsProvider>
</BackendAPIProvider>
</PostHogProvider>
</NuqsAdapter> </NuqsAdapter>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -213,6 +213,23 @@ export function parseToolResponse(
timestamp: timestamp || new Date(), timestamp: timestamp || new Date(),
}; };
} }
if (responseType === "clarification_needed") {
return {
type: "clarification_needed",
toolName,
questions:
(parsedResult.questions as Array<{
question: string;
keyword: string;
example?: string;
}>) || [],
message:
(parsedResult.message as string) ||
"I need more information to proceed.",
sessionId: (parsedResult.session_id as string) || "",
timestamp: timestamp || new Date(),
};
}
if (responseType === "need_login") { if (responseType === "need_login") {
return { return {
type: "login_needed", type: "login_needed",

View File

@@ -14,6 +14,7 @@ import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessa
import { AIChatBubble } from "../AIChatBubble/AIChatBubble"; import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget"; import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget";
import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup"; import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup";
import { ClarificationQuestionsWidget } from "../ClarificationQuestionsWidget/ClarificationQuestionsWidget";
import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage"; import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage";
import { MarkdownContent } from "../MarkdownContent/MarkdownContent"; import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage"; import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage";
@@ -69,6 +70,7 @@ export function ChatMessage({
isToolResponse, isToolResponse,
isLoginNeeded, isLoginNeeded,
isCredentialsNeeded, isCredentialsNeeded,
isClarificationNeeded,
} = useChatMessage(message); } = useChatMessage(message);
const displayContent = getDisplayContent(message, isUser); const displayContent = getDisplayContent(message, isUser);
@@ -96,6 +98,18 @@ export function ChatMessage({
} }
} }
function handleClarificationAnswers(answers: Record<string, string>) {
if (onSendMessage) {
const contextMessage = Object.entries(answers)
.map(([keyword, answer]) => `${keyword}: ${answer}`)
.join("\n");
onSendMessage(
`I have the answers to your questions:\n\n${contextMessage}\n\nPlease proceed with creating the agent.`,
);
}
}
const handleCopy = useCallback( const handleCopy = useCallback(
async function handleCopy() { async function handleCopy() {
if (message.type !== "message") return; if (message.type !== "message") return;
@@ -141,6 +155,17 @@ export function ChatMessage({
); );
} }
if (isClarificationNeeded && message.type === "clarification_needed") {
return (
<ClarificationQuestionsWidget
questions={message.questions}
message={message.message}
onSubmitAnswers={handleClarificationAnswers}
className={className}
/>
);
}
// Render login needed messages // Render login needed messages
if (isLoginNeeded && message.type === "login_needed") { if (isLoginNeeded && message.type === "login_needed") {
// If user is already logged in, show success message instead of auth prompt // If user is already logged in, show success message instead of auth prompt

View File

@@ -91,6 +91,18 @@ export type ChatMessageData =
credentialsSchema?: Record<string, any>; credentialsSchema?: Record<string, any>;
message: string; message: string;
timestamp?: string | Date; timestamp?: string | Date;
}
| {
type: "clarification_needed";
toolName: string;
questions: Array<{
question: string;
keyword: string;
example?: string;
}>;
message: string;
sessionId: string;
timestamp?: string | Date;
}; };
export function useChatMessage(message: ChatMessageData) { export function useChatMessage(message: ChatMessageData) {
@@ -111,5 +123,6 @@ export function useChatMessage(message: ChatMessageData) {
isAgentCarousel: message.type === "agent_carousel", isAgentCarousel: message.type === "agent_carousel",
isExecutionStarted: message.type === "execution_started", isExecutionStarted: message.type === "execution_started",
isInputsNeeded: message.type === "inputs_needed", isInputsNeeded: message.type === "inputs_needed",
isClarificationNeeded: message.type === "clarification_needed",
}; };
} }

View File

@@ -0,0 +1,154 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Card } from "@/components/atoms/Card/Card";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { CheckCircleIcon, QuestionIcon } from "@phosphor-icons/react";
import { useState } from "react";
export interface ClarifyingQuestion {
question: string;
keyword: string;
example?: string;
}
interface Props {
questions: ClarifyingQuestion[];
message: string;
onSubmitAnswers: (answers: Record<string, string>) => void;
onCancel?: () => void;
className?: string;
}
export function ClarificationQuestionsWidget({
questions,
message,
onSubmitAnswers,
onCancel,
className,
}: Props) {
const [answers, setAnswers] = useState<Record<string, string>>({});
function handleAnswerChange(keyword: string, value: string) {
setAnswers((prev) => ({ ...prev, [keyword]: value }));
}
function handleSubmit() {
// Check if all questions are answered
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
if (!allAnswered) {
return;
}
onSubmitAnswers(answers);
}
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
return (
<div
className={cn(
"group relative flex w-full justify-start gap-3 px-4 py-3",
className,
)}
>
<div className="flex w-full max-w-3xl gap-3">
<div className="flex-shrink-0">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
<QuestionIcon className="h-4 w-4 text-indigo-50" weight="bold" />
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<Card className="space-y-4 p-4">
<div>
<Text variant="h4" className="mb-1 text-slate-900">
I need more information
</Text>
<Text variant="small" className="text-slate-600">
{message}
</Text>
</div>
<div className="space-y-3">
{questions.map((q, index) => {
const isAnswered = !!answers[q.keyword]?.trim();
return (
<div
key={`${q.keyword}-${index}`}
className={cn(
"relative rounded-lg border p-3",
isAnswered
? "border-green-500 bg-green-50/50"
: "border-slate-200 bg-white/50",
)}
>
<div className="mb-2 flex items-start gap-2">
{isAnswered ? (
<CheckCircleIcon
size={16}
className="mt-0.5 text-green-500"
weight="bold"
/>
) : (
<div className="mt-0.5 flex h-4 w-4 items-center justify-center rounded-full border border-slate-300 bg-white text-xs text-slate-500">
{index + 1}
</div>
)}
<div className="flex-1">
<Text
variant="small"
className="mb-2 font-semibold text-slate-900"
>
{q.question}
</Text>
{q.example && (
<Text
variant="small"
className="mb-2 italic text-slate-500"
>
Example: {q.example}
</Text>
)}
<Input
type="textarea"
id={`clarification-${q.keyword}-${index}`}
label={q.question}
hideLabel
placeholder="Your answer..."
rows={2}
value={answers[q.keyword] || ""}
onChange={(e) =>
handleAnswerChange(q.keyword, e.target.value)
}
/>
</div>
</div>
</div>
);
})}
</div>
<div className="flex gap-2">
<Button
onClick={handleSubmit}
disabled={!allAnswered}
className="flex-1"
variant="primary"
>
Submit Answers
</Button>
{onCancel && (
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
)}
</div>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { environment } from "@/services/environment";
import { PostHogProvider as PHProvider } from "@posthog/react";
import { usePathname, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { ReactNode, useEffect, useRef } from "react";
export function PostHogProvider({ children }: { children: ReactNode }) {
const isPostHogEnabled = environment.isPostHogEnabled();
useEffect(() => {
if (process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: "2025-11-30",
capture_pageview: false,
capture_pageleave: true,
autocapture: true,
});
}
}, []);
if (!isPostHogEnabled) return <>{children}</>;
return <PHProvider client={posthog}>{children}</PHProvider>;
}
export function PostHogUserTracker() {
const { user, isUserLoading } = useSupabase();
const previousUserIdRef = useRef<string | null>(null);
const isPostHogEnabled = environment.isPostHogEnabled();
useEffect(() => {
if (isUserLoading || !isPostHogEnabled) return;
if (user) {
if (previousUserIdRef.current !== user.id) {
posthog.identify(user.id, {
email: user.email,
...(user.user_metadata?.name && { name: user.user_metadata.name }),
});
previousUserIdRef.current = user.id;
}
} else if (previousUserIdRef.current !== null) {
posthog.reset();
previousUserIdRef.current = null;
}
}, [user, isUserLoading, isPostHogEnabled]);
return null;
}
export function PostHogPageViewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
const isPostHogEnabled = environment.isPostHogEnabled();
useEffect(() => {
if (pathname && isPostHogEnabled) {
let url = window.origin + pathname;
if (searchParams && searchParams.toString()) {
url = url + `?${searchParams.toString()}`;
}
posthog.capture("$pageview", { $current_url: url });
}
}, [pathname, searchParams, isPostHogEnabled]);
return null;
}

View File

@@ -76,6 +76,13 @@ function getPreviewStealingDev() {
return branch; return branch;
} }
function getPostHogCredentials() {
return {
key: process.env.NEXT_PUBLIC_POSTHOG_KEY,
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
};
}
function isProductionBuild() { function isProductionBuild() {
return process.env.NODE_ENV === "production"; return process.env.NODE_ENV === "production";
} }
@@ -116,6 +123,13 @@ function areFeatureFlagsEnabled() {
return process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "enabled"; return process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "enabled";
} }
function isPostHogEnabled() {
const inCloud = isCloud();
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const host = process.env.NEXT_PUBLIC_POSTHOG_HOST;
return inCloud && key && host;
}
export const environment = { export const environment = {
// Generic // Generic
getEnvironmentStr, getEnvironmentStr,
@@ -128,6 +142,7 @@ export const environment = {
getSupabaseUrl, getSupabaseUrl,
getSupabaseAnonKey, getSupabaseAnonKey,
getPreviewStealingDev, getPreviewStealingDev,
getPostHogCredentials,
// Assertions // Assertions
isServerSide, isServerSide,
isClientSide, isClientSide,
@@ -138,5 +153,6 @@ export const environment = {
isCloud, isCloud,
isLocal, isLocal,
isVercelPreview, isVercelPreview,
isPostHogEnabled,
areFeatureFlagsEnabled, areFeatureFlagsEnabled,
}; };