mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-04 03:45:12 -05:00
Compare commits
1 Commits
abhi/check
...
classic-fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f12c76536 |
@@ -1,368 +0,0 @@
|
||||
"""Redis Streams consumer for operation completion messages.
|
||||
|
||||
This module provides a consumer (ChatCompletionConsumer) that listens for
|
||||
completion notifications (OperationCompleteMessage) from external services
|
||||
(like Agent Generator) and triggers the appropriate stream registry and
|
||||
chat service updates via process_operation_success/process_operation_failure.
|
||||
|
||||
Why Redis Streams instead of RabbitMQ?
|
||||
--------------------------------------
|
||||
While the project typically uses RabbitMQ for async task queues (e.g., execution
|
||||
queue), Redis Streams was chosen for chat completion notifications because:
|
||||
|
||||
1. **Unified Infrastructure**: The SSE reconnection feature already uses Redis
|
||||
Streams (via stream_registry) for message persistence and replay. Using Redis
|
||||
Streams for completion notifications keeps all chat streaming infrastructure
|
||||
in one system, simplifying operations and reducing cross-system coordination.
|
||||
|
||||
2. **Message Replay**: Redis Streams support XREAD with arbitrary message IDs,
|
||||
allowing consumers to replay missed messages after reconnection. This aligns
|
||||
with the SSE reconnection pattern where clients can resume from last_message_id.
|
||||
|
||||
3. **Consumer Groups with XAUTOCLAIM**: Redis consumer groups provide automatic
|
||||
load balancing across pods with explicit message claiming (XAUTOCLAIM) for
|
||||
recovering from dead consumers - ideal for the completion callback pattern.
|
||||
|
||||
4. **Lower Latency**: For real-time SSE updates, Redis (already in-memory for
|
||||
stream_registry) provides lower latency than an additional RabbitMQ hop.
|
||||
|
||||
5. **Atomicity with Task State**: Completion processing often needs to update
|
||||
task metadata stored in Redis. Keeping both in Redis enables simpler
|
||||
transactional semantics without distributed coordination.
|
||||
|
||||
The consumer uses Redis Streams with consumer groups for reliable message
|
||||
processing across multiple platform pods, with XAUTOCLAIM for reclaiming
|
||||
stale pending messages from dead consumers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
from prisma import Prisma
|
||||
from pydantic import BaseModel
|
||||
from redis.exceptions import ResponseError
|
||||
|
||||
from backend.data.redis_client import get_redis_async
|
||||
|
||||
from . import stream_registry
|
||||
from .completion_handler import process_operation_failure, process_operation_success
|
||||
from .config import ChatConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
config = ChatConfig()
|
||||
|
||||
|
||||
class OperationCompleteMessage(BaseModel):
|
||||
"""Message format for operation completion notifications."""
|
||||
|
||||
operation_id: str
|
||||
task_id: str
|
||||
success: bool
|
||||
result: dict | str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ChatCompletionConsumer:
|
||||
"""Consumer for chat operation completion messages from Redis Streams.
|
||||
|
||||
This consumer initializes its own Prisma client in start() to ensure
|
||||
database operations work correctly within this async context.
|
||||
|
||||
Uses Redis consumer groups to allow multiple platform pods to consume
|
||||
messages reliably with automatic redelivery on failure.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._consumer_task: asyncio.Task | None = None
|
||||
self._running = False
|
||||
self._prisma: Prisma | None = None
|
||||
self._consumer_name = f"consumer-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the completion consumer."""
|
||||
if self._running:
|
||||
logger.warning("Completion consumer already running")
|
||||
return
|
||||
|
||||
# Create consumer group if it doesn't exist
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
await redis.xgroup_create(
|
||||
config.stream_completion_name,
|
||||
config.stream_consumer_group,
|
||||
id="0",
|
||||
mkstream=True,
|
||||
)
|
||||
logger.info(
|
||||
f"Created consumer group '{config.stream_consumer_group}' "
|
||||
f"on stream '{config.stream_completion_name}'"
|
||||
)
|
||||
except ResponseError as e:
|
||||
if "BUSYGROUP" in str(e):
|
||||
logger.debug(
|
||||
f"Consumer group '{config.stream_consumer_group}' already exists"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
self._running = True
|
||||
self._consumer_task = asyncio.create_task(self._consume_messages())
|
||||
logger.info(
|
||||
f"Chat completion consumer started (consumer: {self._consumer_name})"
|
||||
)
|
||||
|
||||
async def _ensure_prisma(self) -> Prisma:
|
||||
"""Lazily initialize Prisma client on first use."""
|
||||
if self._prisma is None:
|
||||
database_url = os.getenv("DATABASE_URL", "postgresql://localhost:5432")
|
||||
self._prisma = Prisma(datasource={"url": database_url})
|
||||
await self._prisma.connect()
|
||||
logger.info("[COMPLETION] Consumer Prisma client connected (lazy init)")
|
||||
return self._prisma
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the completion consumer."""
|
||||
self._running = False
|
||||
|
||||
if self._consumer_task:
|
||||
self._consumer_task.cancel()
|
||||
try:
|
||||
await self._consumer_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._consumer_task = None
|
||||
|
||||
if self._prisma:
|
||||
await self._prisma.disconnect()
|
||||
self._prisma = None
|
||||
logger.info("[COMPLETION] Consumer Prisma client disconnected")
|
||||
|
||||
logger.info("Chat completion consumer stopped")
|
||||
|
||||
async def _consume_messages(self) -> None:
|
||||
"""Main message consumption loop with retry logic."""
|
||||
max_retries = 10
|
||||
retry_delay = 5 # seconds
|
||||
retry_count = 0
|
||||
block_timeout = 5000 # milliseconds
|
||||
|
||||
while self._running and retry_count < max_retries:
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
|
||||
# Reset retry count on successful connection
|
||||
retry_count = 0
|
||||
|
||||
while self._running:
|
||||
# First, claim any stale pending messages from dead consumers
|
||||
# Redis does NOT auto-redeliver pending messages; we must explicitly
|
||||
# claim them using XAUTOCLAIM
|
||||
try:
|
||||
claimed_result = await redis.xautoclaim(
|
||||
name=config.stream_completion_name,
|
||||
groupname=config.stream_consumer_group,
|
||||
consumername=self._consumer_name,
|
||||
min_idle_time=config.stream_claim_min_idle_ms,
|
||||
start_id="0-0",
|
||||
count=10,
|
||||
)
|
||||
# xautoclaim returns: (next_start_id, [(id, data), ...], [deleted_ids])
|
||||
if claimed_result and len(claimed_result) >= 2:
|
||||
claimed_entries = claimed_result[1]
|
||||
if claimed_entries:
|
||||
logger.info(
|
||||
f"Claimed {len(claimed_entries)} stale pending messages"
|
||||
)
|
||||
for entry_id, data in claimed_entries:
|
||||
if not self._running:
|
||||
return
|
||||
await self._process_entry(redis, entry_id, data)
|
||||
except Exception as e:
|
||||
logger.warning(f"XAUTOCLAIM failed (non-fatal): {e}")
|
||||
|
||||
# Read new messages from the stream
|
||||
messages = await redis.xreadgroup(
|
||||
groupname=config.stream_consumer_group,
|
||||
consumername=self._consumer_name,
|
||||
streams={config.stream_completion_name: ">"},
|
||||
block=block_timeout,
|
||||
count=10,
|
||||
)
|
||||
|
||||
if not messages:
|
||||
continue
|
||||
|
||||
for stream_name, entries in messages:
|
||||
for entry_id, data in entries:
|
||||
if not self._running:
|
||||
return
|
||||
await self._process_entry(redis, entry_id, data)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Consumer cancelled")
|
||||
return
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
logger.error(
|
||||
f"Consumer error (retry {retry_count}/{max_retries}): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
if self._running and retry_count < max_retries:
|
||||
await asyncio.sleep(retry_delay)
|
||||
else:
|
||||
logger.error("Max retries reached, stopping consumer")
|
||||
return
|
||||
|
||||
async def _process_entry(
|
||||
self, redis: Any, entry_id: str, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Process a single stream entry and acknowledge it on success.
|
||||
|
||||
Args:
|
||||
redis: Redis client connection
|
||||
entry_id: The stream entry ID
|
||||
data: The entry data dict
|
||||
"""
|
||||
try:
|
||||
# Handle the message
|
||||
message_data = data.get("data")
|
||||
if message_data:
|
||||
await self._handle_message(
|
||||
message_data.encode()
|
||||
if isinstance(message_data, str)
|
||||
else message_data
|
||||
)
|
||||
|
||||
# Acknowledge the message after successful processing
|
||||
await redis.xack(
|
||||
config.stream_completion_name,
|
||||
config.stream_consumer_group,
|
||||
entry_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing completion message {entry_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Message remains in pending state and will be claimed by
|
||||
# XAUTOCLAIM after min_idle_time expires
|
||||
|
||||
async def _handle_message(self, body: bytes) -> None:
|
||||
"""Handle a completion message using our own Prisma client."""
|
||||
try:
|
||||
data = orjson.loads(body)
|
||||
message = OperationCompleteMessage(**data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse completion message: {e}")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"[COMPLETION] Received completion for operation {message.operation_id} "
|
||||
f"(task_id={message.task_id}, success={message.success})"
|
||||
)
|
||||
|
||||
# Find task in registry
|
||||
task = await stream_registry.find_task_by_operation_id(message.operation_id)
|
||||
if task is None:
|
||||
task = await stream_registry.get_task(message.task_id)
|
||||
|
||||
if task is None:
|
||||
logger.warning(
|
||||
f"[COMPLETION] Task not found for operation {message.operation_id} "
|
||||
f"(task_id={message.task_id})"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"[COMPLETION] Found task: task_id={task.task_id}, "
|
||||
f"session_id={task.session_id}, tool_call_id={task.tool_call_id}"
|
||||
)
|
||||
|
||||
# Guard against empty task fields
|
||||
if not task.task_id or not task.session_id or not task.tool_call_id:
|
||||
logger.error(
|
||||
f"[COMPLETION] Task has empty critical fields! "
|
||||
f"task_id={task.task_id!r}, session_id={task.session_id!r}, "
|
||||
f"tool_call_id={task.tool_call_id!r}"
|
||||
)
|
||||
return
|
||||
|
||||
if message.success:
|
||||
await self._handle_success(task, message)
|
||||
else:
|
||||
await self._handle_failure(task, message)
|
||||
|
||||
async def _handle_success(
|
||||
self,
|
||||
task: stream_registry.ActiveTask,
|
||||
message: OperationCompleteMessage,
|
||||
) -> None:
|
||||
"""Handle successful operation completion."""
|
||||
prisma = await self._ensure_prisma()
|
||||
await process_operation_success(task, message.result, prisma)
|
||||
|
||||
async def _handle_failure(
|
||||
self,
|
||||
task: stream_registry.ActiveTask,
|
||||
message: OperationCompleteMessage,
|
||||
) -> None:
|
||||
"""Handle failed operation completion."""
|
||||
prisma = await self._ensure_prisma()
|
||||
await process_operation_failure(task, message.error, prisma)
|
||||
|
||||
|
||||
# Module-level consumer instance
|
||||
_consumer: ChatCompletionConsumer | None = None
|
||||
|
||||
|
||||
async def start_completion_consumer() -> None:
|
||||
"""Start the global completion consumer."""
|
||||
global _consumer
|
||||
if _consumer is None:
|
||||
_consumer = ChatCompletionConsumer()
|
||||
await _consumer.start()
|
||||
|
||||
|
||||
async def stop_completion_consumer() -> None:
|
||||
"""Stop the global completion consumer."""
|
||||
global _consumer
|
||||
if _consumer:
|
||||
await _consumer.stop()
|
||||
_consumer = None
|
||||
|
||||
|
||||
async def publish_operation_complete(
|
||||
operation_id: str,
|
||||
task_id: str,
|
||||
success: bool,
|
||||
result: dict | str | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Publish an operation completion message to Redis Streams.
|
||||
|
||||
Args:
|
||||
operation_id: The operation ID that completed.
|
||||
task_id: The task ID associated with the operation.
|
||||
success: Whether the operation succeeded.
|
||||
result: The result data (for success).
|
||||
error: The error message (for failure).
|
||||
"""
|
||||
message = OperationCompleteMessage(
|
||||
operation_id=operation_id,
|
||||
task_id=task_id,
|
||||
success=success,
|
||||
result=result,
|
||||
error=error,
|
||||
)
|
||||
|
||||
redis = await get_redis_async()
|
||||
await redis.xadd(
|
||||
config.stream_completion_name,
|
||||
{"data": message.model_dump_json()},
|
||||
maxlen=config.stream_max_length,
|
||||
)
|
||||
logger.info(f"Published completion for operation {operation_id}")
|
||||
@@ -1,344 +0,0 @@
|
||||
"""Shared completion handling for operation success and failure.
|
||||
|
||||
This module provides common logic for handling operation completion from both:
|
||||
- The Redis Streams consumer (completion_consumer.py)
|
||||
- The HTTP webhook endpoint (routes.py)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
from prisma import Prisma
|
||||
|
||||
from . import service as chat_service
|
||||
from . import stream_registry
|
||||
from .response_model import StreamError, StreamToolOutputAvailable
|
||||
from .tools.models import ErrorResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tools that produce agent_json that needs to be saved to library
|
||||
AGENT_GENERATION_TOOLS = {"create_agent", "edit_agent"}
|
||||
|
||||
# Keys that should be stripped from agent_json when returning in error responses
|
||||
SENSITIVE_KEYS = frozenset(
|
||||
{
|
||||
"api_key",
|
||||
"apikey",
|
||||
"api_secret",
|
||||
"password",
|
||||
"secret",
|
||||
"credentials",
|
||||
"credential",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"private_key",
|
||||
"privatekey",
|
||||
"auth",
|
||||
"authorization",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_agent_json(obj: Any) -> Any:
|
||||
"""Recursively sanitize agent_json by removing sensitive keys.
|
||||
|
||||
Args:
|
||||
obj: The object to sanitize (dict, list, or primitive)
|
||||
|
||||
Returns:
|
||||
Sanitized copy with sensitive keys removed/redacted
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: "[REDACTED]" if k.lower() in SENSITIVE_KEYS else _sanitize_agent_json(v)
|
||||
for k, v in obj.items()
|
||||
}
|
||||
elif isinstance(obj, list):
|
||||
return [_sanitize_agent_json(item) for item in obj]
|
||||
else:
|
||||
return obj
|
||||
|
||||
|
||||
class ToolMessageUpdateError(Exception):
|
||||
"""Raised when updating a tool message in the database fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def _update_tool_message(
|
||||
session_id: str,
|
||||
tool_call_id: str,
|
||||
content: str,
|
||||
prisma_client: Prisma | None,
|
||||
) -> None:
|
||||
"""Update tool message in database.
|
||||
|
||||
Args:
|
||||
session_id: The session ID
|
||||
tool_call_id: The tool call ID to update
|
||||
content: The new content for the message
|
||||
prisma_client: Optional Prisma client. If None, uses chat_service.
|
||||
|
||||
Raises:
|
||||
ToolMessageUpdateError: If the database update fails. The caller should
|
||||
handle this to avoid marking the task as completed with inconsistent state.
|
||||
"""
|
||||
try:
|
||||
if prisma_client:
|
||||
# Use provided Prisma client (for consumer with its own connection)
|
||||
updated_count = await prisma_client.chatmessage.update_many(
|
||||
where={
|
||||
"sessionId": session_id,
|
||||
"toolCallId": tool_call_id,
|
||||
},
|
||||
data={"content": content},
|
||||
)
|
||||
# Check if any rows were updated - 0 means message not found
|
||||
if updated_count == 0:
|
||||
raise ToolMessageUpdateError(
|
||||
f"No message found with tool_call_id={tool_call_id} in session {session_id}"
|
||||
)
|
||||
else:
|
||||
# Use service function (for webhook endpoint)
|
||||
await chat_service._update_pending_operation(
|
||||
session_id=session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
result=content,
|
||||
)
|
||||
except ToolMessageUpdateError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[COMPLETION] Failed to update tool message: {e}", exc_info=True)
|
||||
raise ToolMessageUpdateError(
|
||||
f"Failed to update tool message for tool_call_id={tool_call_id}: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
def serialize_result(result: dict | list | str | int | float | bool | None) -> str:
|
||||
"""Serialize result to JSON string with sensible defaults.
|
||||
|
||||
Args:
|
||||
result: The result to serialize. Can be a dict, list, string,
|
||||
number, boolean, or None.
|
||||
|
||||
Returns:
|
||||
JSON string representation of the result. Returns '{"status": "completed"}'
|
||||
only when result is explicitly None.
|
||||
"""
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
if result is None:
|
||||
return '{"status": "completed"}'
|
||||
return orjson.dumps(result).decode("utf-8")
|
||||
|
||||
|
||||
async def _save_agent_from_result(
|
||||
result: dict[str, Any],
|
||||
user_id: str | None,
|
||||
tool_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Save agent to library if result contains agent_json.
|
||||
|
||||
Args:
|
||||
result: The result dict that may contain agent_json
|
||||
user_id: The user ID to save the agent for
|
||||
tool_name: The tool name (create_agent or edit_agent)
|
||||
|
||||
Returns:
|
||||
Updated result dict with saved agent details, or original result if no agent_json
|
||||
"""
|
||||
if not user_id:
|
||||
logger.warning("[COMPLETION] Cannot save agent: no user_id in task")
|
||||
return result
|
||||
|
||||
agent_json = result.get("agent_json")
|
||||
if not agent_json:
|
||||
logger.warning(
|
||||
f"[COMPLETION] {tool_name} completed but no agent_json in result"
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
from .tools.agent_generator import save_agent_to_library
|
||||
|
||||
is_update = tool_name == "edit_agent"
|
||||
created_graph, library_agent = await save_agent_to_library(
|
||||
agent_json, user_id, is_update=is_update
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[COMPLETION] Saved agent '{created_graph.name}' to library "
|
||||
f"(graph_id={created_graph.id}, library_agent_id={library_agent.id})"
|
||||
)
|
||||
|
||||
# Return a response similar to AgentSavedResponse
|
||||
return {
|
||||
"type": "agent_saved",
|
||||
"message": f"Agent '{created_graph.name}' has been saved to your library!",
|
||||
"agent_id": created_graph.id,
|
||||
"agent_name": created_graph.name,
|
||||
"library_agent_id": library_agent.id,
|
||||
"library_agent_link": f"/library/agents/{library_agent.id}",
|
||||
"agent_page_link": f"/build?flowID={created_graph.id}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[COMPLETION] Failed to save agent to library: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Return error but don't fail the whole operation
|
||||
# Sanitize agent_json to remove sensitive keys before returning
|
||||
return {
|
||||
"type": "error",
|
||||
"message": f"Agent was generated but failed to save: {str(e)}",
|
||||
"error": str(e),
|
||||
"agent_json": _sanitize_agent_json(agent_json),
|
||||
}
|
||||
|
||||
|
||||
async def process_operation_success(
|
||||
task: stream_registry.ActiveTask,
|
||||
result: dict | str | None,
|
||||
prisma_client: Prisma | None = None,
|
||||
) -> None:
|
||||
"""Handle successful operation completion.
|
||||
|
||||
Publishes the result to the stream registry, updates the database,
|
||||
generates LLM continuation, and marks the task as completed.
|
||||
|
||||
Args:
|
||||
task: The active task that completed
|
||||
result: The result data from the operation
|
||||
prisma_client: Optional Prisma client for database operations.
|
||||
If None, uses chat_service._update_pending_operation instead.
|
||||
|
||||
Raises:
|
||||
ToolMessageUpdateError: If the database update fails. The task will be
|
||||
marked as failed instead of completed to avoid inconsistent state.
|
||||
"""
|
||||
# For agent generation tools, save the agent to library
|
||||
if task.tool_name in AGENT_GENERATION_TOOLS and isinstance(result, dict):
|
||||
result = await _save_agent_from_result(result, task.user_id, task.tool_name)
|
||||
|
||||
# Serialize result for output (only substitute default when result is exactly None)
|
||||
result_output = result if result is not None else {"status": "completed"}
|
||||
output_str = (
|
||||
result_output
|
||||
if isinstance(result_output, str)
|
||||
else orjson.dumps(result_output).decode("utf-8")
|
||||
)
|
||||
|
||||
# Publish result to stream registry
|
||||
await stream_registry.publish_chunk(
|
||||
task.task_id,
|
||||
StreamToolOutputAvailable(
|
||||
toolCallId=task.tool_call_id,
|
||||
toolName=task.tool_name,
|
||||
output=output_str,
|
||||
success=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Update pending operation in database
|
||||
# If this fails, we must not continue to mark the task as completed
|
||||
result_str = serialize_result(result)
|
||||
try:
|
||||
await _update_tool_message(
|
||||
session_id=task.session_id,
|
||||
tool_call_id=task.tool_call_id,
|
||||
content=result_str,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
except ToolMessageUpdateError:
|
||||
# DB update failed - mark task as failed to avoid inconsistent state
|
||||
logger.error(
|
||||
f"[COMPLETION] DB update failed for task {task.task_id}, "
|
||||
"marking as failed instead of completed"
|
||||
)
|
||||
await stream_registry.publish_chunk(
|
||||
task.task_id,
|
||||
StreamError(errorText="Failed to save operation result to database"),
|
||||
)
|
||||
await stream_registry.mark_task_completed(task.task_id, status="failed")
|
||||
raise
|
||||
|
||||
# Generate LLM continuation with streaming
|
||||
try:
|
||||
await chat_service._generate_llm_continuation_with_streaming(
|
||||
session_id=task.session_id,
|
||||
user_id=task.user_id,
|
||||
task_id=task.task_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[COMPLETION] Failed to generate LLM continuation: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Mark task as completed and release Redis lock
|
||||
await stream_registry.mark_task_completed(task.task_id, status="completed")
|
||||
try:
|
||||
await chat_service._mark_operation_completed(task.tool_call_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[COMPLETION] Failed to mark operation completed: {e}")
|
||||
|
||||
logger.info(
|
||||
f"[COMPLETION] Successfully processed completion for task {task.task_id}"
|
||||
)
|
||||
|
||||
|
||||
async def process_operation_failure(
|
||||
task: stream_registry.ActiveTask,
|
||||
error: str | None,
|
||||
prisma_client: Prisma | None = None,
|
||||
) -> None:
|
||||
"""Handle failed operation completion.
|
||||
|
||||
Publishes the error to the stream registry, updates the database with
|
||||
the error response, and marks the task as failed.
|
||||
|
||||
Args:
|
||||
task: The active task that failed
|
||||
error: The error message from the operation
|
||||
prisma_client: Optional Prisma client for database operations.
|
||||
If None, uses chat_service._update_pending_operation instead.
|
||||
"""
|
||||
error_msg = error or "Operation failed"
|
||||
|
||||
# Publish error to stream registry
|
||||
await stream_registry.publish_chunk(
|
||||
task.task_id,
|
||||
StreamError(errorText=error_msg),
|
||||
)
|
||||
|
||||
# Update pending operation with error
|
||||
# If this fails, we still continue to mark the task as failed
|
||||
error_response = ErrorResponse(
|
||||
message=error_msg,
|
||||
error=error,
|
||||
)
|
||||
try:
|
||||
await _update_tool_message(
|
||||
session_id=task.session_id,
|
||||
tool_call_id=task.tool_call_id,
|
||||
content=error_response.model_dump_json(),
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
except ToolMessageUpdateError:
|
||||
# DB update failed - log but continue with cleanup
|
||||
logger.error(
|
||||
f"[COMPLETION] DB update failed while processing failure for task {task.task_id}, "
|
||||
"continuing with cleanup"
|
||||
)
|
||||
|
||||
# Mark task as failed and release Redis lock
|
||||
await stream_registry.mark_task_completed(task.task_id, status="failed")
|
||||
try:
|
||||
await chat_service._mark_operation_completed(task.tool_call_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[COMPLETION] Failed to mark operation completed: {e}")
|
||||
|
||||
logger.info(f"[COMPLETION] Processed failure for task {task.task_id}: {error_msg}")
|
||||
@@ -44,48 +44,6 @@ class ChatConfig(BaseSettings):
|
||||
description="TTL in seconds for long-running operation tracking in Redis (safety net if pod dies)",
|
||||
)
|
||||
|
||||
# Stream registry configuration for SSE reconnection
|
||||
stream_ttl: int = Field(
|
||||
default=3600,
|
||||
description="TTL in seconds for stream data in Redis (1 hour)",
|
||||
)
|
||||
stream_max_length: int = Field(
|
||||
default=10000,
|
||||
description="Maximum number of messages to store per stream",
|
||||
)
|
||||
|
||||
# Redis Streams configuration for completion consumer
|
||||
stream_completion_name: str = Field(
|
||||
default="chat:completions",
|
||||
description="Redis Stream name for operation completions",
|
||||
)
|
||||
stream_consumer_group: str = Field(
|
||||
default="chat_consumers",
|
||||
description="Consumer group name for completion stream",
|
||||
)
|
||||
stream_claim_min_idle_ms: int = Field(
|
||||
default=60000,
|
||||
description="Minimum idle time in milliseconds before claiming pending messages from dead consumers",
|
||||
)
|
||||
|
||||
# Redis key prefixes for stream registry
|
||||
task_meta_prefix: str = Field(
|
||||
default="chat:task:meta:",
|
||||
description="Prefix for task metadata hash keys",
|
||||
)
|
||||
task_stream_prefix: str = Field(
|
||||
default="chat:stream:",
|
||||
description="Prefix for task message stream keys",
|
||||
)
|
||||
task_op_prefix: str = Field(
|
||||
default="chat:task:op:",
|
||||
description="Prefix for operation ID to task ID mapping keys",
|
||||
)
|
||||
internal_api_key: str | None = Field(
|
||||
default=None,
|
||||
description="API key for internal webhook callbacks (env: CHAT_INTERNAL_API_KEY)",
|
||||
)
|
||||
|
||||
# Langfuse Prompt Management Configuration
|
||||
# Note: Langfuse credentials are in Settings().secrets (settings.py)
|
||||
langfuse_prompt_name: str = Field(
|
||||
@@ -124,14 +82,6 @@ class ChatConfig(BaseSettings):
|
||||
v = "https://openrouter.ai/api/v1"
|
||||
return v
|
||||
|
||||
@field_validator("internal_api_key", mode="before")
|
||||
@classmethod
|
||||
def get_internal_api_key(cls, v):
|
||||
"""Get internal API key from environment if not provided."""
|
||||
if v is None:
|
||||
v = os.getenv("CHAT_INTERNAL_API_KEY")
|
||||
return v
|
||||
|
||||
# Prompt paths for different contexts
|
||||
PROMPT_PATHS: dict[str, str] = {
|
||||
"default": "prompts/chat_system.md",
|
||||
|
||||
@@ -52,10 +52,6 @@ class StreamStart(StreamBaseResponse):
|
||||
|
||||
type: ResponseType = ResponseType.START
|
||||
messageId: str = Field(..., description="Unique message ID")
|
||||
taskId: str | None = Field(
|
||||
default=None,
|
||||
description="Task ID for SSE reconnection. Clients can reconnect using GET /tasks/{taskId}/stream",
|
||||
)
|
||||
|
||||
|
||||
class StreamFinish(StreamBaseResponse):
|
||||
@@ -117,7 +113,7 @@ class StreamToolOutputAvailable(StreamBaseResponse):
|
||||
type: ResponseType = ResponseType.TOOL_OUTPUT_AVAILABLE
|
||||
toolCallId: str = Field(..., description="Tool call ID this responds to")
|
||||
output: str | dict[str, Any] = Field(..., description="Tool execution output")
|
||||
# Keep these for internal backend use
|
||||
# Additional fields for internal use (not part of AI SDK spec but useful)
|
||||
toolName: str | None = Field(
|
||||
default=None, description="Name of the tool that was executed"
|
||||
)
|
||||
@@ -125,15 +121,6 @@ class StreamToolOutputAvailable(StreamBaseResponse):
|
||||
default=True, description="Whether the tool execution succeeded"
|
||||
)
|
||||
|
||||
def to_sse(self) -> str:
|
||||
"""Convert to SSE format, excluding non-spec fields."""
|
||||
import json
|
||||
data = {
|
||||
"type": self.type.value,
|
||||
"toolCallId": self.toolCallId,
|
||||
"output": self.output,
|
||||
}
|
||||
return f"data: {json.dumps(data)}\n\n"
|
||||
|
||||
# ========== Other ==========
|
||||
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
"""Chat API routes for chat session management and streaming via SSE."""
|
||||
|
||||
import logging
|
||||
import uuid as uuid_module
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Annotated
|
||||
|
||||
from autogpt_libs import auth
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Security
|
||||
from fastapi import APIRouter, Depends, Query, Security
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.util.exceptions import NotFoundError
|
||||
|
||||
from . import service as chat_service
|
||||
from . import stream_registry
|
||||
from .completion_handler import process_operation_failure, process_operation_success
|
||||
from .config import ChatConfig
|
||||
from .model import ChatSession, create_chat_session, get_chat_session, get_user_sessions
|
||||
from .response_model import StreamFinish, StreamHeartbeat, StreamStart
|
||||
|
||||
config = ChatConfig()
|
||||
|
||||
@@ -59,15 +55,6 @@ class CreateSessionResponse(BaseModel):
|
||||
user_id: str | None
|
||||
|
||||
|
||||
class ActiveStreamInfo(BaseModel):
|
||||
"""Information about an active stream for reconnection."""
|
||||
|
||||
task_id: str
|
||||
last_message_id: str # Redis Stream message ID for resumption
|
||||
operation_id: str # Operation ID for completion tracking
|
||||
tool_name: str # Name of the tool being executed
|
||||
|
||||
|
||||
class SessionDetailResponse(BaseModel):
|
||||
"""Response model providing complete details for a chat session, including messages."""
|
||||
|
||||
@@ -76,7 +63,6 @@ class SessionDetailResponse(BaseModel):
|
||||
updated_at: str
|
||||
user_id: str | None
|
||||
messages: list[dict]
|
||||
active_stream: ActiveStreamInfo | None = None # Present if stream is still active
|
||||
|
||||
|
||||
class SessionSummaryResponse(BaseModel):
|
||||
@@ -95,14 +81,6 @@ class ListSessionsResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class OperationCompleteRequest(BaseModel):
|
||||
"""Request model for external completion webhook."""
|
||||
|
||||
success: bool
|
||||
result: dict | str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ========== Routes ==========
|
||||
|
||||
|
||||
@@ -188,14 +166,13 @@ async def get_session(
|
||||
Retrieve the details of a specific chat session.
|
||||
|
||||
Looks up a chat session by ID for the given user (if authenticated) and returns all session data including messages.
|
||||
If there's an active stream for this session, returns the task_id for reconnection.
|
||||
|
||||
Args:
|
||||
session_id: The unique identifier for the desired chat session.
|
||||
user_id: The optional authenticated user ID, or None for anonymous access.
|
||||
|
||||
Returns:
|
||||
SessionDetailResponse: Details for the requested session, including active_stream info if applicable.
|
||||
SessionDetailResponse: Details for the requested session, or None if not found.
|
||||
|
||||
"""
|
||||
session = await get_chat_session(session_id, user_id)
|
||||
@@ -203,28 +180,11 @@ async def get_session(
|
||||
raise NotFoundError(f"Session {session_id} not found.")
|
||||
|
||||
messages = [message.model_dump() for message in session.messages]
|
||||
|
||||
# Check if there's an active stream for this session
|
||||
active_stream_info = None
|
||||
active_task, last_message_id = await stream_registry.get_active_task_for_session(
|
||||
session_id, user_id
|
||||
logger.info(
|
||||
f"Returning session {session_id}: "
|
||||
f"message_count={len(messages)}, "
|
||||
f"roles={[m.get('role') for m in messages]}"
|
||||
)
|
||||
if active_task:
|
||||
# Filter out the in-progress assistant message from the session response.
|
||||
# The client will receive the complete assistant response through the SSE
|
||||
# stream replay instead, preventing duplicate content.
|
||||
if messages and messages[-1].get("role") == "assistant":
|
||||
messages = messages[:-1]
|
||||
|
||||
# Use "0-0" as last_message_id to replay the stream from the beginning.
|
||||
# Since we filtered out the cached assistant message, the client needs
|
||||
# the full stream to reconstruct the response.
|
||||
active_stream_info = ActiveStreamInfo(
|
||||
task_id=active_task.task_id,
|
||||
last_message_id="0-0",
|
||||
operation_id=active_task.operation_id,
|
||||
tool_name=active_task.tool_name,
|
||||
)
|
||||
|
||||
return SessionDetailResponse(
|
||||
id=session.session_id,
|
||||
@@ -232,7 +192,6 @@ async def get_session(
|
||||
updated_at=session.updated_at.isoformat(),
|
||||
user_id=session.user_id or None,
|
||||
messages=messages,
|
||||
active_stream=active_stream_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -252,112 +211,49 @@ async def stream_chat_post(
|
||||
- Tool call UI elements (if invoked)
|
||||
- Tool execution results
|
||||
|
||||
The AI generation runs in a background task that continues even if the client disconnects.
|
||||
All chunks are written to Redis for reconnection support. If the client disconnects,
|
||||
they can reconnect using GET /tasks/{task_id}/stream to resume from where they left off.
|
||||
|
||||
Args:
|
||||
session_id: The chat session identifier to associate with the streamed messages.
|
||||
request: Request body containing message, is_user_message, and optional context.
|
||||
user_id: Optional authenticated user ID.
|
||||
Returns:
|
||||
StreamingResponse: SSE-formatted response chunks. First chunk is a "start" event
|
||||
containing the task_id for reconnection.
|
||||
StreamingResponse: SSE-formatted response chunks.
|
||||
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
session = await _validate_and_get_session(session_id, user_id)
|
||||
|
||||
# Create a task in the stream registry for reconnection support
|
||||
task_id = str(uuid_module.uuid4())
|
||||
operation_id = str(uuid_module.uuid4())
|
||||
await stream_registry.create_task(
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
tool_call_id="chat_stream", # Not a tool call, but needed for the model
|
||||
tool_name="chat",
|
||||
operation_id=operation_id,
|
||||
)
|
||||
|
||||
# Background task that runs the AI generation independently of SSE connection
|
||||
async def run_ai_generation():
|
||||
try:
|
||||
# Emit a start event with task_id for reconnection
|
||||
start_chunk = StreamStart(messageId=task_id, taskId=task_id)
|
||||
await stream_registry.publish_chunk(task_id, start_chunk)
|
||||
|
||||
async for chunk in chat_service.stream_chat_completion(
|
||||
session_id,
|
||||
request.message,
|
||||
is_user_message=request.is_user_message,
|
||||
user_id=user_id,
|
||||
session=session, # Pass pre-fetched session to avoid double-fetch
|
||||
context=request.context,
|
||||
):
|
||||
# Write to Redis (subscribers will receive via XREAD)
|
||||
await stream_registry.publish_chunk(task_id, chunk)
|
||||
|
||||
# Mark task as completed
|
||||
await stream_registry.mark_task_completed(task_id, "completed")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in background AI generation for session {session_id}: {e}"
|
||||
)
|
||||
await stream_registry.mark_task_completed(task_id, "failed")
|
||||
|
||||
# Start the AI generation in a background task
|
||||
bg_task = asyncio.create_task(run_ai_generation())
|
||||
await stream_registry.set_task_asyncio_task(task_id, bg_task)
|
||||
|
||||
# SSE endpoint that subscribes to the task's stream
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
subscriber_queue = None
|
||||
try:
|
||||
# Subscribe to the task stream (this replays existing messages + live updates)
|
||||
subscriber_queue = await stream_registry.subscribe_to_task(
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
last_message_id="0-0", # Get all messages from the beginning
|
||||
)
|
||||
|
||||
if subscriber_queue is None:
|
||||
yield StreamFinish().to_sse()
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
# Read from the subscriber queue and yield to SSE
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(subscriber_queue.get(), timeout=30.0)
|
||||
yield chunk.to_sse()
|
||||
|
||||
# Check for finish signal
|
||||
if isinstance(chunk, StreamFinish):
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
# Send heartbeat to keep connection alive
|
||||
yield StreamHeartbeat().to_sse()
|
||||
|
||||
except GeneratorExit:
|
||||
pass # Client disconnected - background task continues
|
||||
except Exception as e:
|
||||
logger.error(f"Error in SSE stream for task {task_id}: {e}")
|
||||
finally:
|
||||
# Unsubscribe when client disconnects or stream ends to prevent resource leak
|
||||
if subscriber_queue is not None:
|
||||
try:
|
||||
await stream_registry.unsubscribe_from_task(
|
||||
task_id, subscriber_queue
|
||||
)
|
||||
except Exception as unsub_err:
|
||||
logger.error(
|
||||
f"Error unsubscribing from task {task_id}: {unsub_err}",
|
||||
exc_info=True,
|
||||
)
|
||||
# AI SDK protocol termination - always yield even if unsubscribe fails
|
||||
yield "data: [DONE]\n\n"
|
||||
chunk_count = 0
|
||||
first_chunk_type: str | None = None
|
||||
async for chunk in chat_service.stream_chat_completion(
|
||||
session_id,
|
||||
request.message,
|
||||
is_user_message=request.is_user_message,
|
||||
user_id=user_id,
|
||||
session=session, # Pass pre-fetched session to avoid double-fetch
|
||||
context=request.context,
|
||||
):
|
||||
if chunk_count < 3:
|
||||
logger.info(
|
||||
"Chat stream chunk",
|
||||
extra={
|
||||
"session_id": session_id,
|
||||
"chunk_type": str(chunk.type),
|
||||
},
|
||||
)
|
||||
if not first_chunk_type:
|
||||
first_chunk_type = str(chunk.type)
|
||||
chunk_count += 1
|
||||
yield chunk.to_sse()
|
||||
logger.info(
|
||||
"Chat stream completed",
|
||||
extra={
|
||||
"session_id": session_id,
|
||||
"chunk_count": chunk_count,
|
||||
"first_chunk_type": first_chunk_type,
|
||||
},
|
||||
)
|
||||
# AI SDK protocol termination
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
@@ -470,251 +366,6 @@ async def session_assign_user(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ========== Task Streaming (SSE Reconnection) ==========
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}/stream",
|
||||
)
|
||||
async def stream_task(
|
||||
task_id: str,
|
||||
user_id: str | None = Depends(auth.get_user_id),
|
||||
last_message_id: str = Query(
|
||||
default="0-0",
|
||||
description="Last Redis Stream message ID received (e.g., '1706540123456-0'). Use '0-0' for full replay.",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Reconnect to a long-running task's SSE stream.
|
||||
|
||||
When a long-running operation (like agent generation) starts, the client
|
||||
receives a task_id. If the connection drops, the client can reconnect
|
||||
using this endpoint to resume receiving updates.
|
||||
|
||||
Args:
|
||||
task_id: The task ID from the operation_started response.
|
||||
user_id: Authenticated user ID for ownership validation.
|
||||
last_message_id: Last Redis Stream message ID received ("0-0" for full replay).
|
||||
|
||||
Returns:
|
||||
StreamingResponse: SSE-formatted response chunks starting after last_message_id.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if task not found, 410 if task expired, 403 if access denied.
|
||||
"""
|
||||
# Check task existence and expiry before subscribing
|
||||
task, error_code = await stream_registry.get_task_with_expiry_info(task_id)
|
||||
|
||||
if error_code == "TASK_EXPIRED":
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail={
|
||||
"code": "TASK_EXPIRED",
|
||||
"message": "This operation has expired. Please try again.",
|
||||
},
|
||||
)
|
||||
|
||||
if error_code == "TASK_NOT_FOUND":
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"code": "TASK_NOT_FOUND",
|
||||
"message": f"Task {task_id} not found.",
|
||||
},
|
||||
)
|
||||
|
||||
# Validate ownership if task has an owner
|
||||
if task and task.user_id and user_id != task.user_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"code": "ACCESS_DENIED",
|
||||
"message": "You do not have access to this task.",
|
||||
},
|
||||
)
|
||||
|
||||
# Get subscriber queue from stream registry
|
||||
subscriber_queue = await stream_registry.subscribe_to_task(
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
last_message_id=last_message_id,
|
||||
)
|
||||
|
||||
if subscriber_queue is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"code": "TASK_NOT_FOUND",
|
||||
"message": f"Task {task_id} not found or access denied.",
|
||||
},
|
||||
)
|
||||
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
import asyncio
|
||||
|
||||
heartbeat_interval = 15.0 # Send heartbeat every 15 seconds
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
# Wait for next chunk with timeout for heartbeats
|
||||
chunk = await asyncio.wait_for(
|
||||
subscriber_queue.get(), timeout=heartbeat_interval
|
||||
)
|
||||
yield chunk.to_sse()
|
||||
|
||||
# Check for finish signal
|
||||
if isinstance(chunk, StreamFinish):
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
# Send heartbeat to keep connection alive
|
||||
yield StreamHeartbeat().to_sse()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in task stream {task_id}: {e}", exc_info=True)
|
||||
finally:
|
||||
# Unsubscribe when client disconnects or stream ends
|
||||
try:
|
||||
await stream_registry.unsubscribe_from_task(task_id, subscriber_queue)
|
||||
except Exception as unsub_err:
|
||||
logger.error(
|
||||
f"Error unsubscribing from task {task_id}: {unsub_err}",
|
||||
exc_info=True,
|
||||
)
|
||||
# AI SDK protocol termination - always yield even if unsubscribe fails
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"x-vercel-ai-ui-message-stream": "v1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}",
|
||||
)
|
||||
async def get_task_status(
|
||||
task_id: str,
|
||||
user_id: str | None = Depends(auth.get_user_id),
|
||||
) -> dict:
|
||||
"""
|
||||
Get the status of a long-running task.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to check.
|
||||
user_id: Authenticated user ID for ownership validation.
|
||||
|
||||
Returns:
|
||||
dict: Task status including task_id, status, tool_name, and operation_id.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If task_id is not found or user doesn't have access.
|
||||
"""
|
||||
task = await stream_registry.get_task(task_id)
|
||||
|
||||
if task is None:
|
||||
raise NotFoundError(f"Task {task_id} not found.")
|
||||
|
||||
# Validate ownership - if task has an owner, requester must match
|
||||
if task.user_id and user_id != task.user_id:
|
||||
raise NotFoundError(f"Task {task_id} not found.")
|
||||
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"session_id": task.session_id,
|
||||
"status": task.status,
|
||||
"tool_name": task.tool_name,
|
||||
"operation_id": task.operation_id,
|
||||
"created_at": task.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ========== External Completion Webhook ==========
|
||||
|
||||
|
||||
@router.post(
|
||||
"/operations/{operation_id}/complete",
|
||||
status_code=200,
|
||||
)
|
||||
async def complete_operation(
|
||||
operation_id: str,
|
||||
request: OperationCompleteRequest,
|
||||
x_api_key: str | None = Header(default=None),
|
||||
) -> dict:
|
||||
"""
|
||||
External completion webhook for long-running operations.
|
||||
|
||||
Called by Agent Generator (or other services) when an operation completes.
|
||||
This triggers the stream registry to publish completion and continue LLM generation.
|
||||
|
||||
Args:
|
||||
operation_id: The operation ID to complete.
|
||||
request: Completion payload with success status and result/error.
|
||||
x_api_key: Internal API key for authentication.
|
||||
|
||||
Returns:
|
||||
dict: Status of the completion.
|
||||
|
||||
Raises:
|
||||
HTTPException: If API key is invalid or operation not found.
|
||||
"""
|
||||
# Validate internal API key - reject if not configured or invalid
|
||||
if not config.internal_api_key:
|
||||
logger.error(
|
||||
"Operation complete webhook rejected: CHAT_INTERNAL_API_KEY not configured"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Webhook not available: internal API key not configured",
|
||||
)
|
||||
if x_api_key != config.internal_api_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
# Find task by operation_id
|
||||
task = await stream_registry.find_task_by_operation_id(operation_id)
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Operation {operation_id} not found",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Received completion webhook for operation {operation_id} "
|
||||
f"(task_id={task.task_id}, success={request.success})"
|
||||
)
|
||||
|
||||
if request.success:
|
||||
await process_operation_success(task, request.result)
|
||||
else:
|
||||
await process_operation_failure(task, request.error)
|
||||
|
||||
return {"status": "ok", "task_id": task.task_id}
|
||||
|
||||
|
||||
# ========== Configuration ==========
|
||||
|
||||
|
||||
@router.get("/config/ttl", status_code=200)
|
||||
async def get_ttl_config() -> dict:
|
||||
"""
|
||||
Get the stream TTL configuration.
|
||||
|
||||
Returns the Time-To-Live settings for chat streams, which determines
|
||||
how long clients can reconnect to an active stream.
|
||||
|
||||
Returns:
|
||||
dict: TTL configuration with seconds and milliseconds values.
|
||||
"""
|
||||
return {
|
||||
"stream_ttl_seconds": config.stream_ttl,
|
||||
"stream_ttl_ms": config.stream_ttl * 1000,
|
||||
}
|
||||
|
||||
|
||||
# ========== Health Check ==========
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ from backend.util.exceptions import NotFoundError
|
||||
from backend.util.settings import Settings
|
||||
|
||||
from . import db as chat_db
|
||||
from . import stream_registry
|
||||
from .config import ChatConfig
|
||||
from .model import (
|
||||
ChatMessage,
|
||||
@@ -341,7 +340,6 @@ async def stream_chat_completion(
|
||||
retry_count: int = 0,
|
||||
session: ChatSession | None = None,
|
||||
context: dict[str, str] | None = None, # {url: str, content: str}
|
||||
_continuation_message_id: str | None = None, # Internal: reuse message ID for tool call continuations
|
||||
) -> AsyncGenerator[StreamBaseResponse, None]:
|
||||
"""Main entry point for streaming chat completions with database handling.
|
||||
|
||||
@@ -470,15 +468,11 @@ async def stream_chat_completion(
|
||||
# Generate unique IDs for AI SDK protocol
|
||||
import uuid as uuid_module
|
||||
|
||||
# Reuse message ID for continuations (tool call follow-ups) to avoid duplicate messages
|
||||
is_continuation = _continuation_message_id is not None
|
||||
message_id = _continuation_message_id or str(uuid_module.uuid4())
|
||||
message_id = str(uuid_module.uuid4())
|
||||
text_block_id = str(uuid_module.uuid4())
|
||||
|
||||
# Only yield message start for the initial call, not for continuations
|
||||
# This prevents the AI SDK from creating duplicate message objects
|
||||
if not is_continuation:
|
||||
yield StreamStart(messageId=message_id)
|
||||
# Yield message start
|
||||
yield StreamStart(messageId=message_id)
|
||||
|
||||
try:
|
||||
async for chunk in _stream_chat_chunks(
|
||||
@@ -706,7 +700,6 @@ async def stream_chat_completion(
|
||||
retry_count=retry_count + 1,
|
||||
session=session,
|
||||
context=context,
|
||||
_continuation_message_id=message_id, # Reuse message ID since start was already sent
|
||||
):
|
||||
yield chunk
|
||||
return # Exit after retry to avoid double-saving in finally block
|
||||
@@ -776,7 +769,6 @@ async def stream_chat_completion(
|
||||
session=session, # Pass session object to avoid Redis refetch
|
||||
context=context,
|
||||
tool_call_response=str(tool_response_messages),
|
||||
_continuation_message_id=message_id, # Reuse message ID to avoid duplicates
|
||||
):
|
||||
yield chunk
|
||||
|
||||
@@ -1192,9 +1184,8 @@ async def _yield_tool_call(
|
||||
)
|
||||
return
|
||||
|
||||
# Generate operation ID and task ID
|
||||
# Generate operation ID
|
||||
operation_id = str(uuid_module.uuid4())
|
||||
task_id = str(uuid_module.uuid4())
|
||||
|
||||
# Build a user-friendly message based on tool and arguments
|
||||
if tool_name == "create_agent":
|
||||
@@ -1237,16 +1228,6 @@ async def _yield_tool_call(
|
||||
|
||||
# Wrap session save and task creation in try-except to release lock on failure
|
||||
try:
|
||||
# Create task in stream registry for SSE reconnection support
|
||||
await stream_registry.create_task(
|
||||
task_id=task_id,
|
||||
session_id=session.session_id,
|
||||
user_id=session.user_id,
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=tool_name,
|
||||
operation_id=operation_id,
|
||||
)
|
||||
|
||||
# Save assistant message with tool_call FIRST (required by LLM)
|
||||
assistant_message = ChatMessage(
|
||||
role="assistant",
|
||||
@@ -1268,27 +1249,23 @@ async def _yield_tool_call(
|
||||
session.messages.append(pending_message)
|
||||
await upsert_chat_session(session)
|
||||
logger.info(
|
||||
f"Saved pending operation {operation_id} (task_id={task_id}) "
|
||||
f"for tool {tool_name} in session {session.session_id}"
|
||||
f"Saved pending operation {operation_id} for tool {tool_name} "
|
||||
f"in session {session.session_id}"
|
||||
)
|
||||
|
||||
# Store task reference in module-level set to prevent GC before completion
|
||||
bg_task = asyncio.create_task(
|
||||
_execute_long_running_tool_with_streaming(
|
||||
task = asyncio.create_task(
|
||||
_execute_long_running_tool(
|
||||
tool_name=tool_name,
|
||||
parameters=arguments,
|
||||
tool_call_id=tool_call_id,
|
||||
operation_id=operation_id,
|
||||
task_id=task_id,
|
||||
session_id=session.session_id,
|
||||
user_id=session.user_id,
|
||||
)
|
||||
)
|
||||
_background_tasks.add(bg_task)
|
||||
bg_task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
# Associate the asyncio task with the stream registry task
|
||||
await stream_registry.set_task_asyncio_task(task_id, bg_task)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
except Exception as e:
|
||||
# Roll back appended messages to prevent data corruption on subsequent saves
|
||||
if (
|
||||
@@ -1306,11 +1283,6 @@ async def _yield_tool_call(
|
||||
|
||||
# Release the Redis lock since the background task won't be spawned
|
||||
await _mark_operation_completed(tool_call_id)
|
||||
# Mark stream registry task as failed if it was created
|
||||
try:
|
||||
await stream_registry.mark_task_completed(task_id, status="failed")
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(
|
||||
f"Failed to setup long-running tool {tool_name}: {e}", exc_info=True
|
||||
)
|
||||
@@ -1324,7 +1296,6 @@ async def _yield_tool_call(
|
||||
message=started_msg,
|
||||
operation_id=operation_id,
|
||||
tool_name=tool_name,
|
||||
task_id=task_id, # Include task_id for SSE reconnection
|
||||
).model_dump_json(),
|
||||
success=True,
|
||||
)
|
||||
@@ -1394,9 +1365,6 @@ async def _execute_long_running_tool(
|
||||
|
||||
This function runs independently of the SSE connection, so the operation
|
||||
survives if the user closes their browser tab.
|
||||
|
||||
NOTE: This is the legacy function without stream registry support.
|
||||
Use _execute_long_running_tool_with_streaming for new implementations.
|
||||
"""
|
||||
try:
|
||||
# Load fresh session (not stale reference)
|
||||
@@ -1449,133 +1417,6 @@ async def _execute_long_running_tool(
|
||||
await _mark_operation_completed(tool_call_id)
|
||||
|
||||
|
||||
async def _execute_long_running_tool_with_streaming(
|
||||
tool_name: str,
|
||||
parameters: dict[str, Any],
|
||||
tool_call_id: str,
|
||||
operation_id: str,
|
||||
task_id: str,
|
||||
session_id: str,
|
||||
user_id: str | None,
|
||||
) -> None:
|
||||
"""Execute a long-running tool with stream registry support for SSE reconnection.
|
||||
|
||||
This function runs independently of the SSE connection, publishes progress
|
||||
to the stream registry, and survives if the user closes their browser tab.
|
||||
Clients can reconnect via GET /chat/tasks/{task_id}/stream to resume streaming.
|
||||
|
||||
If the external service returns a 202 Accepted (async), this function exits
|
||||
early and lets the Redis Streams completion consumer handle the rest.
|
||||
"""
|
||||
# Track whether we delegated to async processing - if so, the Redis Streams
|
||||
# completion consumer (stream_registry / completion_consumer) will handle cleanup, not us
|
||||
delegated_to_async = False
|
||||
|
||||
try:
|
||||
# Load fresh session (not stale reference)
|
||||
session = await get_chat_session(session_id, user_id)
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found for background tool")
|
||||
await stream_registry.mark_task_completed(task_id, status="failed")
|
||||
return
|
||||
|
||||
# Pass operation_id and task_id to the tool for async processing
|
||||
enriched_parameters = {
|
||||
**parameters,
|
||||
"_operation_id": operation_id,
|
||||
"_task_id": task_id,
|
||||
}
|
||||
|
||||
# Execute the actual tool
|
||||
result = await execute_tool(
|
||||
tool_name=tool_name,
|
||||
parameters=enriched_parameters,
|
||||
tool_call_id=tool_call_id,
|
||||
user_id=user_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
# Check if the tool result indicates async processing
|
||||
# (e.g., Agent Generator returned 202 Accepted)
|
||||
try:
|
||||
if isinstance(result.output, dict):
|
||||
result_data = result.output
|
||||
elif result.output:
|
||||
result_data = orjson.loads(result.output)
|
||||
else:
|
||||
result_data = {}
|
||||
if result_data.get("status") == "accepted":
|
||||
logger.info(
|
||||
f"Tool {tool_name} delegated to async processing "
|
||||
f"(operation_id={operation_id}, task_id={task_id}). "
|
||||
f"Redis Streams completion consumer will handle the rest."
|
||||
)
|
||||
# Don't publish result, don't continue with LLM, and don't cleanup
|
||||
# The Redis Streams consumer (completion_consumer) will handle
|
||||
# everything when the external service completes via webhook
|
||||
delegated_to_async = True
|
||||
return
|
||||
except (orjson.JSONDecodeError, TypeError):
|
||||
pass # Not JSON or not async - continue normally
|
||||
|
||||
# Publish tool result to stream registry
|
||||
await stream_registry.publish_chunk(task_id, result)
|
||||
|
||||
# Update the pending message with result
|
||||
result_str = (
|
||||
result.output
|
||||
if isinstance(result.output, str)
|
||||
else orjson.dumps(result.output).decode("utf-8")
|
||||
)
|
||||
await _update_pending_operation(
|
||||
session_id=session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
result=result_str,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Background tool {tool_name} completed for session {session_id} "
|
||||
f"(task_id={task_id})"
|
||||
)
|
||||
|
||||
# Generate LLM continuation and stream chunks to registry
|
||||
await _generate_llm_continuation_with_streaming(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# Mark task as completed in stream registry
|
||||
await stream_registry.mark_task_completed(task_id, status="completed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Background tool {tool_name} failed: {e}", exc_info=True)
|
||||
error_response = ErrorResponse(
|
||||
message=f"Tool {tool_name} failed: {str(e)}",
|
||||
)
|
||||
|
||||
# Publish error to stream registry followed by finish event
|
||||
await stream_registry.publish_chunk(
|
||||
task_id,
|
||||
StreamError(errorText=str(e)),
|
||||
)
|
||||
await stream_registry.publish_chunk(task_id, StreamFinish())
|
||||
|
||||
await _update_pending_operation(
|
||||
session_id=session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
result=error_response.model_dump_json(),
|
||||
)
|
||||
|
||||
# Mark task as failed in stream registry
|
||||
await stream_registry.mark_task_completed(task_id, status="failed")
|
||||
finally:
|
||||
# Only cleanup if we didn't delegate to async processing
|
||||
# For async path, the Redis Streams completion consumer handles cleanup
|
||||
if not delegated_to_async:
|
||||
await _mark_operation_completed(tool_call_id)
|
||||
|
||||
|
||||
async def _update_pending_operation(
|
||||
session_id: str,
|
||||
tool_call_id: str,
|
||||
@@ -1756,128 +1597,3 @@ async def _generate_llm_continuation(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate LLM continuation: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def _generate_llm_continuation_with_streaming(
|
||||
session_id: str,
|
||||
user_id: str | None,
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""Generate an LLM response with streaming to the stream registry.
|
||||
|
||||
This is called by background tasks to continue the conversation
|
||||
after a tool result is saved. Chunks are published to the stream registry
|
||||
so reconnecting clients can receive them.
|
||||
"""
|
||||
import uuid as uuid_module
|
||||
|
||||
try:
|
||||
# Load fresh session from DB (bypass cache to get the updated tool result)
|
||||
await invalidate_session_cache(session_id)
|
||||
session = await get_chat_session(session_id, user_id)
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found for LLM continuation")
|
||||
return
|
||||
|
||||
# Build system prompt
|
||||
system_prompt, _ = await _build_system_prompt(user_id)
|
||||
|
||||
# Build messages in OpenAI format
|
||||
messages = session.to_openai_messages()
|
||||
if system_prompt:
|
||||
from openai.types.chat import ChatCompletionSystemMessageParam
|
||||
|
||||
system_message = ChatCompletionSystemMessageParam(
|
||||
role="system",
|
||||
content=system_prompt,
|
||||
)
|
||||
messages = [system_message] + messages
|
||||
|
||||
# Build extra_body for tracing
|
||||
extra_body: dict[str, Any] = {
|
||||
"posthogProperties": {
|
||||
"environment": settings.config.app_env.value,
|
||||
},
|
||||
}
|
||||
if user_id:
|
||||
extra_body["user"] = user_id[:128]
|
||||
extra_body["posthogDistinctId"] = user_id
|
||||
if session_id:
|
||||
extra_body["session_id"] = session_id[:128]
|
||||
|
||||
# Make streaming LLM call (no tools - just text response)
|
||||
from typing import cast
|
||||
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
|
||||
# Generate unique IDs for AI SDK protocol
|
||||
message_id = str(uuid_module.uuid4())
|
||||
text_block_id = str(uuid_module.uuid4())
|
||||
|
||||
# Publish start event
|
||||
await stream_registry.publish_chunk(task_id, StreamStart(messageId=message_id))
|
||||
await stream_registry.publish_chunk(task_id, StreamTextStart(id=text_block_id))
|
||||
|
||||
# Stream the response
|
||||
stream = await client.chat.completions.create(
|
||||
model=config.model,
|
||||
messages=cast(list[ChatCompletionMessageParam], messages),
|
||||
extra_body=extra_body,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
assistant_content = ""
|
||||
async for chunk in stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
delta = chunk.choices[0].delta.content
|
||||
assistant_content += delta
|
||||
# Publish delta to stream registry
|
||||
await stream_registry.publish_chunk(
|
||||
task_id,
|
||||
StreamTextDelta(id=text_block_id, delta=delta),
|
||||
)
|
||||
|
||||
# Publish end events
|
||||
await stream_registry.publish_chunk(task_id, StreamTextEnd(id=text_block_id))
|
||||
|
||||
if assistant_content:
|
||||
# Reload session from DB to avoid race condition with user messages
|
||||
fresh_session = await get_chat_session(session_id, user_id)
|
||||
if not fresh_session:
|
||||
logger.error(
|
||||
f"Session {session_id} disappeared during LLM continuation"
|
||||
)
|
||||
return
|
||||
|
||||
# Save assistant message to database
|
||||
assistant_message = ChatMessage(
|
||||
role="assistant",
|
||||
content=assistant_content,
|
||||
)
|
||||
fresh_session.messages.append(assistant_message)
|
||||
|
||||
# Save to database (not cache) to persist the response
|
||||
await upsert_chat_session(fresh_session)
|
||||
|
||||
# Invalidate cache so next poll/refresh gets fresh data
|
||||
await invalidate_session_cache(session_id)
|
||||
|
||||
logger.info(
|
||||
f"Generated streaming LLM continuation for session {session_id} "
|
||||
f"(task_id={task_id}), response length: {len(assistant_content)}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Streaming LLM continuation returned empty response for {session_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to generate streaming LLM continuation: {e}", exc_info=True
|
||||
)
|
||||
# Publish error to stream registry followed by finish event
|
||||
await stream_registry.publish_chunk(
|
||||
task_id,
|
||||
StreamError(errorText=f"Failed to generate response: {e}"),
|
||||
)
|
||||
await stream_registry.publish_chunk(task_id, StreamFinish())
|
||||
|
||||
@@ -1,704 +0,0 @@
|
||||
"""Stream registry for managing reconnectable SSE streams.
|
||||
|
||||
This module provides a registry for tracking active streaming tasks and their
|
||||
messages. It uses Redis for all state management (no in-memory state), making
|
||||
pods stateless and horizontally scalable.
|
||||
|
||||
Architecture:
|
||||
- Redis Stream: Persists all messages for replay and real-time delivery
|
||||
- Redis Hash: Task metadata (status, session_id, etc.)
|
||||
|
||||
Subscribers:
|
||||
1. Replay missed messages from Redis Stream (XREAD)
|
||||
2. Listen for live updates via blocking XREAD
|
||||
3. No in-memory state required on the subscribing pod
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
import orjson
|
||||
|
||||
from backend.data.redis_client import get_redis_async
|
||||
|
||||
from .config import ChatConfig
|
||||
from .response_model import StreamBaseResponse, StreamError, StreamFinish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
config = ChatConfig()
|
||||
|
||||
# Track background tasks for this pod (just the asyncio.Task reference, not subscribers)
|
||||
_local_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
# Track listener tasks per subscriber queue for cleanup
|
||||
# Maps queue id() to (task_id, asyncio.Task) for proper cleanup on unsubscribe
|
||||
_listener_tasks: dict[int, tuple[str, asyncio.Task]] = {}
|
||||
|
||||
# Timeout for putting chunks into subscriber queues (seconds)
|
||||
# If the queue is full and doesn't drain within this time, send an overflow error
|
||||
QUEUE_PUT_TIMEOUT = 5.0
|
||||
|
||||
# Lua script for atomic compare-and-swap status update (idempotent completion)
|
||||
# Returns 1 if status was updated, 0 if already completed/failed
|
||||
COMPLETE_TASK_SCRIPT = """
|
||||
local current = redis.call("HGET", KEYS[1], "status")
|
||||
if current == "running" then
|
||||
redis.call("HSET", KEYS[1], "status", ARGV[1])
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveTask:
|
||||
"""Represents an active streaming task (metadata only, no in-memory queues)."""
|
||||
|
||||
task_id: str
|
||||
session_id: str
|
||||
user_id: str | None
|
||||
tool_call_id: str
|
||||
tool_name: str
|
||||
operation_id: str
|
||||
status: Literal["running", "completed", "failed"] = "running"
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
asyncio_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
def _get_task_meta_key(task_id: str) -> str:
|
||||
"""Get Redis key for task metadata."""
|
||||
return f"{config.task_meta_prefix}{task_id}"
|
||||
|
||||
|
||||
def _get_task_stream_key(task_id: str) -> str:
|
||||
"""Get Redis key for task message stream."""
|
||||
return f"{config.task_stream_prefix}{task_id}"
|
||||
|
||||
|
||||
def _get_operation_mapping_key(operation_id: str) -> str:
|
||||
"""Get Redis key for operation_id to task_id mapping."""
|
||||
return f"{config.task_op_prefix}{operation_id}"
|
||||
|
||||
|
||||
async def create_task(
|
||||
task_id: str,
|
||||
session_id: str,
|
||||
user_id: str | None,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
operation_id: str,
|
||||
) -> ActiveTask:
|
||||
"""Create a new streaming task in Redis.
|
||||
|
||||
Args:
|
||||
task_id: Unique identifier for the task
|
||||
session_id: Chat session ID
|
||||
user_id: User ID (may be None for anonymous)
|
||||
tool_call_id: Tool call ID from the LLM
|
||||
tool_name: Name of the tool being executed
|
||||
operation_id: Operation ID for webhook callbacks
|
||||
|
||||
Returns:
|
||||
The created ActiveTask instance (metadata only)
|
||||
"""
|
||||
task = ActiveTask(
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=tool_name,
|
||||
operation_id=operation_id,
|
||||
)
|
||||
|
||||
# Store metadata in Redis
|
||||
redis = await get_redis_async()
|
||||
meta_key = _get_task_meta_key(task_id)
|
||||
op_key = _get_operation_mapping_key(operation_id)
|
||||
|
||||
await redis.hset( # type: ignore[misc]
|
||||
meta_key,
|
||||
mapping={
|
||||
"task_id": task_id,
|
||||
"session_id": session_id,
|
||||
"user_id": user_id or "",
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_name": tool_name,
|
||||
"operation_id": operation_id,
|
||||
"status": task.status,
|
||||
"created_at": task.created_at.isoformat(),
|
||||
},
|
||||
)
|
||||
await redis.expire(meta_key, config.stream_ttl)
|
||||
|
||||
# Create operation_id -> task_id mapping for webhook lookups
|
||||
await redis.set(op_key, task_id, ex=config.stream_ttl)
|
||||
|
||||
logger.debug(f"Created task {task_id} for session {session_id}")
|
||||
|
||||
return task
|
||||
|
||||
|
||||
async def publish_chunk(
|
||||
task_id: str,
|
||||
chunk: StreamBaseResponse,
|
||||
) -> str:
|
||||
"""Publish a chunk to Redis Stream.
|
||||
|
||||
All delivery is via Redis Streams - no in-memory state.
|
||||
|
||||
Args:
|
||||
task_id: Task ID to publish to
|
||||
chunk: The stream response chunk to publish
|
||||
|
||||
Returns:
|
||||
The Redis Stream message ID
|
||||
"""
|
||||
chunk_json = chunk.model_dump_json()
|
||||
message_id = "0-0"
|
||||
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
stream_key = _get_task_stream_key(task_id)
|
||||
|
||||
# Write to Redis Stream for persistence and real-time delivery
|
||||
raw_id = await redis.xadd(
|
||||
stream_key,
|
||||
{"data": chunk_json},
|
||||
maxlen=config.stream_max_length,
|
||||
)
|
||||
message_id = raw_id if isinstance(raw_id, str) else raw_id.decode()
|
||||
|
||||
# Set TTL on stream to match task metadata TTL
|
||||
await redis.expire(stream_key, config.stream_ttl)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to publish chunk for task {task_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return message_id
|
||||
|
||||
|
||||
async def subscribe_to_task(
|
||||
task_id: str,
|
||||
user_id: str | None,
|
||||
last_message_id: str = "0-0",
|
||||
) -> asyncio.Queue[StreamBaseResponse] | None:
|
||||
"""Subscribe to a task's stream with replay of missed messages.
|
||||
|
||||
This is fully stateless - uses Redis Stream for replay and pub/sub for live updates.
|
||||
|
||||
Args:
|
||||
task_id: Task ID to subscribe to
|
||||
user_id: User ID for ownership validation
|
||||
last_message_id: Last Redis Stream message ID received ("0-0" for full replay)
|
||||
|
||||
Returns:
|
||||
An asyncio Queue that will receive stream chunks, or None if task not found
|
||||
or user doesn't have access
|
||||
"""
|
||||
redis = await get_redis_async()
|
||||
meta_key = _get_task_meta_key(task_id)
|
||||
meta: dict[Any, Any] = await redis.hgetall(meta_key) # type: ignore[misc]
|
||||
|
||||
if not meta:
|
||||
logger.debug(f"Task {task_id} not found in Redis")
|
||||
return None
|
||||
|
||||
# Note: Redis client uses decode_responses=True, so keys are strings
|
||||
task_status = meta.get("status", "")
|
||||
task_user_id = meta.get("user_id", "") or None
|
||||
|
||||
# Validate ownership - if task has an owner, requester must match
|
||||
if task_user_id:
|
||||
if user_id != task_user_id:
|
||||
logger.warning(
|
||||
f"User {user_id} denied access to task {task_id} "
|
||||
f"owned by {task_user_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
subscriber_queue: asyncio.Queue[StreamBaseResponse] = asyncio.Queue()
|
||||
stream_key = _get_task_stream_key(task_id)
|
||||
|
||||
# Step 1: Replay messages from Redis Stream
|
||||
messages = await redis.xread({stream_key: last_message_id}, block=0, count=1000)
|
||||
|
||||
replayed_count = 0
|
||||
replay_last_id = last_message_id
|
||||
if messages:
|
||||
for _stream_name, stream_messages in messages:
|
||||
for msg_id, msg_data in stream_messages:
|
||||
replay_last_id = msg_id if isinstance(msg_id, str) else msg_id.decode()
|
||||
# Note: Redis client uses decode_responses=True, so keys are strings
|
||||
if "data" in msg_data:
|
||||
try:
|
||||
chunk_data = orjson.loads(msg_data["data"])
|
||||
chunk = _reconstruct_chunk(chunk_data)
|
||||
if chunk:
|
||||
await subscriber_queue.put(chunk)
|
||||
replayed_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to replay message: {e}")
|
||||
|
||||
logger.debug(f"Task {task_id}: replayed {replayed_count} messages")
|
||||
|
||||
# Step 2: If task is still running, start stream listener for live updates
|
||||
if task_status == "running":
|
||||
listener_task = asyncio.create_task(
|
||||
_stream_listener(task_id, subscriber_queue, replay_last_id)
|
||||
)
|
||||
# Track listener task for cleanup on unsubscribe
|
||||
_listener_tasks[id(subscriber_queue)] = (task_id, listener_task)
|
||||
else:
|
||||
# Task is completed/failed - add finish marker
|
||||
await subscriber_queue.put(StreamFinish())
|
||||
|
||||
return subscriber_queue
|
||||
|
||||
|
||||
async def _stream_listener(
|
||||
task_id: str,
|
||||
subscriber_queue: asyncio.Queue[StreamBaseResponse],
|
||||
last_replayed_id: str,
|
||||
) -> None:
|
||||
"""Listen to Redis Stream for new messages using blocking XREAD.
|
||||
|
||||
This approach avoids the duplicate message issue that can occur with pub/sub
|
||||
when messages are published during the gap between replay and subscription.
|
||||
|
||||
Args:
|
||||
task_id: Task ID to listen for
|
||||
subscriber_queue: Queue to deliver messages to
|
||||
last_replayed_id: Last message ID from replay (continue from here)
|
||||
"""
|
||||
queue_id = id(subscriber_queue)
|
||||
# Track the last successfully delivered message ID for recovery hints
|
||||
last_delivered_id = last_replayed_id
|
||||
|
||||
try:
|
||||
redis = await get_redis_async()
|
||||
stream_key = _get_task_stream_key(task_id)
|
||||
current_id = last_replayed_id
|
||||
|
||||
while True:
|
||||
# Block for up to 30 seconds waiting for new messages
|
||||
# This allows periodic checking if task is still running
|
||||
messages = await redis.xread(
|
||||
{stream_key: current_id}, block=30000, count=100
|
||||
)
|
||||
|
||||
if not messages:
|
||||
# Timeout - check if task is still running
|
||||
meta_key = _get_task_meta_key(task_id)
|
||||
status = await redis.hget(meta_key, "status") # type: ignore[misc]
|
||||
if status and status != "running":
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
subscriber_queue.put(StreamFinish()),
|
||||
timeout=QUEUE_PUT_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"Timeout delivering finish event for task {task_id}"
|
||||
)
|
||||
break
|
||||
continue
|
||||
|
||||
for _stream_name, stream_messages in messages:
|
||||
for msg_id, msg_data in stream_messages:
|
||||
current_id = msg_id if isinstance(msg_id, str) else msg_id.decode()
|
||||
|
||||
if "data" not in msg_data:
|
||||
continue
|
||||
|
||||
try:
|
||||
chunk_data = orjson.loads(msg_data["data"])
|
||||
chunk = _reconstruct_chunk(chunk_data)
|
||||
if chunk:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
subscriber_queue.put(chunk),
|
||||
timeout=QUEUE_PUT_TIMEOUT,
|
||||
)
|
||||
# Update last delivered ID on successful delivery
|
||||
last_delivered_id = current_id
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"Subscriber queue full for task {task_id}, "
|
||||
f"message delivery timed out after {QUEUE_PUT_TIMEOUT}s"
|
||||
)
|
||||
# Send overflow error with recovery info
|
||||
try:
|
||||
overflow_error = StreamError(
|
||||
errorText="Message delivery timeout - some messages may have been missed",
|
||||
code="QUEUE_OVERFLOW",
|
||||
details={
|
||||
"last_delivered_id": last_delivered_id,
|
||||
"recovery_hint": f"Reconnect with last_message_id={last_delivered_id}",
|
||||
},
|
||||
)
|
||||
subscriber_queue.put_nowait(overflow_error)
|
||||
except asyncio.QueueFull:
|
||||
# Queue is completely stuck, nothing more we can do
|
||||
logger.error(
|
||||
f"Cannot deliver overflow error for task {task_id}, "
|
||||
"queue completely blocked"
|
||||
)
|
||||
|
||||
# Stop listening on finish
|
||||
if isinstance(chunk, StreamFinish):
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing stream message: {e}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"Stream listener cancelled for task {task_id}")
|
||||
raise # Re-raise to propagate cancellation
|
||||
except Exception as e:
|
||||
logger.error(f"Stream listener error for task {task_id}: {e}")
|
||||
# On error, send finish to unblock subscriber
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
subscriber_queue.put(StreamFinish()),
|
||||
timeout=QUEUE_PUT_TIMEOUT,
|
||||
)
|
||||
except (asyncio.TimeoutError, asyncio.QueueFull):
|
||||
logger.warning(
|
||||
f"Could not deliver finish event for task {task_id} after error"
|
||||
)
|
||||
finally:
|
||||
# Clean up listener task mapping on exit
|
||||
_listener_tasks.pop(queue_id, None)
|
||||
|
||||
|
||||
async def mark_task_completed(
|
||||
task_id: str,
|
||||
status: Literal["completed", "failed"] = "completed",
|
||||
) -> bool:
|
||||
"""Mark a task as completed and publish finish event.
|
||||
|
||||
This is idempotent - calling multiple times with the same task_id is safe.
|
||||
Uses atomic compare-and-swap via Lua script to prevent race conditions.
|
||||
Status is updated first (source of truth), then finish event is published (best-effort).
|
||||
|
||||
Args:
|
||||
task_id: Task ID to mark as completed
|
||||
status: Final status ("completed" or "failed")
|
||||
|
||||
Returns:
|
||||
True if task was newly marked completed, False if already completed/failed
|
||||
"""
|
||||
redis = await get_redis_async()
|
||||
meta_key = _get_task_meta_key(task_id)
|
||||
|
||||
# Atomic compare-and-swap: only update if status is "running"
|
||||
# This prevents race conditions when multiple callers try to complete simultaneously
|
||||
result = await redis.eval(COMPLETE_TASK_SCRIPT, 1, meta_key, status) # type: ignore[misc]
|
||||
|
||||
if result == 0:
|
||||
logger.debug(f"Task {task_id} already completed/failed, skipping")
|
||||
return False
|
||||
|
||||
# THEN publish finish event (best-effort - listeners can detect via status polling)
|
||||
try:
|
||||
await publish_chunk(task_id, StreamFinish())
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to publish finish event for task {task_id}: {e}. "
|
||||
"Listeners will detect completion via status polling."
|
||||
)
|
||||
|
||||
# Clean up local task reference if exists
|
||||
_local_tasks.pop(task_id, None)
|
||||
return True
|
||||
|
||||
|
||||
async def find_task_by_operation_id(operation_id: str) -> ActiveTask | None:
|
||||
"""Find a task by its operation ID.
|
||||
|
||||
Used by webhook callbacks to locate the task to update.
|
||||
|
||||
Args:
|
||||
operation_id: Operation ID to search for
|
||||
|
||||
Returns:
|
||||
ActiveTask if found, None otherwise
|
||||
"""
|
||||
redis = await get_redis_async()
|
||||
op_key = _get_operation_mapping_key(operation_id)
|
||||
task_id = await redis.get(op_key)
|
||||
|
||||
if not task_id:
|
||||
return None
|
||||
|
||||
task_id_str = task_id.decode() if isinstance(task_id, bytes) else task_id
|
||||
return await get_task(task_id_str)
|
||||
|
||||
|
||||
async def get_task(task_id: str) -> ActiveTask | None:
|
||||
"""Get a task by its ID from Redis.
|
||||
|
||||
Args:
|
||||
task_id: Task ID to look up
|
||||
|
||||
Returns:
|
||||
ActiveTask if found, None otherwise
|
||||
"""
|
||||
redis = await get_redis_async()
|
||||
meta_key = _get_task_meta_key(task_id)
|
||||
meta: dict[Any, Any] = await redis.hgetall(meta_key) # type: ignore[misc]
|
||||
|
||||
if not meta:
|
||||
return None
|
||||
|
||||
# Note: Redis client uses decode_responses=True, so keys/values are strings
|
||||
return ActiveTask(
|
||||
task_id=meta.get("task_id", ""),
|
||||
session_id=meta.get("session_id", ""),
|
||||
user_id=meta.get("user_id", "") or None,
|
||||
tool_call_id=meta.get("tool_call_id", ""),
|
||||
tool_name=meta.get("tool_name", ""),
|
||||
operation_id=meta.get("operation_id", ""),
|
||||
status=meta.get("status", "running"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
async def get_task_with_expiry_info(
|
||||
task_id: str,
|
||||
) -> tuple[ActiveTask | None, str | None]:
|
||||
"""Get a task by its ID with expiration detection.
|
||||
|
||||
Returns (task, error_code) where error_code is:
|
||||
- None if task found
|
||||
- "TASK_EXPIRED" if stream exists but metadata is gone (TTL expired)
|
||||
- "TASK_NOT_FOUND" if neither exists
|
||||
|
||||
Args:
|
||||
task_id: Task ID to look up
|
||||
|
||||
Returns:
|
||||
Tuple of (ActiveTask or None, error_code or None)
|
||||
"""
|
||||
redis = await get_redis_async()
|
||||
meta_key = _get_task_meta_key(task_id)
|
||||
stream_key = _get_task_stream_key(task_id)
|
||||
|
||||
meta: dict[Any, Any] = await redis.hgetall(meta_key) # type: ignore[misc]
|
||||
|
||||
if not meta:
|
||||
# Check if stream still has data (metadata expired but stream hasn't)
|
||||
stream_len = await redis.xlen(stream_key)
|
||||
if stream_len > 0:
|
||||
return None, "TASK_EXPIRED"
|
||||
return None, "TASK_NOT_FOUND"
|
||||
|
||||
# Note: Redis client uses decode_responses=True, so keys/values are strings
|
||||
return (
|
||||
ActiveTask(
|
||||
task_id=meta.get("task_id", ""),
|
||||
session_id=meta.get("session_id", ""),
|
||||
user_id=meta.get("user_id", "") or None,
|
||||
tool_call_id=meta.get("tool_call_id", ""),
|
||||
tool_name=meta.get("tool_name", ""),
|
||||
operation_id=meta.get("operation_id", ""),
|
||||
status=meta.get("status", "running"), # type: ignore[arg-type]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
async def get_active_task_for_session(
|
||||
session_id: str,
|
||||
user_id: str | None = None,
|
||||
) -> tuple[ActiveTask | None, str]:
|
||||
"""Get the active (running) task for a session, if any.
|
||||
|
||||
Scans Redis for tasks matching the session_id with status="running".
|
||||
|
||||
Args:
|
||||
session_id: Session ID to look up
|
||||
user_id: User ID for ownership validation (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (ActiveTask if found and running, last_message_id from Redis Stream)
|
||||
"""
|
||||
|
||||
redis = await get_redis_async()
|
||||
|
||||
# Scan Redis for task metadata keys
|
||||
cursor = 0
|
||||
tasks_checked = 0
|
||||
|
||||
while True:
|
||||
cursor, keys = await redis.scan(
|
||||
cursor, match=f"{config.task_meta_prefix}*", count=100
|
||||
)
|
||||
|
||||
for key in keys:
|
||||
tasks_checked += 1
|
||||
meta: dict[Any, Any] = await redis.hgetall(key) # type: ignore[misc]
|
||||
if not meta:
|
||||
continue
|
||||
|
||||
# Note: Redis client uses decode_responses=True, so keys/values are strings
|
||||
task_session_id = meta.get("session_id", "")
|
||||
task_status = meta.get("status", "")
|
||||
task_user_id = meta.get("user_id", "") or None
|
||||
task_id = meta.get("task_id", "")
|
||||
|
||||
if task_session_id == session_id and task_status == "running":
|
||||
# Validate ownership - if task has an owner, requester must match
|
||||
if task_user_id and user_id != task_user_id:
|
||||
continue
|
||||
|
||||
# Get the last message ID from Redis Stream
|
||||
stream_key = _get_task_stream_key(task_id)
|
||||
last_id = "0-0"
|
||||
try:
|
||||
messages = await redis.xrevrange(stream_key, count=1)
|
||||
if messages:
|
||||
msg_id = messages[0][0]
|
||||
last_id = msg_id if isinstance(msg_id, str) else msg_id.decode()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get last message ID: {e}")
|
||||
|
||||
return (
|
||||
ActiveTask(
|
||||
task_id=task_id,
|
||||
session_id=task_session_id,
|
||||
user_id=task_user_id,
|
||||
tool_call_id=meta.get("tool_call_id", ""),
|
||||
tool_name=meta.get("tool_name", ""),
|
||||
operation_id=meta.get("operation_id", ""),
|
||||
status="running",
|
||||
),
|
||||
last_id,
|
||||
)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
return None, "0-0"
|
||||
|
||||
|
||||
def _reconstruct_chunk(chunk_data: dict) -> StreamBaseResponse | None:
|
||||
"""Reconstruct a StreamBaseResponse from JSON data.
|
||||
|
||||
Args:
|
||||
chunk_data: Parsed JSON data from Redis
|
||||
|
||||
Returns:
|
||||
Reconstructed response object, or None if unknown type
|
||||
"""
|
||||
from .response_model import (
|
||||
ResponseType,
|
||||
StreamError,
|
||||
StreamFinish,
|
||||
StreamHeartbeat,
|
||||
StreamStart,
|
||||
StreamTextDelta,
|
||||
StreamTextEnd,
|
||||
StreamTextStart,
|
||||
StreamToolInputAvailable,
|
||||
StreamToolInputStart,
|
||||
StreamToolOutputAvailable,
|
||||
StreamUsage,
|
||||
)
|
||||
|
||||
# Map response types to their corresponding classes
|
||||
type_to_class: dict[str, type[StreamBaseResponse]] = {
|
||||
ResponseType.START.value: StreamStart,
|
||||
ResponseType.FINISH.value: StreamFinish,
|
||||
ResponseType.TEXT_START.value: StreamTextStart,
|
||||
ResponseType.TEXT_DELTA.value: StreamTextDelta,
|
||||
ResponseType.TEXT_END.value: StreamTextEnd,
|
||||
ResponseType.TOOL_INPUT_START.value: StreamToolInputStart,
|
||||
ResponseType.TOOL_INPUT_AVAILABLE.value: StreamToolInputAvailable,
|
||||
ResponseType.TOOL_OUTPUT_AVAILABLE.value: StreamToolOutputAvailable,
|
||||
ResponseType.ERROR.value: StreamError,
|
||||
ResponseType.USAGE.value: StreamUsage,
|
||||
ResponseType.HEARTBEAT.value: StreamHeartbeat,
|
||||
}
|
||||
|
||||
chunk_type = chunk_data.get("type")
|
||||
chunk_class = type_to_class.get(chunk_type) # type: ignore[arg-type]
|
||||
|
||||
if chunk_class is None:
|
||||
logger.warning(f"Unknown chunk type: {chunk_type}")
|
||||
return None
|
||||
|
||||
try:
|
||||
return chunk_class(**chunk_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reconstruct chunk of type {chunk_type}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def set_task_asyncio_task(task_id: str, asyncio_task: asyncio.Task) -> None:
|
||||
"""Track the asyncio.Task for a task (local reference only).
|
||||
|
||||
This is just for cleanup purposes - the task state is in Redis.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
asyncio_task: The asyncio Task to track
|
||||
"""
|
||||
_local_tasks[task_id] = asyncio_task
|
||||
|
||||
|
||||
async def unsubscribe_from_task(
|
||||
task_id: str,
|
||||
subscriber_queue: asyncio.Queue[StreamBaseResponse],
|
||||
) -> None:
|
||||
"""Clean up when a subscriber disconnects.
|
||||
|
||||
Cancels the XREAD-based listener task associated with this subscriber queue
|
||||
to prevent resource leaks.
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
subscriber_queue: The subscriber's queue used to look up the listener task
|
||||
"""
|
||||
queue_id = id(subscriber_queue)
|
||||
listener_entry = _listener_tasks.pop(queue_id, None)
|
||||
|
||||
if listener_entry is None:
|
||||
logger.debug(
|
||||
f"No listener task found for task {task_id} queue {queue_id} "
|
||||
"(may have already completed)"
|
||||
)
|
||||
return
|
||||
|
||||
stored_task_id, listener_task = listener_entry
|
||||
|
||||
if stored_task_id != task_id:
|
||||
logger.warning(
|
||||
f"Task ID mismatch in unsubscribe: expected {task_id}, "
|
||||
f"found {stored_task_id}"
|
||||
)
|
||||
|
||||
if listener_task.done():
|
||||
logger.debug(f"Listener task for task {task_id} already completed")
|
||||
return
|
||||
|
||||
# Cancel the listener task
|
||||
listener_task.cancel()
|
||||
|
||||
try:
|
||||
# Wait for the task to be cancelled with a timeout
|
||||
await asyncio.wait_for(listener_task, timeout=5.0)
|
||||
except asyncio.CancelledError:
|
||||
# Expected - the task was successfully cancelled
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"Timeout waiting for listener task cancellation for task {task_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during listener task cancellation for task {task_id}: {e}")
|
||||
|
||||
logger.debug(f"Successfully unsubscribed from task {task_id}")
|
||||
@@ -10,7 +10,6 @@ from .add_understanding import AddUnderstandingTool
|
||||
from .agent_output import AgentOutputTool
|
||||
from .base import BaseTool
|
||||
from .create_agent import CreateAgentTool
|
||||
from .customize_agent import CustomizeAgentTool
|
||||
from .edit_agent import EditAgentTool
|
||||
from .find_agent import FindAgentTool
|
||||
from .find_block import FindBlockTool
|
||||
@@ -35,7 +34,6 @@ logger = logging.getLogger(__name__)
|
||||
TOOL_REGISTRY: dict[str, BaseTool] = {
|
||||
"add_understanding": AddUnderstandingTool(),
|
||||
"create_agent": CreateAgentTool(),
|
||||
"customize_agent": CustomizeAgentTool(),
|
||||
"edit_agent": EditAgentTool(),
|
||||
"find_agent": FindAgentTool(),
|
||||
"find_block": FindBlockTool(),
|
||||
|
||||
@@ -8,7 +8,6 @@ from .core import (
|
||||
DecompositionStep,
|
||||
LibraryAgentSummary,
|
||||
MarketplaceAgentSummary,
|
||||
customize_template,
|
||||
decompose_goal,
|
||||
enrich_library_agents_from_steps,
|
||||
extract_search_terms_from_steps,
|
||||
@@ -20,7 +19,6 @@ from .core import (
|
||||
get_library_agent_by_graph_id,
|
||||
get_library_agent_by_id,
|
||||
get_library_agents_for_generation,
|
||||
graph_to_json,
|
||||
json_to_graph,
|
||||
save_agent_to_library,
|
||||
search_marketplace_agents_for_generation,
|
||||
@@ -38,7 +36,6 @@ __all__ = [
|
||||
"LibraryAgentSummary",
|
||||
"MarketplaceAgentSummary",
|
||||
"check_external_service_health",
|
||||
"customize_template",
|
||||
"decompose_goal",
|
||||
"enrich_library_agents_from_steps",
|
||||
"extract_search_terms_from_steps",
|
||||
@@ -51,7 +48,6 @@ __all__ = [
|
||||
"get_library_agent_by_id",
|
||||
"get_library_agents_for_generation",
|
||||
"get_user_message_for_error",
|
||||
"graph_to_json",
|
||||
"is_external_service_configured",
|
||||
"json_to_graph",
|
||||
"save_agent_to_library",
|
||||
|
||||
@@ -19,7 +19,6 @@ from backend.data.graph import (
|
||||
from backend.util.exceptions import DatabaseError, NotFoundError
|
||||
|
||||
from .service import (
|
||||
customize_template_external,
|
||||
decompose_goal_external,
|
||||
generate_agent_external,
|
||||
generate_agent_patch_external,
|
||||
@@ -550,21 +549,15 @@ async def decompose_goal(
|
||||
async def generate_agent(
|
||||
instructions: DecompositionResult | dict[str, Any],
|
||||
library_agents: list[AgentSummary] | list[dict[str, Any]] | None = None,
|
||||
operation_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Generate agent JSON from instructions.
|
||||
|
||||
Args:
|
||||
instructions: Structured instructions from decompose_goal
|
||||
library_agents: User's library agents available for sub-agent composition
|
||||
operation_id: Operation ID for async processing (enables Redis Streams
|
||||
completion notification)
|
||||
task_id: Task ID for async processing (enables Redis Streams persistence
|
||||
and SSE delivery)
|
||||
|
||||
Returns:
|
||||
Agent JSON dict, {"status": "accepted"} for async, error dict {"type": "error", ...}, or None on error
|
||||
Agent JSON dict, error dict {"type": "error", ...}, or None on error
|
||||
|
||||
Raises:
|
||||
AgentGeneratorNotConfiguredError: If the external service is not configured.
|
||||
@@ -572,13 +565,8 @@ async def generate_agent(
|
||||
_check_service_configured()
|
||||
logger.info("Calling external Agent Generator service for generate_agent")
|
||||
result = await generate_agent_external(
|
||||
dict(instructions), _to_dict_list(library_agents), operation_id, task_id
|
||||
dict(instructions), _to_dict_list(library_agents)
|
||||
)
|
||||
|
||||
# Don't modify async response
|
||||
if result and result.get("status") == "accepted":
|
||||
return result
|
||||
|
||||
if result:
|
||||
if isinstance(result, dict) and result.get("type") == "error":
|
||||
return result
|
||||
@@ -752,15 +740,32 @@ async def save_agent_to_library(
|
||||
return created_graph, library_agents[0]
|
||||
|
||||
|
||||
def graph_to_json(graph: Graph) -> dict[str, Any]:
|
||||
"""Convert a Graph object to JSON format for the agent generator.
|
||||
async def get_agent_as_json(
|
||||
agent_id: str, user_id: str | None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Fetch an agent and convert to JSON format for editing.
|
||||
|
||||
Args:
|
||||
graph: Graph object to convert
|
||||
agent_id: Graph ID or library agent ID
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Agent as JSON dict
|
||||
Agent as JSON dict or None if not found
|
||||
"""
|
||||
graph = await get_graph(agent_id, version=None, user_id=user_id)
|
||||
|
||||
if not graph and user_id:
|
||||
try:
|
||||
library_agent = await library_db.get_library_agent(agent_id, user_id)
|
||||
graph = await get_graph(
|
||||
library_agent.graph_id, version=None, user_id=user_id
|
||||
)
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
if not graph:
|
||||
return None
|
||||
|
||||
nodes = []
|
||||
for node in graph.nodes:
|
||||
nodes.append(
|
||||
@@ -797,41 +802,10 @@ def graph_to_json(graph: Graph) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def get_agent_as_json(
|
||||
agent_id: str, user_id: str | None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Fetch an agent and convert to JSON format for editing.
|
||||
|
||||
Args:
|
||||
agent_id: Graph ID or library agent ID
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Agent as JSON dict or None if not found
|
||||
"""
|
||||
graph = await get_graph(agent_id, version=None, user_id=user_id)
|
||||
|
||||
if not graph and user_id:
|
||||
try:
|
||||
library_agent = await library_db.get_library_agent(agent_id, user_id)
|
||||
graph = await get_graph(
|
||||
library_agent.graph_id, version=None, user_id=user_id
|
||||
)
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
if not graph:
|
||||
return None
|
||||
|
||||
return graph_to_json(graph)
|
||||
|
||||
|
||||
async def generate_agent_patch(
|
||||
update_request: str,
|
||||
current_agent: dict[str, Any],
|
||||
library_agents: list[AgentSummary] | None = None,
|
||||
operation_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update an existing agent using natural language.
|
||||
|
||||
@@ -844,12 +818,10 @@ async def generate_agent_patch(
|
||||
update_request: Natural language description of changes
|
||||
current_agent: Current agent JSON
|
||||
library_agents: User's library agents available for sub-agent composition
|
||||
operation_id: Operation ID for async processing (enables Redis Streams callback)
|
||||
task_id: Task ID for async processing (enables Redis Streams callback)
|
||||
|
||||
Returns:
|
||||
Updated agent JSON, clarifying questions dict {"type": "clarifying_questions", ...},
|
||||
{"status": "accepted"} for async, error dict {"type": "error", ...}, or None on error
|
||||
error dict {"type": "error", ...}, or None on unexpected error
|
||||
|
||||
Raises:
|
||||
AgentGeneratorNotConfiguredError: If the external service is not configured.
|
||||
@@ -857,43 +829,5 @@ async def generate_agent_patch(
|
||||
_check_service_configured()
|
||||
logger.info("Calling external Agent Generator service for generate_agent_patch")
|
||||
return await generate_agent_patch_external(
|
||||
update_request,
|
||||
current_agent,
|
||||
_to_dict_list(library_agents),
|
||||
operation_id,
|
||||
task_id,
|
||||
)
|
||||
|
||||
|
||||
async def customize_template(
|
||||
template_agent: dict[str, Any],
|
||||
modification_request: str,
|
||||
context: str = "",
|
||||
) -> dict[str, Any] | None:
|
||||
"""Customize a template/marketplace agent using natural language.
|
||||
|
||||
This is used when users want to modify a template or marketplace agent
|
||||
to fit their specific needs before adding it to their library.
|
||||
|
||||
The external Agent Generator service handles:
|
||||
- Understanding the modification request
|
||||
- Applying changes to the template
|
||||
- Fixing and validating the result
|
||||
|
||||
Args:
|
||||
template_agent: The template agent JSON to customize
|
||||
modification_request: Natural language description of customizations
|
||||
context: Additional context (e.g., answers to previous questions)
|
||||
|
||||
Returns:
|
||||
Customized agent JSON, clarifying questions dict {"type": "clarifying_questions", ...},
|
||||
error dict {"type": "error", ...}, or None on unexpected error
|
||||
|
||||
Raises:
|
||||
AgentGeneratorNotConfiguredError: If the external service is not configured.
|
||||
"""
|
||||
_check_service_configured()
|
||||
logger.info("Calling external Agent Generator service for customize_template")
|
||||
return await customize_template_external(
|
||||
template_agent, modification_request, context
|
||||
update_request, current_agent, _to_dict_list(library_agents)
|
||||
)
|
||||
|
||||
@@ -212,45 +212,24 @@ async def decompose_goal_external(
|
||||
async def generate_agent_external(
|
||||
instructions: dict[str, Any],
|
||||
library_agents: list[dict[str, Any]] | None = None,
|
||||
operation_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Call the external service to generate an agent from instructions.
|
||||
|
||||
Args:
|
||||
instructions: Structured instructions from decompose_goal
|
||||
library_agents: User's library agents available for sub-agent composition
|
||||
operation_id: Operation ID for async processing (enables Redis Streams callback)
|
||||
task_id: Task ID for async processing (enables Redis Streams callback)
|
||||
|
||||
Returns:
|
||||
Agent JSON dict, {"status": "accepted"} for async, or error dict {"type": "error", ...} on error
|
||||
Agent JSON dict on success, or error dict {"type": "error", ...} on error
|
||||
"""
|
||||
client = _get_client()
|
||||
|
||||
# Build request payload
|
||||
payload: dict[str, Any] = {"instructions": instructions}
|
||||
if library_agents:
|
||||
payload["library_agents"] = library_agents
|
||||
if operation_id and task_id:
|
||||
payload["operation_id"] = operation_id
|
||||
payload["task_id"] = task_id
|
||||
|
||||
try:
|
||||
response = await client.post("/api/generate-agent", json=payload)
|
||||
|
||||
# Handle 202 Accepted for async processing
|
||||
if response.status_code == 202:
|
||||
logger.info(
|
||||
f"Agent Generator accepted async request "
|
||||
f"(operation_id={operation_id}, task_id={task_id})"
|
||||
)
|
||||
return {
|
||||
"status": "accepted",
|
||||
"operation_id": operation_id,
|
||||
"task_id": task_id,
|
||||
}
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -282,8 +261,6 @@ async def generate_agent_patch_external(
|
||||
update_request: str,
|
||||
current_agent: dict[str, Any],
|
||||
library_agents: list[dict[str, Any]] | None = None,
|
||||
operation_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Call the external service to generate a patch for an existing agent.
|
||||
|
||||
@@ -291,40 +268,21 @@ async def generate_agent_patch_external(
|
||||
update_request: Natural language description of changes
|
||||
current_agent: Current agent JSON
|
||||
library_agents: User's library agents available for sub-agent composition
|
||||
operation_id: Operation ID for async processing (enables Redis Streams callback)
|
||||
task_id: Task ID for async processing (enables Redis Streams callback)
|
||||
|
||||
Returns:
|
||||
Updated agent JSON, clarifying questions dict, {"status": "accepted"} for async, or error dict on error
|
||||
Updated agent JSON, clarifying questions dict, or error dict on error
|
||||
"""
|
||||
client = _get_client()
|
||||
|
||||
# Build request payload
|
||||
payload: dict[str, Any] = {
|
||||
"update_request": update_request,
|
||||
"current_agent_json": current_agent,
|
||||
}
|
||||
if library_agents:
|
||||
payload["library_agents"] = library_agents
|
||||
if operation_id and task_id:
|
||||
payload["operation_id"] = operation_id
|
||||
payload["task_id"] = task_id
|
||||
|
||||
try:
|
||||
response = await client.post("/api/update-agent", json=payload)
|
||||
|
||||
# Handle 202 Accepted for async processing
|
||||
if response.status_code == 202:
|
||||
logger.info(
|
||||
f"Agent Generator accepted async update request "
|
||||
f"(operation_id={operation_id}, task_id={task_id})"
|
||||
)
|
||||
return {
|
||||
"status": "accepted",
|
||||
"operation_id": operation_id,
|
||||
"task_id": task_id,
|
||||
}
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
@@ -368,77 +326,6 @@ async def generate_agent_patch_external(
|
||||
return _create_error_response(error_msg, "unexpected_error")
|
||||
|
||||
|
||||
async def customize_template_external(
|
||||
template_agent: dict[str, Any],
|
||||
modification_request: str,
|
||||
context: str = "",
|
||||
) -> dict[str, Any] | None:
|
||||
"""Call the external service to customize a template/marketplace agent.
|
||||
|
||||
Args:
|
||||
template_agent: The template agent JSON to customize
|
||||
modification_request: Natural language description of customizations
|
||||
context: Additional context (e.g., answers to previous questions)
|
||||
|
||||
Returns:
|
||||
Customized agent JSON, clarifying questions dict, or error dict on error
|
||||
"""
|
||||
client = _get_client()
|
||||
|
||||
request = modification_request
|
||||
if context:
|
||||
request = f"{modification_request}\n\nAdditional context from user:\n{context}"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"template_agent_json": template_agent,
|
||||
"modification_request": request,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await client.post("/api/template-modification", json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
error_msg = data.get("error", "Unknown error from Agent Generator")
|
||||
error_type = data.get("error_type", "unknown")
|
||||
logger.error(
|
||||
f"Agent Generator template customization failed: {error_msg} "
|
||||
f"(type: {error_type})"
|
||||
)
|
||||
return _create_error_response(error_msg, error_type)
|
||||
|
||||
# Check if it's clarifying questions
|
||||
if data.get("type") == "clarifying_questions":
|
||||
return {
|
||||
"type": "clarifying_questions",
|
||||
"questions": data.get("questions", []),
|
||||
}
|
||||
|
||||
# Check if it's an error passed through
|
||||
if data.get("type") == "error":
|
||||
return _create_error_response(
|
||||
data.get("error", "Unknown error"),
|
||||
data.get("error_type", "unknown"),
|
||||
)
|
||||
|
||||
# Otherwise return the customized agent JSON
|
||||
return data.get("agent_json")
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_type, error_msg = _classify_http_error(e)
|
||||
logger.error(error_msg)
|
||||
return _create_error_response(error_msg, error_type)
|
||||
except httpx.RequestError as e:
|
||||
error_type, error_msg = _classify_request_error(e)
|
||||
logger.error(error_msg)
|
||||
return _create_error_response(error_msg, error_type)
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error calling Agent Generator: {e}"
|
||||
logger.error(error_msg)
|
||||
return _create_error_response(error_msg, "unexpected_error")
|
||||
|
||||
|
||||
async def get_blocks_external() -> list[dict[str, Any]] | None:
|
||||
"""Get available blocks from the external service.
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ from .base import BaseTool
|
||||
from .models import (
|
||||
AgentPreviewResponse,
|
||||
AgentSavedResponse,
|
||||
AsyncProcessingResponse,
|
||||
ClarificationNeededResponse,
|
||||
ClarifyingQuestion,
|
||||
ErrorResponse,
|
||||
@@ -99,10 +98,6 @@ class CreateAgentTool(BaseTool):
|
||||
save = kwargs.get("save", True)
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
# Extract async processing params (passed by long-running tool handler)
|
||||
operation_id = kwargs.get("_operation_id")
|
||||
task_id = kwargs.get("_task_id")
|
||||
|
||||
if not description:
|
||||
return ErrorResponse(
|
||||
message="Please provide a description of what the agent should do.",
|
||||
@@ -224,12 +219,7 @@ class CreateAgentTool(BaseTool):
|
||||
logger.warning(f"Failed to enrich library agents from steps: {e}")
|
||||
|
||||
try:
|
||||
agent_json = await generate_agent(
|
||||
decomposition_result,
|
||||
library_agents,
|
||||
operation_id=operation_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
agent_json = await generate_agent(decomposition_result, library_agents)
|
||||
except AgentGeneratorNotConfiguredError:
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
@@ -273,19 +263,6 @@ class CreateAgentTool(BaseTool):
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Check if Agent Generator accepted for async processing
|
||||
if agent_json.get("status") == "accepted":
|
||||
logger.info(
|
||||
f"Agent generation delegated to async processing "
|
||||
f"(operation_id={operation_id}, task_id={task_id})"
|
||||
)
|
||||
return AsyncProcessingResponse(
|
||||
message="Agent generation started. You'll be notified when it's complete.",
|
||||
operation_id=operation_id,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
agent_name = agent_json.get("name", "Generated Agent")
|
||||
agent_description = agent_json.get("description", "")
|
||||
node_count = len(agent_json.get("nodes", []))
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
"""CustomizeAgentTool - Customizes marketplace/template agents using natural language."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from backend.api.features.chat.model import ChatSession
|
||||
from backend.api.features.store import db as store_db
|
||||
from backend.api.features.store.exceptions import AgentNotFoundError
|
||||
|
||||
from .agent_generator import (
|
||||
AgentGeneratorNotConfiguredError,
|
||||
customize_template,
|
||||
get_user_message_for_error,
|
||||
graph_to_json,
|
||||
save_agent_to_library,
|
||||
)
|
||||
from .base import BaseTool
|
||||
from .models import (
|
||||
AgentPreviewResponse,
|
||||
AgentSavedResponse,
|
||||
ClarificationNeededResponse,
|
||||
ClarifyingQuestion,
|
||||
ErrorResponse,
|
||||
ToolResponseBase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomizeAgentTool(BaseTool):
|
||||
"""Tool for customizing marketplace/template agents using natural language."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "customize_agent"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Customize a marketplace or template agent using natural language. "
|
||||
"Takes an existing agent from the marketplace and modifies it based on "
|
||||
"the user's requirements before adding to their library."
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_long_running(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The marketplace agent ID in format 'creator/slug' "
|
||||
"(e.g., 'autogpt/newsletter-writer'). "
|
||||
"Get this from find_agent results."
|
||||
),
|
||||
},
|
||||
"modifications": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Natural language description of how to customize the agent. "
|
||||
"Be specific about what changes you want to make."
|
||||
),
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Additional context or answers to previous clarifying questions."
|
||||
),
|
||||
},
|
||||
"save": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Whether to save the customized agent to the user's library. "
|
||||
"Default is true. Set to false for preview only."
|
||||
),
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
"required": ["agent_id", "modifications"],
|
||||
}
|
||||
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
session: ChatSession,
|
||||
**kwargs,
|
||||
) -> ToolResponseBase:
|
||||
"""Execute the customize_agent tool.
|
||||
|
||||
Flow:
|
||||
1. Parse the agent ID to get creator/slug
|
||||
2. Fetch the template agent from the marketplace
|
||||
3. Call customize_template with the modification request
|
||||
4. Preview or save based on the save parameter
|
||||
"""
|
||||
agent_id = kwargs.get("agent_id", "").strip()
|
||||
modifications = kwargs.get("modifications", "").strip()
|
||||
context = kwargs.get("context", "")
|
||||
save = kwargs.get("save", True)
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
if not agent_id:
|
||||
return ErrorResponse(
|
||||
message="Please provide the marketplace agent ID (e.g., 'creator/agent-name').",
|
||||
error="missing_agent_id",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if not modifications:
|
||||
return ErrorResponse(
|
||||
message="Please describe how you want to customize this agent.",
|
||||
error="missing_modifications",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Parse agent_id in format "creator/slug"
|
||||
parts = [p.strip() for p in agent_id.split("/")]
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
f"Invalid agent ID format: '{agent_id}'. "
|
||||
"Expected format is 'creator/agent-name' "
|
||||
"(e.g., 'autogpt/newsletter-writer')."
|
||||
),
|
||||
error="invalid_agent_id_format",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
creator_username, agent_slug = parts
|
||||
|
||||
# Fetch the marketplace agent details
|
||||
try:
|
||||
agent_details = await store_db.get_store_agent_details(
|
||||
username=creator_username, agent_name=agent_slug
|
||||
)
|
||||
except AgentNotFoundError:
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
f"Could not find marketplace agent '{agent_id}'. "
|
||||
"Please check the agent ID and try again."
|
||||
),
|
||||
error="agent_not_found",
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching marketplace agent {agent_id}: {e}")
|
||||
return ErrorResponse(
|
||||
message="Failed to fetch the marketplace agent. Please try again.",
|
||||
error="fetch_error",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if not agent_details.store_listing_version_id:
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
f"The agent '{agent_id}' does not have an available version. "
|
||||
"Please try a different agent."
|
||||
),
|
||||
error="no_version_available",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Get the full agent graph
|
||||
try:
|
||||
graph = await store_db.get_agent(agent_details.store_listing_version_id)
|
||||
template_agent = graph_to_json(graph)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching agent graph for {agent_id}: {e}")
|
||||
return ErrorResponse(
|
||||
message="Failed to fetch the agent configuration. Please try again.",
|
||||
error="graph_fetch_error",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Call customize_template
|
||||
try:
|
||||
result = await customize_template(
|
||||
template_agent=template_agent,
|
||||
modification_request=modifications,
|
||||
context=context,
|
||||
)
|
||||
except AgentGeneratorNotConfiguredError:
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
"Agent customization is not available. "
|
||||
"The Agent Generator service is not configured."
|
||||
),
|
||||
error="service_not_configured",
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling customize_template for {agent_id}: {e}")
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
"Failed to customize the agent due to a service error. "
|
||||
"Please try again."
|
||||
),
|
||||
error="customization_service_error",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return ErrorResponse(
|
||||
message=(
|
||||
"Failed to customize the agent. "
|
||||
"The agent generation service may be unavailable or timed out. "
|
||||
"Please try again."
|
||||
),
|
||||
error="customization_failed",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Handle error response
|
||||
if isinstance(result, dict) and result.get("type") == "error":
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
error_type = result.get("error_type", "unknown")
|
||||
user_message = get_user_message_for_error(
|
||||
error_type,
|
||||
operation="customize the agent",
|
||||
llm_parse_message=(
|
||||
"The AI had trouble customizing the agent. "
|
||||
"Please try again or simplify your request."
|
||||
),
|
||||
validation_message=(
|
||||
"The customized agent failed validation. "
|
||||
"Please try rephrasing your request."
|
||||
),
|
||||
error_details=error_msg,
|
||||
)
|
||||
return ErrorResponse(
|
||||
message=user_message,
|
||||
error=f"customization_failed:{error_type}",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Handle clarifying questions
|
||||
if isinstance(result, dict) and result.get("type") == "clarifying_questions":
|
||||
questions = result.get("questions") or []
|
||||
if not isinstance(questions, list):
|
||||
logger.error(
|
||||
f"Unexpected clarifying questions format: {type(questions)}"
|
||||
)
|
||||
questions = []
|
||||
return ClarificationNeededResponse(
|
||||
message=(
|
||||
"I need some more information to customize this agent. "
|
||||
"Please answer the following questions:"
|
||||
),
|
||||
questions=[
|
||||
ClarifyingQuestion(
|
||||
question=q.get("question", ""),
|
||||
keyword=q.get("keyword", ""),
|
||||
example=q.get("example"),
|
||||
)
|
||||
for q in questions
|
||||
if isinstance(q, dict)
|
||||
],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Result should be the customized agent JSON
|
||||
if not isinstance(result, dict):
|
||||
logger.error(f"Unexpected customize_template response type: {type(result)}")
|
||||
return ErrorResponse(
|
||||
message="Failed to customize the agent due to an unexpected response.",
|
||||
error="unexpected_response_type",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
customized_agent = result
|
||||
|
||||
agent_name = customized_agent.get(
|
||||
"name", f"Customized {agent_details.agent_name}"
|
||||
)
|
||||
agent_description = customized_agent.get("description", "")
|
||||
nodes = customized_agent.get("nodes")
|
||||
links = customized_agent.get("links")
|
||||
node_count = len(nodes) if isinstance(nodes, list) else 0
|
||||
link_count = len(links) if isinstance(links, list) else 0
|
||||
|
||||
if not save:
|
||||
return AgentPreviewResponse(
|
||||
message=(
|
||||
f"I've customized the agent '{agent_details.agent_name}'. "
|
||||
f"The customized agent has {node_count} blocks. "
|
||||
f"Review it and call customize_agent with save=true to save it."
|
||||
),
|
||||
agent_json=customized_agent,
|
||||
agent_name=agent_name,
|
||||
description=agent_description,
|
||||
node_count=node_count,
|
||||
link_count=link_count,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
return ErrorResponse(
|
||||
message="You must be logged in to save agents.",
|
||||
error="auth_required",
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Save to user's library
|
||||
try:
|
||||
created_graph, library_agent = await save_agent_to_library(
|
||||
customized_agent, user_id, is_update=False
|
||||
)
|
||||
|
||||
return AgentSavedResponse(
|
||||
message=(
|
||||
f"Customized agent '{created_graph.name}' "
|
||||
f"(based on '{agent_details.agent_name}') "
|
||||
f"has been saved to your library!"
|
||||
),
|
||||
agent_id=created_graph.id,
|
||||
agent_name=created_graph.name,
|
||||
library_agent_id=library_agent.id,
|
||||
library_agent_link=f"/library/agents/{library_agent.id}",
|
||||
agent_page_link=f"/build?flowID={created_graph.id}",
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving customized agent: {e}")
|
||||
return ErrorResponse(
|
||||
message="Failed to save the customized agent. Please try again.",
|
||||
error="save_failed",
|
||||
session_id=session_id,
|
||||
)
|
||||
@@ -17,7 +17,6 @@ from .base import BaseTool
|
||||
from .models import (
|
||||
AgentPreviewResponse,
|
||||
AgentSavedResponse,
|
||||
AsyncProcessingResponse,
|
||||
ClarificationNeededResponse,
|
||||
ClarifyingQuestion,
|
||||
ErrorResponse,
|
||||
@@ -105,10 +104,6 @@ class EditAgentTool(BaseTool):
|
||||
save = kwargs.get("save", True)
|
||||
session_id = session.session_id if session else None
|
||||
|
||||
# Extract async processing params (passed by long-running tool handler)
|
||||
operation_id = kwargs.get("_operation_id")
|
||||
task_id = kwargs.get("_task_id")
|
||||
|
||||
if not agent_id:
|
||||
return ErrorResponse(
|
||||
message="Please provide the agent ID to edit.",
|
||||
@@ -154,11 +149,7 @@ class EditAgentTool(BaseTool):
|
||||
|
||||
try:
|
||||
result = await generate_agent_patch(
|
||||
update_request,
|
||||
current_agent,
|
||||
library_agents,
|
||||
operation_id=operation_id,
|
||||
task_id=task_id,
|
||||
update_request, current_agent, library_agents
|
||||
)
|
||||
except AgentGeneratorNotConfiguredError:
|
||||
return ErrorResponse(
|
||||
@@ -178,20 +169,6 @@ class EditAgentTool(BaseTool):
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Check if Agent Generator accepted for async processing
|
||||
if result.get("status") == "accepted":
|
||||
logger.info(
|
||||
f"Agent edit delegated to async processing "
|
||||
f"(operation_id={operation_id}, task_id={task_id})"
|
||||
)
|
||||
return AsyncProcessingResponse(
|
||||
message="Agent edit started. You'll be notified when it's complete.",
|
||||
operation_id=operation_id,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Check if the result is an error from the external service
|
||||
if isinstance(result, dict) and result.get("type") == "error":
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
error_type = result.get("error_type", "unknown")
|
||||
|
||||
@@ -372,15 +372,11 @@ class OperationStartedResponse(ToolResponseBase):
|
||||
|
||||
This is returned immediately to the client while the operation continues
|
||||
to execute. The user can close the tab and check back later.
|
||||
|
||||
The task_id can be used to reconnect to the SSE stream via
|
||||
GET /chat/tasks/{task_id}/stream?last_idx=0
|
||||
"""
|
||||
|
||||
type: ResponseType = ResponseType.OPERATION_STARTED
|
||||
operation_id: str
|
||||
tool_name: str
|
||||
task_id: str | None = None # For SSE reconnection
|
||||
|
||||
|
||||
class OperationPendingResponse(ToolResponseBase):
|
||||
@@ -404,20 +400,3 @@ class OperationInProgressResponse(ToolResponseBase):
|
||||
|
||||
type: ResponseType = ResponseType.OPERATION_IN_PROGRESS
|
||||
tool_call_id: str
|
||||
|
||||
|
||||
class AsyncProcessingResponse(ToolResponseBase):
|
||||
"""Response when an operation has been delegated to async processing.
|
||||
|
||||
This is returned by tools when the external service accepts the request
|
||||
for async processing (HTTP 202 Accepted). The Redis Streams completion
|
||||
consumer will handle the result when the external service completes.
|
||||
|
||||
The status field is specifically "accepted" to allow the long-running tool
|
||||
handler to detect this response and skip LLM continuation.
|
||||
"""
|
||||
|
||||
type: ResponseType = ResponseType.OPERATION_STARTED
|
||||
status: str = "accepted" # Must be "accepted" for detection
|
||||
operation_id: str | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
@@ -454,9 +454,6 @@ async def test_unified_hybrid_search_pagination(
|
||||
cleanup_embeddings: list,
|
||||
):
|
||||
"""Test unified search pagination works correctly."""
|
||||
# Use a unique search term to avoid matching other test data
|
||||
unique_term = f"xyzpagtest{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create multiple items
|
||||
content_ids = []
|
||||
for i in range(5):
|
||||
@@ -468,14 +465,14 @@ async def test_unified_hybrid_search_pagination(
|
||||
content_type=ContentType.BLOCK,
|
||||
content_id=content_id,
|
||||
embedding=mock_embedding,
|
||||
searchable_text=f"{unique_term} item number {i}",
|
||||
searchable_text=f"pagination test item number {i}",
|
||||
metadata={"index": i},
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
# Get first page
|
||||
page1_results, total1 = await unified_hybrid_search(
|
||||
query=unique_term,
|
||||
query="pagination test",
|
||||
content_types=[ContentType.BLOCK],
|
||||
page=1,
|
||||
page_size=2,
|
||||
@@ -483,7 +480,7 @@ async def test_unified_hybrid_search_pagination(
|
||||
|
||||
# Get second page
|
||||
page2_results, total2 = await unified_hybrid_search(
|
||||
query=unique_term,
|
||||
query="pagination test",
|
||||
content_types=[ContentType.BLOCK],
|
||||
page=2,
|
||||
page_size=2,
|
||||
|
||||
@@ -40,10 +40,6 @@ import backend.data.user
|
||||
import backend.integrations.webhooks.utils
|
||||
import backend.util.service
|
||||
import backend.util.settings
|
||||
from backend.api.features.chat.completion_consumer import (
|
||||
start_completion_consumer,
|
||||
stop_completion_consumer,
|
||||
)
|
||||
from backend.blocks.llm import DEFAULT_LLM_MODEL
|
||||
from backend.data.model import Credentials
|
||||
from backend.integrations.providers import ProviderName
|
||||
@@ -122,21 +118,9 @@ async def lifespan_context(app: fastapi.FastAPI):
|
||||
await backend.data.graph.migrate_llm_models(DEFAULT_LLM_MODEL)
|
||||
await backend.integrations.webhooks.utils.migrate_legacy_triggered_graphs()
|
||||
|
||||
# Start chat completion consumer for Redis Streams notifications
|
||||
try:
|
||||
await start_completion_consumer()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not start chat completion consumer: {e}")
|
||||
|
||||
with launch_darkly_context():
|
||||
yield
|
||||
|
||||
# Stop chat completion consumer
|
||||
try:
|
||||
await stop_completion_consumer()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping chat completion consumer: {e}")
|
||||
|
||||
try:
|
||||
await shutdown_cloud_storage_handler()
|
||||
except Exception as e:
|
||||
|
||||
@@ -182,7 +182,10 @@ class StagehandObserveBlock(Block):
|
||||
**kwargs,
|
||||
) -> BlockOutput:
|
||||
|
||||
logger.debug(f"OBSERVE: Using model provider {model_credentials.provider}")
|
||||
logger.info(f"OBSERVE: Stagehand credentials: {stagehand_credentials}")
|
||||
logger.info(
|
||||
f"OBSERVE: Model credentials: {model_credentials} for provider {model_credentials.provider} secret: {model_credentials.api_key.get_secret_value()}"
|
||||
)
|
||||
|
||||
with disable_signal_handling():
|
||||
stagehand = Stagehand(
|
||||
@@ -279,7 +282,10 @@ class StagehandActBlock(Block):
|
||||
**kwargs,
|
||||
) -> BlockOutput:
|
||||
|
||||
logger.debug(f"ACT: Using model provider {model_credentials.provider}")
|
||||
logger.info(f"ACT: Stagehand credentials: {stagehand_credentials}")
|
||||
logger.info(
|
||||
f"ACT: Model credentials: {model_credentials} for provider {model_credentials.provider} secret: {model_credentials.api_key.get_secret_value()}"
|
||||
)
|
||||
|
||||
with disable_signal_handling():
|
||||
stagehand = Stagehand(
|
||||
@@ -364,7 +370,10 @@ class StagehandExtractBlock(Block):
|
||||
**kwargs,
|
||||
) -> BlockOutput:
|
||||
|
||||
logger.debug(f"EXTRACT: Using model provider {model_credentials.provider}")
|
||||
logger.info(f"EXTRACT: Stagehand credentials: {stagehand_credentials}")
|
||||
logger.info(
|
||||
f"EXTRACT: Model credentials: {model_credentials} for provider {model_credentials.provider} secret: {model_credentials.api_key.get_secret_value()}"
|
||||
)
|
||||
|
||||
with disable_signal_handling():
|
||||
stagehand = Stagehand(
|
||||
|
||||
@@ -873,13 +873,14 @@ def is_block_auth_configured(
|
||||
|
||||
|
||||
async def initialize_blocks() -> None:
|
||||
# First, sync all provider costs to blocks
|
||||
# Imported here to avoid circular import
|
||||
from backend.sdk.cost_integration import sync_all_provider_costs
|
||||
from backend.util.retry import func_retry
|
||||
|
||||
sync_all_provider_costs()
|
||||
|
||||
@func_retry
|
||||
async def sync_block_to_db(block: Block) -> None:
|
||||
for cls in get_blocks().values():
|
||||
block = cls()
|
||||
existing_block = await AgentBlock.prisma().find_first(
|
||||
where={"OR": [{"id": block.id}, {"name": block.name}]}
|
||||
)
|
||||
@@ -892,7 +893,7 @@ async def initialize_blocks() -> None:
|
||||
outputSchema=json.dumps(block.output_schema.jsonschema()),
|
||||
)
|
||||
)
|
||||
return
|
||||
continue
|
||||
|
||||
input_schema = json.dumps(block.input_schema.jsonschema())
|
||||
output_schema = json.dumps(block.output_schema.jsonschema())
|
||||
@@ -912,25 +913,6 @@ async def initialize_blocks() -> None:
|
||||
},
|
||||
)
|
||||
|
||||
failed_blocks: list[str] = []
|
||||
for cls in get_blocks().values():
|
||||
block = cls()
|
||||
try:
|
||||
await sync_block_to_db(block)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to sync block {block.name} to database: {e}. "
|
||||
"Block is still available in memory.",
|
||||
exc_info=True,
|
||||
)
|
||||
failed_blocks.append(block.name)
|
||||
|
||||
if failed_blocks:
|
||||
logger.error(
|
||||
f"Failed to sync {len(failed_blocks)} block(s) to database: "
|
||||
f"{', '.join(failed_blocks)}. These blocks are still available in memory."
|
||||
)
|
||||
|
||||
|
||||
# Note on the return type annotation: https://github.com/microsoft/pyright/issues/10281
|
||||
def get_block(block_id: str) -> AnyBlockSchema | None:
|
||||
|
||||
@@ -111,7 +111,9 @@ class TestGenerateAgent:
|
||||
instructions = {"type": "instructions", "steps": ["Step 1"]}
|
||||
result = await core.generate_agent(instructions)
|
||||
|
||||
mock_external.assert_called_once_with(instructions, None, None, None)
|
||||
# library_agents defaults to None
|
||||
mock_external.assert_called_once_with(instructions, None)
|
||||
# Result should have id, version, is_active added if not present
|
||||
assert result is not None
|
||||
assert result["name"] == "Test Agent"
|
||||
assert "id" in result
|
||||
@@ -175,9 +177,8 @@ class TestGenerateAgentPatch:
|
||||
current_agent = {"nodes": [], "links": []}
|
||||
result = await core.generate_agent_patch("Add a node", current_agent)
|
||||
|
||||
mock_external.assert_called_once_with(
|
||||
"Add a node", current_agent, None, None, None
|
||||
)
|
||||
# library_agents defaults to None
|
||||
mock_external.assert_called_once_with("Add a node", current_agent, None)
|
||||
assert result == expected_result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"defaults"
|
||||
],
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "3.0.61",
|
||||
"@faker-js/faker": "10.0.0",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@next/third-parties": "15.4.6",
|
||||
@@ -61,10 +60,6 @@
|
||||
"@rjsf/utils": "6.1.2",
|
||||
"@rjsf/validator-ajv8": "6.1.2",
|
||||
"@sentry/nextjs": "10.27.0",
|
||||
"@streamdown/cjk": "1.0.1",
|
||||
"@streamdown/code": "1.0.1",
|
||||
"@streamdown/math": "1.0.1",
|
||||
"@streamdown/mermaid": "1.0.1",
|
||||
"@supabase/ssr": "0.7.0",
|
||||
"@supabase/supabase-js": "2.78.0",
|
||||
"@tanstack/react-query": "5.90.6",
|
||||
@@ -73,7 +68,6 @@
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"@vercel/speed-insights": "1.2.0",
|
||||
"@xyflow/react": "12.9.2",
|
||||
"ai": "6.0.59",
|
||||
"boring-avatars": "1.11.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -118,11 +112,9 @@
|
||||
"remark-math": "6.0.0",
|
||||
"shepherd.js": "14.5.1",
|
||||
"sonner": "2.0.7",
|
||||
"streamdown": "2.1.0",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwind-scrollbar": "3.1.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"use-stick-to-bottom": "1.1.2",
|
||||
"uuid": "11.1.0",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "3.25.76",
|
||||
|
||||
1161
autogpt_platform/frontend/pnpm-lock.yaml
generated
1161
autogpt_platform/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
import { UIDataTypes, UITools, UIMessage } from "ai";
|
||||
import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer";
|
||||
import { EmptySession } from "../EmptySession/EmptySession";
|
||||
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||
import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider";
|
||||
import { LayoutGroup, motion } from "framer-motion";
|
||||
|
||||
export interface ChatContainerProps {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
status: string;
|
||||
error: Error | undefined;
|
||||
sessionId: string | null;
|
||||
isCreatingSession: boolean;
|
||||
onCreateSession: () => void | Promise<string>;
|
||||
onSend: (message: string) => void | Promise<void>;
|
||||
}
|
||||
export const ChatContainer = ({
|
||||
messages,
|
||||
status,
|
||||
error,
|
||||
sessionId,
|
||||
isCreatingSession,
|
||||
onCreateSession,
|
||||
onSend,
|
||||
}: ChatContainerProps) => {
|
||||
const inputLayoutId = "copilot-2-chat-input";
|
||||
|
||||
return (
|
||||
<CopilotChatActionsProvider onSend={onSend}>
|
||||
<LayoutGroup id="copilot-2-chat-layout">
|
||||
<div className="h-full w-full pb-6">
|
||||
<div className="flex h-full flex-col">
|
||||
{sessionId ? (
|
||||
<div className="mx-auto flex h-full w-full max-w-3xl flex-col">
|
||||
<ChatMessagesContainer
|
||||
messages={messages}
|
||||
status={status}
|
||||
error={error}
|
||||
/>
|
||||
<motion.div
|
||||
layoutId={inputLayoutId}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
|
||||
className="relative px-3 pt-2"
|
||||
>
|
||||
<div className="pointer-events-none absolute top-[-18px] z-10 h-6 w-full bg-gradient-to-b from-transparent to-[#f8f8f9] dark:to-background" />
|
||||
<ChatInput
|
||||
inputId="chat-input-session"
|
||||
onSend={onSend}
|
||||
disabled={status === "streaming"}
|
||||
isStreaming={status === "streaming"}
|
||||
onStop={() => {}}
|
||||
placeholder="You can search or just ask"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptySession
|
||||
inputLayoutId={inputLayoutId}
|
||||
isCreatingSession={isCreatingSession}
|
||||
onCreateSession={onCreateSession}
|
||||
onSend={onSend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
</CopilotChatActionsProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { MessageSquareIcon } from "lucide-react";
|
||||
import { UIMessage, UIDataTypes, UITools, ToolUIPart } from "ai";
|
||||
import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks";
|
||||
import { FindAgentsTool } from "../../tools/FindAgents/FindAgents";
|
||||
import { SearchDocsTool } from "../../tools/SearchDocs/SearchDocs";
|
||||
import { RunBlockTool } from "../../tools/RunBlock/RunBlock";
|
||||
import { RunAgentTool } from "../../tools/RunAgent/RunAgent";
|
||||
import { ViewAgentOutputTool } from "../../tools/ViewAgentOutput/ViewAgentOutput";
|
||||
import { CreateAgentTool } from "../../tools/CreateAgent/CreateAgent";
|
||||
import { EditAgentTool } from "../../tools/EditAgent/EditAgent";
|
||||
|
||||
interface ChatMessagesContainerProps {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
status: string;
|
||||
error: Error | undefined;
|
||||
}
|
||||
|
||||
export const ChatMessagesContainer = ({
|
||||
messages,
|
||||
status,
|
||||
error,
|
||||
}: ChatMessagesContainerProps) => {
|
||||
return (
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent>
|
||||
{messages.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<MessageSquareIcon className="size-12" />}
|
||||
title="Start a conversation"
|
||||
description="Type a message below to begin chatting"
|
||||
/>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent
|
||||
className={
|
||||
"rounded-xl border px-3 py-2 " +
|
||||
"group-[.is-user]:rounded-2xl group-[.is-user]:border-purple-200 group-[.is-user]:bg-purple-100 group-[.is-user]:text-slate-900 " +
|
||||
"group-[.is-assistant]:border-none group-[.is-assistant]:bg-slate-50/20 group-[.is-assistant]:text-slate-900"
|
||||
}
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<MessageResponse key={`${message.id}-${i}`}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
);
|
||||
case "tool-find_block":
|
||||
return (
|
||||
<FindBlocksTool
|
||||
key={`${message.id}-${i}`}
|
||||
part={part as ToolUIPart}
|
||||
/>
|
||||
);
|
||||
case "tool-find_agent":
|
||||
case "tool-find_library_agent":
|
||||
return (
|
||||
<FindAgentsTool
|
||||
key={`${message.id}-${i}`}
|
||||
part={part as ToolUIPart}
|
||||
/>
|
||||
);
|
||||
case "tool-search_docs":
|
||||
case "tool-get_doc_page":
|
||||
return (
|
||||
<SearchDocsTool
|
||||
key={`${message.id}-${i}`}
|
||||
part={part as ToolUIPart}
|
||||
/>
|
||||
);
|
||||
case "tool-run_block":
|
||||
return (
|
||||
<RunBlockTool
|
||||
key={`${message.id}-${i}`}
|
||||
part={part as ToolUIPart}
|
||||
/>
|
||||
);
|
||||
case "tool-run_agent":
|
||||
case "tool-schedule_agent":
|
||||
return (
|
||||
<RunAgentTool
|
||||
key={`${message.id}-${i}`}
|
||||
part={part as ToolUIPart}
|
||||
/>
|
||||
);
|
||||
case "tool-create_agent":
|
||||
return (
|
||||
<CreateAgentTool
|
||||
key={`${message.id}-${i}`}
|
||||
part={part as ToolUIPart}
|
||||
/>
|
||||
);
|
||||
case "tool-edit_agent":
|
||||
return (
|
||||
<EditAgentTool
|
||||
key={`${message.id}-${i}`}
|
||||
part={part as ToolUIPart}
|
||||
/>
|
||||
);
|
||||
case "tool-view_agent_output":
|
||||
return (
|
||||
<ViewAgentOutputTool
|
||||
key={`${message.id}-${i}`}
|
||||
part={part as ToolUIPart}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))
|
||||
)}
|
||||
{status === "submitted" && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<p className="text-zinc-500">Thinking...</p>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 p-3 text-red-600">
|
||||
Error: {error.message}
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
};
|
||||
@@ -1,174 +0,0 @@
|
||||
"use client";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PlusIcon,
|
||||
SpinnerGapIcon,
|
||||
ChatCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import {
|
||||
postV2CreateSession,
|
||||
useGetV2ListSessions,
|
||||
getGetV2ListSessionsQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function ChatSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: sessionsResponse, isLoading: isLoadingSessions } =
|
||||
useGetV2ListSessions({ limit: 50 });
|
||||
|
||||
const sessions =
|
||||
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
|
||||
|
||||
async function handleNewChat() {
|
||||
if (isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const response = await postV2CreateSession({
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (response.status === 200 && response.data?.id) {
|
||||
setSessionId(response.data.id);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectSession(id: string) {
|
||||
setSessionId(id);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return "Today";
|
||||
if (diffDays === 1) return "Yesterday";
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
variant="inset"
|
||||
collapsible="icon"
|
||||
className="!top-[60px] !h-[calc(100vh-60px)]"
|
||||
>
|
||||
{isCollapsed && (
|
||||
<SidebarHeader
|
||||
className={cn(
|
||||
"flex",
|
||||
isCollapsed
|
||||
? "flex-row items-center justify-between gap-y-4 md:flex-col md:items-start md:justify-start"
|
||||
: "flex-row items-center justify-between",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
key={isCollapsed ? "header-collapsed" : "header-expanded"}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
isCollapsed ? "flex-row md:flex-col-reverse" : "flex-row",
|
||||
)}
|
||||
initial={{ opacity: 0, filter: "blur(3px)" }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
transition={{ type: "spring", bounce: 0.2 }}
|
||||
>
|
||||
{isCollapsed && (
|
||||
<div className="h-fit rounded-3xl border border-neutral-400 bg-secondary p-1">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent className="gap-4 overflow-y-auto px-2 py-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleNewChat}
|
||||
disabled={isCreating}
|
||||
className={cn(
|
||||
"flex h-fit w-full items-center justify-center gap-2 rounded-3xl border-purple-400 bg-purple-100 px-3 py-2 text-purple-600 hover:border-purple-500 hover:bg-purple-200 hover:text-purple-700",
|
||||
isCollapsed && "justify-center rounded-3xl px-1",
|
||||
)}
|
||||
>
|
||||
{isCreating ? (
|
||||
<SpinnerGapIcon className="h-4 w-4 animate-spin" weight="bold" />
|
||||
) : (
|
||||
<PlusIcon className="h-4 w-4" weight="bold" />
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<span>{isCreating ? "Creating..." : "New Chat"}</span>
|
||||
)}
|
||||
</Button>
|
||||
{!isCollapsed && (
|
||||
<div className="h-fit rounded-3xl border border-neutral-400 bg-secondary p-1">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
{isLoadingSessions ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<SpinnerGapIcon className="h-5 w-5 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-neutral-500">
|
||||
No conversations yet
|
||||
</p>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-800",
|
||||
sessionId === session.id &&
|
||||
"bg-neutral-100 dark:bg-neutral-800",
|
||||
)}
|
||||
>
|
||||
<ChatCircleIcon className="h-4 w-4 shrink-0 text-neutral-500" />
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="truncate font-medium">
|
||||
{session.title || `Untitled chat`}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{formatDate(session.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="px-2"></SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CopilotChatActionsContext } from "./useCopilotChatActions";
|
||||
|
||||
interface Props {
|
||||
onSend: (message: string) => void | Promise<void>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CopilotChatActionsProvider({ onSend, children }: Props) {
|
||||
return (
|
||||
<CopilotChatActionsContext.Provider value={{ onSend }}>
|
||||
{children}
|
||||
</CopilotChatActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
interface CopilotChatActions {
|
||||
onSend: (message: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const CopilotChatActionsContext = createContext<CopilotChatActions | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function useCopilotChatActions(): CopilotChatActions {
|
||||
const ctx = useContext(CopilotChatActionsContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useCopilotChatActions must be used within CopilotChatActionsProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { CopilotChatActionsContext };
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getGreetingName,
|
||||
getQuickActions,
|
||||
} from "@/app/(platform)/copilot/helpers";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { SparkleIcon, SpinnerGapIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
inputLayoutId: string;
|
||||
isCreatingSession: boolean;
|
||||
onCreateSession: () => void | Promise<string>;
|
||||
onSend: (message: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function EmptySession({
|
||||
inputLayoutId,
|
||||
isCreatingSession,
|
||||
onSend,
|
||||
}: Props) {
|
||||
const { user } = useSupabase();
|
||||
const greetingName = getGreetingName(user);
|
||||
const quickActions = getQuickActions();
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||
|
||||
async function handleQuickActionClick(action: string) {
|
||||
if (isCreatingSession || loadingAction) return;
|
||||
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
await onSend(action);
|
||||
} finally {
|
||||
setLoadingAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-1 flex-col items-center justify-center overflow-hidden px-6 py-10 dark:bg-background">
|
||||
<motion.div
|
||||
className="relative w-full max-w-3xl"
|
||||
initial={{ opacity: 0, y: 14, filter: "blur(6px)" }}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.7 }}
|
||||
>
|
||||
<div className="mx-auto flex flex-col items-center text-center">
|
||||
<div className="mb-5 flex items-center gap-2 rounded-full border border-border/60 bg-white/70 px-3 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur dark:bg-neutral-950/40">
|
||||
<SparkleIcon className="h-3.5 w-3.5 text-purple-600" />
|
||||
<span>Autopilot runs for you 24/7</span>
|
||||
</div>
|
||||
|
||||
<Text variant="h3" className="mb-3 !text-[1.375rem] text-zinc-700">
|
||||
Hey, <span className="text-violet-600">{greetingName}</span>
|
||||
</Text>
|
||||
<Text variant="h3" className="!font-normal">
|
||||
What do you want to automate?
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto p-5 dark:bg-neutral-950/40">
|
||||
<motion.div
|
||||
layoutId={inputLayoutId}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
|
||||
className="w-full"
|
||||
>
|
||||
<ChatInput
|
||||
inputId="chat-input-empty"
|
||||
onSend={onSend}
|
||||
disabled={isCreatingSession}
|
||||
placeholder='You can search or just ask - e.g. "create a blog post outline"'
|
||||
className="w-full"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8 flex w-full flex-nowrap items-center justify-center gap-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{quickActions.map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => void handleQuickActionClick(action)}
|
||||
disabled={isCreatingSession || loadingAction !== null}
|
||||
aria-busy={loadingAction === action}
|
||||
leftIcon={
|
||||
loadingAction === action ? (
|
||||
<SpinnerGapIcon
|
||||
className="h-4 w-4 animate-spin"
|
||||
weight="bold"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
className="h-auto shrink-0 border-zinc-600 !px-4 !py-2 text-[1rem] text-zinc-600"
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function MorphingTextAnimation({ text }: Props) {
|
||||
const letters = text.split("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div key={text} className="whitespace-nowrap">
|
||||
<motion.span className="inline-flex overflow-hidden">
|
||||
{letters.map((char, index) => (
|
||||
<motion.span
|
||||
key={`${text}-${index}`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
rotateX: "80deg",
|
||||
filter: "blur(6px)",
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotateX: "0deg",
|
||||
filter: "blur(0px)",
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -8,
|
||||
rotateX: "-80deg",
|
||||
filter: "blur(6px)",
|
||||
}}
|
||||
style={{ willChange: "transform" }}
|
||||
transition={{
|
||||
delay: 0.015 * index,
|
||||
type: "spring",
|
||||
bounce: 0.5,
|
||||
}}
|
||||
className="inline-block"
|
||||
>
|
||||
{char === " " ? "\u00A0" : char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useToolAccordion } from "./useToolAccordion";
|
||||
|
||||
interface Props {
|
||||
badgeText: string;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
defaultExpanded?: boolean;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export function ToolAccordion({
|
||||
badgeText,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
defaultExpanded,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
}: Props) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const contentId = useId();
|
||||
const { isExpanded, toggle } = useToolAccordion({
|
||||
expanded,
|
||||
defaultExpanded,
|
||||
onExpandedChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 w-full rounded-2xl border bg-background px-3 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
onClick={toggle}
|
||||
className="flex w-full items-center justify-between gap-3 py-1 text-left"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="rounded-full border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{badgeText}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CaretDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
weight="bold"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
id={contentId}
|
||||
initial={{ height: 0, opacity: 0, filter: "blur(10px)" }}
|
||||
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ height: 0, opacity: 0, filter: "blur(10px)" }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { type: "spring", bounce: 0.35, duration: 0.55 }
|
||||
}
|
||||
className="overflow-hidden"
|
||||
style={{ willChange: "height, opacity, filter" }}
|
||||
>
|
||||
<div className="pb-2 pt-3">{children}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface UseToolAccordionOptions {
|
||||
expanded?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
interface UseToolAccordionResult {
|
||||
isExpanded: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export function useToolAccordion({
|
||||
expanded,
|
||||
defaultExpanded = false,
|
||||
onExpandedChange,
|
||||
}: UseToolAccordionOptions): UseToolAccordionResult {
|
||||
const [uncontrolledExpanded, setUncontrolledExpanded] =
|
||||
useState(defaultExpanded);
|
||||
|
||||
const isControlled = typeof expanded === "boolean";
|
||||
const isExpanded = isControlled ? expanded : uncontrolledExpanded;
|
||||
|
||||
function toggle() {
|
||||
const next = !isExpanded;
|
||||
if (!isControlled) setUncontrolledExpanded(next);
|
||||
onExpandedChange?.(next);
|
||||
}
|
||||
|
||||
return { isExpanded, toggle };
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { UIMessage, UIDataTypes, UITools } from "ai";
|
||||
|
||||
interface SessionChatMessage {
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_call_id: string | null;
|
||||
tool_calls: unknown[] | null;
|
||||
}
|
||||
|
||||
function coerceSessionChatMessages(
|
||||
rawMessages: unknown[],
|
||||
): SessionChatMessage[] {
|
||||
return rawMessages
|
||||
.map((m) => {
|
||||
if (!m || typeof m !== "object") return null;
|
||||
const msg = m as Record<string, unknown>;
|
||||
|
||||
const role = typeof msg.role === "string" ? msg.role : null;
|
||||
if (!role) return null;
|
||||
|
||||
return {
|
||||
role,
|
||||
content:
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: msg.content == null
|
||||
? null
|
||||
: String(msg.content),
|
||||
tool_call_id:
|
||||
typeof msg.tool_call_id === "string"
|
||||
? msg.tool_call_id
|
||||
: msg.tool_call_id == null
|
||||
? null
|
||||
: String(msg.tool_call_id),
|
||||
tool_calls: Array.isArray(msg.tool_calls) ? msg.tool_calls : null,
|
||||
};
|
||||
})
|
||||
.filter((m): m is SessionChatMessage => m !== null);
|
||||
}
|
||||
|
||||
function safeJsonParse(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toToolInput(rawArguments: unknown): unknown {
|
||||
if (typeof rawArguments === "string") {
|
||||
const trimmed = rawArguments.trim();
|
||||
return trimmed ? safeJsonParse(trimmed) : {};
|
||||
}
|
||||
if (rawArguments && typeof rawArguments === "object") return rawArguments;
|
||||
return {};
|
||||
}
|
||||
|
||||
export function convertChatSessionMessagesToUiMessages(
|
||||
sessionId: string,
|
||||
rawMessages: unknown[],
|
||||
): UIMessage<unknown, UIDataTypes, UITools>[] {
|
||||
const messages = coerceSessionChatMessages(rawMessages);
|
||||
const toolOutputsByCallId = new Map<string, unknown>();
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "tool") continue;
|
||||
if (!msg.tool_call_id) continue;
|
||||
if (msg.content == null) continue;
|
||||
toolOutputsByCallId.set(msg.tool_call_id, msg.content);
|
||||
}
|
||||
|
||||
const uiMessages: UIMessage<unknown, UIDataTypes, UITools>[] = [];
|
||||
|
||||
messages.forEach((msg, index) => {
|
||||
if (msg.role === "tool") return;
|
||||
if (msg.role !== "user" && msg.role !== "assistant") return;
|
||||
|
||||
const parts: UIMessage<unknown, UIDataTypes, UITools>["parts"] = [];
|
||||
|
||||
if (typeof msg.content === "string" && msg.content.trim()) {
|
||||
parts.push({ type: "text", text: msg.content, state: "done" });
|
||||
}
|
||||
|
||||
if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
|
||||
for (const rawToolCall of msg.tool_calls) {
|
||||
if (!rawToolCall || typeof rawToolCall !== "object") continue;
|
||||
const toolCall = rawToolCall as {
|
||||
id?: unknown;
|
||||
function?: { name?: unknown; arguments?: unknown };
|
||||
};
|
||||
|
||||
const toolCallId = String(toolCall.id ?? "").trim();
|
||||
const toolName = String(toolCall.function?.name ?? "").trim();
|
||||
if (!toolCallId || !toolName) continue;
|
||||
|
||||
const input = toToolInput(toolCall.function?.arguments);
|
||||
const output = toolOutputsByCallId.get(toolCallId);
|
||||
|
||||
if (output !== undefined) {
|
||||
parts.push({
|
||||
type: `tool-${toolName}`,
|
||||
toolCallId,
|
||||
state: "output-available",
|
||||
input,
|
||||
output: typeof output === "string" ? safeJsonParse(output) : output,
|
||||
});
|
||||
} else {
|
||||
parts.push({
|
||||
type: `tool-${toolName}`,
|
||||
toolCallId,
|
||||
state: "input-available",
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) return;
|
||||
|
||||
uiMessages.push({
|
||||
id: `${sessionId}-${index}`,
|
||||
role: msg.role,
|
||||
parts,
|
||||
});
|
||||
});
|
||||
|
||||
return uiMessages;
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
|
||||
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
getGetV2ListSessionsQueryKey,
|
||||
getV2GetSession,
|
||||
postV2CreateSession,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { convertChatSessionMessagesToUiMessages } from "./helpers/convertChatSessionToUiMessages";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export default function Page() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
||||
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
|
||||
const hydrationSeq = useRef(0);
|
||||
const lastHydratedSessionIdRef = useRef<string | null>(null);
|
||||
const createSessionPromiseRef = useRef<Promise<string> | null>(null);
|
||||
const queuedFirstMessageRef = useRef<string | null>(null);
|
||||
const queuedFirstMessageResolverRef = useRef<(() => void) | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function handleCopySessionId() {
|
||||
if (!sessionId) return;
|
||||
navigator.clipboard.writeText(sessionId);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const transport = useMemo(() => {
|
||||
if (!sessionId) return null;
|
||||
return new DefaultChatTransport({
|
||||
api: `/api/chat/sessions/${sessionId}/stream`,
|
||||
prepareSendMessagesRequest: ({ messages }) => {
|
||||
const last = messages[messages.length - 1];
|
||||
return {
|
||||
body: {
|
||||
message: last.parts
|
||||
?.map((p) => (p.type === "text" ? p.text : ""))
|
||||
.join(""),
|
||||
is_user_message: last.role === "user",
|
||||
context: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
const { messages, sendMessage, status, error, setMessages } = useChat({
|
||||
id: sessionId ?? undefined,
|
||||
transport: transport ?? undefined,
|
||||
});
|
||||
|
||||
const messagesRef = useRef(messages);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
async function createSession(): Promise<string> {
|
||||
if (sessionId) return sessionId;
|
||||
if (createSessionPromiseRef.current) return createSessionPromiseRef.current;
|
||||
|
||||
setIsCreatingSession(true);
|
||||
const promise = (async () => {
|
||||
const response = await postV2CreateSession({
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (response.status !== 200 || !response.data?.id) {
|
||||
throw new Error("Failed to create chat session");
|
||||
}
|
||||
setSessionId(response.data.id);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
});
|
||||
return response.data.id;
|
||||
})();
|
||||
|
||||
createSessionPromiseRef.current = promise;
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
createSessionPromiseRef.current = null;
|
||||
setIsCreatingSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
hydrationSeq.current += 1;
|
||||
const seq = hydrationSeq.current;
|
||||
const controller = new AbortController();
|
||||
|
||||
if (!sessionId) {
|
||||
setMessages([]);
|
||||
lastHydratedSessionIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSessionId = sessionId;
|
||||
|
||||
if (lastHydratedSessionIdRef.current !== currentSessionId) {
|
||||
setMessages([]);
|
||||
lastHydratedSessionIdRef.current = currentSessionId;
|
||||
}
|
||||
|
||||
async function hydrate() {
|
||||
try {
|
||||
const response = await getV2GetSession(currentSessionId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status !== 200) return;
|
||||
|
||||
const uiMessages = convertChatSessionMessagesToUiMessages(
|
||||
currentSessionId,
|
||||
response.data.messages ?? [],
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
if (hydrationSeq.current !== seq) return;
|
||||
|
||||
const localMessagesCount = messagesRef.current.length;
|
||||
const remoteMessagesCount = uiMessages.length;
|
||||
|
||||
if (remoteMessagesCount === 0) return;
|
||||
if (localMessagesCount > remoteMessagesCount) return;
|
||||
|
||||
setMessages(uiMessages);
|
||||
} catch (error) {
|
||||
if ((error as { name?: string } | null)?.name === "AbortError") return;
|
||||
console.warn("Failed to hydrate chat session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
void hydrate();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [sessionId, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const firstMessage = queuedFirstMessageRef.current;
|
||||
if (!firstMessage) return;
|
||||
|
||||
queuedFirstMessageRef.current = null;
|
||||
sendMessage({ text: firstMessage });
|
||||
queuedFirstMessageResolverRef.current?.();
|
||||
queuedFirstMessageResolverRef.current = null;
|
||||
}, [sendMessage, sessionId]);
|
||||
|
||||
async function onSend(message: string) {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (sessionId) {
|
||||
sendMessage({ text: trimmed });
|
||||
return;
|
||||
}
|
||||
|
||||
queuedFirstMessageRef.current = trimmed;
|
||||
const sentPromise = new Promise<void>((resolve) => {
|
||||
queuedFirstMessageResolverRef.current = resolve;
|
||||
});
|
||||
|
||||
await createSession();
|
||||
await sentPromise;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
defaultOpen={false}
|
||||
className="h-[calc(100vh-72px)] min-h-0"
|
||||
>
|
||||
<ChatSidebar />
|
||||
<SidebarInset className="relative flex h-[calc(100vh-80px)] flex-col overflow-hidden ring-1 ring-zinc-300">
|
||||
{sessionId && (
|
||||
<div className="absolute flex items-center px-4 py-4">
|
||||
<div className="flex items-center gap-2 rounded-3xl border border-neutral-400 bg-neutral-100 px-3 py-1.5 text-sm text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400">
|
||||
<span className="text-xs">{sessionId.slice(0, 8)}...</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleCopySessionId}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatContainer
|
||||
messages={messages}
|
||||
status={status}
|
||||
error={error}
|
||||
sessionId={sessionId}
|
||||
isCreatingSession={isCreatingSession}
|
||||
onCreateSession={createSession}
|
||||
onSend={onSend}
|
||||
/>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import Link from "next/link";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import {
|
||||
ClarificationQuestionsWidget,
|
||||
type ClarifyingQuestion as WidgetClarifyingQuestion,
|
||||
} from "@/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget";
|
||||
import {
|
||||
formatMaybeJson,
|
||||
getAnimationText,
|
||||
getCreateAgentToolOutput,
|
||||
isAgentPreviewOutput,
|
||||
isAgentSavedOutput,
|
||||
isClarificationNeededOutput,
|
||||
isErrorOutput,
|
||||
isOperationInProgressOutput,
|
||||
isOperationPendingOutput,
|
||||
isOperationStartedOutput,
|
||||
StateIcon,
|
||||
truncateText,
|
||||
type CreateAgentToolOutput,
|
||||
} from "./helpers";
|
||||
|
||||
export interface CreateAgentToolPart {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
part: CreateAgentToolPart;
|
||||
}
|
||||
|
||||
function getAccordionMeta(output: CreateAgentToolOutput): {
|
||||
badgeText: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
} {
|
||||
if (isAgentSavedOutput(output)) {
|
||||
return { badgeText: "Create agent", title: output.agent_name };
|
||||
}
|
||||
if (isAgentPreviewOutput(output)) {
|
||||
return {
|
||||
badgeText: "Create agent",
|
||||
title: output.agent_name,
|
||||
description: `${output.node_count} block${output.node_count === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
if (isClarificationNeededOutput(output)) {
|
||||
const questions = output.questions ?? [];
|
||||
return {
|
||||
badgeText: "Create agent",
|
||||
title: "Needs clarification",
|
||||
description: `${questions.length} question${questions.length === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
isOperationStartedOutput(output) ||
|
||||
isOperationPendingOutput(output) ||
|
||||
isOperationInProgressOutput(output)
|
||||
) {
|
||||
return { badgeText: "Create agent", title: "Creating agent" };
|
||||
}
|
||||
return { badgeText: "Create agent", title: "Error" };
|
||||
}
|
||||
|
||||
export function CreateAgentTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
const { onSend } = useCopilotChatActions();
|
||||
|
||||
const output = getCreateAgentToolOutput(part);
|
||||
const hasExpandableContent =
|
||||
part.state === "output-available" &&
|
||||
!!output &&
|
||||
(isOperationStartedOutput(output) ||
|
||||
isOperationPendingOutput(output) ||
|
||||
isOperationInProgressOutput(output) ||
|
||||
isAgentPreviewOutput(output) ||
|
||||
isAgentSavedOutput(output) ||
|
||||
isClarificationNeededOutput(output) ||
|
||||
isErrorOutput(output));
|
||||
|
||||
function handleClarificationAnswers(answers: Record<string, string>) {
|
||||
const contextMessage = Object.entries(answers)
|
||||
.map(([keyword, answer]) => `${keyword}: ${answer}`)
|
||||
.join("\n");
|
||||
|
||||
onSend(
|
||||
`I have the answers to your questions:\n\n${contextMessage}\n\nPlease proceed with creating the agent.`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StateIcon state={part.state} />
|
||||
<MorphingTextAnimation text={text} />
|
||||
</div>
|
||||
|
||||
{hasExpandableContent && output && (
|
||||
<ToolAccordion
|
||||
{...getAccordionMeta(output)}
|
||||
defaultExpanded={isClarificationNeededOutput(output)}
|
||||
>
|
||||
{(isOperationStartedOutput(output) ||
|
||||
isOperationPendingOutput(output)) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Operation: {output.operation_id}
|
||||
</p>
|
||||
<p className="text-xs italic text-muted-foreground">
|
||||
Check your library in a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOperationInProgressOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
<p className="text-xs italic text-muted-foreground">
|
||||
Please wait for the current operation to finish.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAgentSavedOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={output.library_agent_link}
|
||||
className="text-xs font-medium text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Open in library
|
||||
</Link>
|
||||
<Link
|
||||
href={output.agent_page_link}
|
||||
className="text-xs font-medium text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Open in builder
|
||||
</Link>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{truncateText(
|
||||
formatMaybeJson({ agent_id: output.agent_id }),
|
||||
800,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAgentPreviewOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
{output.description?.trim() && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{output.description}
|
||||
</p>
|
||||
)}
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{truncateText(formatMaybeJson(output.agent_json), 1600)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isClarificationNeededOutput(output) && (
|
||||
<ClarificationQuestionsWidget
|
||||
questions={(output.questions ?? []).map((q) => {
|
||||
const item: WidgetClarifyingQuestion = {
|
||||
question: q.question,
|
||||
keyword: q.keyword,
|
||||
};
|
||||
const example =
|
||||
typeof q.example === "string" && q.example.trim()
|
||||
? q.example.trim()
|
||||
: null;
|
||||
if (example) item.example = example;
|
||||
return item;
|
||||
})}
|
||||
message={output.message}
|
||||
onSubmitAnswers={handleClarificationAnswers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isErrorOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
{output.error && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.error)}
|
||||
</pre>
|
||||
)}
|
||||
{output.details && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.details)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentPreviewResponse } from "@/app/api/__generated__/models/agentPreviewResponse";
|
||||
import type { AgentSavedResponse } from "@/app/api/__generated__/models/agentSavedResponse";
|
||||
import type { ClarificationNeededResponse } from "@/app/api/__generated__/models/clarificationNeededResponse";
|
||||
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
|
||||
import type { OperationInProgressResponse } from "@/app/api/__generated__/models/operationInProgressResponse";
|
||||
import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse";
|
||||
import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse";
|
||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
|
||||
export type CreateAgentToolOutput =
|
||||
| OperationStartedResponse
|
||||
| OperationPendingResponse
|
||||
| OperationInProgressResponse
|
||||
| AgentPreviewResponse
|
||||
| AgentSavedResponse
|
||||
| ClarificationNeededResponse
|
||||
| ErrorResponse;
|
||||
|
||||
function parseOutput(output: unknown): CreateAgentToolOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object") {
|
||||
const type = (output as { type?: unknown }).type;
|
||||
if (
|
||||
type === ResponseType.operation_started ||
|
||||
type === ResponseType.operation_pending ||
|
||||
type === ResponseType.operation_in_progress ||
|
||||
type === ResponseType.agent_preview ||
|
||||
type === ResponseType.agent_saved ||
|
||||
type === ResponseType.clarification_needed ||
|
||||
type === ResponseType.error
|
||||
) {
|
||||
return output as CreateAgentToolOutput;
|
||||
}
|
||||
if ("operation_id" in output && "tool_name" in output)
|
||||
return output as OperationStartedResponse | OperationPendingResponse;
|
||||
if ("tool_call_id" in output) return output as OperationInProgressResponse;
|
||||
if ("agent_json" in output && "agent_name" in output)
|
||||
return output as AgentPreviewResponse;
|
||||
if ("agent_id" in output && "library_agent_id" in output)
|
||||
return output as AgentSavedResponse;
|
||||
if ("questions" in output) return output as ClarificationNeededResponse;
|
||||
if ("error" in output || "details" in output)
|
||||
return output as ErrorResponse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCreateAgentToolOutput(
|
||||
part: unknown,
|
||||
): CreateAgentToolOutput | null {
|
||||
if (!part || typeof part !== "object") return null;
|
||||
return parseOutput((part as { output?: unknown }).output);
|
||||
}
|
||||
|
||||
export function isOperationStartedOutput(
|
||||
output: CreateAgentToolOutput,
|
||||
): output is OperationStartedResponse {
|
||||
return (
|
||||
output.type === ResponseType.operation_started ||
|
||||
("operation_id" in output && "tool_name" in output)
|
||||
);
|
||||
}
|
||||
|
||||
export function isOperationPendingOutput(
|
||||
output: CreateAgentToolOutput,
|
||||
): output is OperationPendingResponse {
|
||||
return output.type === ResponseType.operation_pending;
|
||||
}
|
||||
|
||||
export function isOperationInProgressOutput(
|
||||
output: CreateAgentToolOutput,
|
||||
): output is OperationInProgressResponse {
|
||||
return (
|
||||
output.type === ResponseType.operation_in_progress ||
|
||||
"tool_call_id" in output
|
||||
);
|
||||
}
|
||||
|
||||
export function isAgentPreviewOutput(
|
||||
output: CreateAgentToolOutput,
|
||||
): output is AgentPreviewResponse {
|
||||
return output.type === ResponseType.agent_preview || "agent_json" in output;
|
||||
}
|
||||
|
||||
export function isAgentSavedOutput(
|
||||
output: CreateAgentToolOutput,
|
||||
): output is AgentSavedResponse {
|
||||
return (
|
||||
output.type === ResponseType.agent_saved || "agent_page_link" in output
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationNeededOutput(
|
||||
output: CreateAgentToolOutput,
|
||||
): output is ClarificationNeededResponse {
|
||||
return (
|
||||
output.type === ResponseType.clarification_needed || "questions" in output
|
||||
);
|
||||
}
|
||||
|
||||
export function isErrorOutput(
|
||||
output: CreateAgentToolOutput,
|
||||
): output is ErrorResponse {
|
||||
return output.type === ResponseType.error || "error" in output;
|
||||
}
|
||||
|
||||
export function getAnimationText(part: {
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}): string {
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
return "Creating agent";
|
||||
case "input-available":
|
||||
return "Generating agent workflow";
|
||||
case "output-available": {
|
||||
const output = parseOutput(part.output);
|
||||
if (!output) return "Agent created";
|
||||
if (isOperationStartedOutput(output)) return "Agent creation started";
|
||||
if (isOperationPendingOutput(output)) return "Agent creation in progress";
|
||||
if (isOperationInProgressOutput(output))
|
||||
return "Agent creation already in progress";
|
||||
if (isAgentSavedOutput(output)) return `Saved: ${output.agent_name}`;
|
||||
if (isAgentPreviewOutput(output)) return `Preview: ${output.agent_name}`;
|
||||
if (isClarificationNeededOutput(output)) return "Needs clarification";
|
||||
return "Error creating agent";
|
||||
}
|
||||
case "output-error":
|
||||
return "Error creating agent";
|
||||
default:
|
||||
return "Processing";
|
||||
}
|
||||
}
|
||||
|
||||
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
|
||||
switch (state) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
className="h-4 w-4 animate-spin text-muted-foreground"
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
case "output-available":
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
|
||||
case "output-error":
|
||||
return <XCircleIcon className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMaybeJson(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxChars: number): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
return `${trimmed.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import Link from "next/link";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import {
|
||||
ClarificationQuestionsWidget,
|
||||
type ClarifyingQuestion as WidgetClarifyingQuestion,
|
||||
} from "@/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget";
|
||||
import {
|
||||
formatMaybeJson,
|
||||
getAnimationText,
|
||||
getEditAgentToolOutput,
|
||||
isAgentPreviewOutput,
|
||||
isAgentSavedOutput,
|
||||
isClarificationNeededOutput,
|
||||
isErrorOutput,
|
||||
isOperationInProgressOutput,
|
||||
isOperationPendingOutput,
|
||||
isOperationStartedOutput,
|
||||
StateIcon,
|
||||
truncateText,
|
||||
type EditAgentToolOutput,
|
||||
} from "./helpers";
|
||||
|
||||
export interface EditAgentToolPart {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
part: EditAgentToolPart;
|
||||
}
|
||||
|
||||
function getAccordionMeta(output: EditAgentToolOutput): {
|
||||
badgeText: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
} {
|
||||
if (isAgentSavedOutput(output)) {
|
||||
return { badgeText: "Edit agent", title: output.agent_name };
|
||||
}
|
||||
if (isAgentPreviewOutput(output)) {
|
||||
return {
|
||||
badgeText: "Edit agent",
|
||||
title: output.agent_name,
|
||||
description: `${output.node_count} block${output.node_count === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
if (isClarificationNeededOutput(output)) {
|
||||
const questions = output.questions ?? [];
|
||||
return {
|
||||
badgeText: "Edit agent",
|
||||
title: "Needs clarification",
|
||||
description: `${questions.length} question${questions.length === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
isOperationStartedOutput(output) ||
|
||||
isOperationPendingOutput(output) ||
|
||||
isOperationInProgressOutput(output)
|
||||
) {
|
||||
return { badgeText: "Edit agent", title: "Editing agent" };
|
||||
}
|
||||
return { badgeText: "Edit agent", title: "Error" };
|
||||
}
|
||||
|
||||
export function EditAgentTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
const { onSend } = useCopilotChatActions();
|
||||
|
||||
const output = getEditAgentToolOutput(part);
|
||||
const hasExpandableContent =
|
||||
part.state === "output-available" &&
|
||||
!!output &&
|
||||
(isOperationStartedOutput(output) ||
|
||||
isOperationPendingOutput(output) ||
|
||||
isOperationInProgressOutput(output) ||
|
||||
isAgentPreviewOutput(output) ||
|
||||
isAgentSavedOutput(output) ||
|
||||
isClarificationNeededOutput(output) ||
|
||||
isErrorOutput(output));
|
||||
|
||||
function handleClarificationAnswers(answers: Record<string, string>) {
|
||||
const contextMessage = Object.entries(answers)
|
||||
.map(([keyword, answer]) => `${keyword}: ${answer}`)
|
||||
.join("\n");
|
||||
|
||||
onSend(
|
||||
`I have the answers to your questions:\n\n${contextMessage}\n\nPlease proceed with editing the agent.`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StateIcon state={part.state} />
|
||||
<MorphingTextAnimation text={text} />
|
||||
</div>
|
||||
|
||||
{hasExpandableContent && output && (
|
||||
<ToolAccordion
|
||||
{...getAccordionMeta(output)}
|
||||
defaultExpanded={isClarificationNeededOutput(output)}
|
||||
>
|
||||
{(isOperationStartedOutput(output) ||
|
||||
isOperationPendingOutput(output)) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Operation: {output.operation_id}
|
||||
</p>
|
||||
<p className="text-xs italic text-muted-foreground">
|
||||
Check your library in a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOperationInProgressOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
<p className="text-xs italic text-muted-foreground">
|
||||
Please wait for the current operation to finish.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAgentSavedOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={output.library_agent_link}
|
||||
className="text-xs font-medium text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Open in library
|
||||
</Link>
|
||||
<Link
|
||||
href={output.agent_page_link}
|
||||
className="text-xs font-medium text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Open in builder
|
||||
</Link>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{truncateText(
|
||||
formatMaybeJson({ agent_id: output.agent_id }),
|
||||
800,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAgentPreviewOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
{output.description?.trim() && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{output.description}
|
||||
</p>
|
||||
)}
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{truncateText(formatMaybeJson(output.agent_json), 1600)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isClarificationNeededOutput(output) && (
|
||||
<ClarificationQuestionsWidget
|
||||
questions={(output.questions ?? []).map((q) => {
|
||||
const item: WidgetClarifyingQuestion = {
|
||||
question: q.question,
|
||||
keyword: q.keyword,
|
||||
};
|
||||
const example =
|
||||
typeof q.example === "string" && q.example.trim()
|
||||
? q.example.trim()
|
||||
: null;
|
||||
if (example) item.example = example;
|
||||
return item;
|
||||
})}
|
||||
message={output.message}
|
||||
onSubmitAnswers={handleClarificationAnswers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isErrorOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
{output.error && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.error)}
|
||||
</pre>
|
||||
)}
|
||||
{output.details && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.details)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentPreviewResponse } from "@/app/api/__generated__/models/agentPreviewResponse";
|
||||
import type { AgentSavedResponse } from "@/app/api/__generated__/models/agentSavedResponse";
|
||||
import type { ClarificationNeededResponse } from "@/app/api/__generated__/models/clarificationNeededResponse";
|
||||
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
|
||||
import type { OperationInProgressResponse } from "@/app/api/__generated__/models/operationInProgressResponse";
|
||||
import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse";
|
||||
import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse";
|
||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
|
||||
export type EditAgentToolOutput =
|
||||
| OperationStartedResponse
|
||||
| OperationPendingResponse
|
||||
| OperationInProgressResponse
|
||||
| AgentPreviewResponse
|
||||
| AgentSavedResponse
|
||||
| ClarificationNeededResponse
|
||||
| ErrorResponse;
|
||||
|
||||
function parseOutput(output: unknown): EditAgentToolOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object") {
|
||||
const type = (output as { type?: unknown }).type;
|
||||
if (
|
||||
type === ResponseType.operation_started ||
|
||||
type === ResponseType.operation_pending ||
|
||||
type === ResponseType.operation_in_progress ||
|
||||
type === ResponseType.agent_preview ||
|
||||
type === ResponseType.agent_saved ||
|
||||
type === ResponseType.clarification_needed ||
|
||||
type === ResponseType.error
|
||||
) {
|
||||
return output as EditAgentToolOutput;
|
||||
}
|
||||
if ("operation_id" in output && "tool_name" in output)
|
||||
return output as OperationStartedResponse | OperationPendingResponse;
|
||||
if ("tool_call_id" in output) return output as OperationInProgressResponse;
|
||||
if ("agent_json" in output && "agent_name" in output)
|
||||
return output as AgentPreviewResponse;
|
||||
if ("agent_id" in output && "library_agent_id" in output)
|
||||
return output as AgentSavedResponse;
|
||||
if ("questions" in output) return output as ClarificationNeededResponse;
|
||||
if ("error" in output || "details" in output)
|
||||
return output as ErrorResponse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getEditAgentToolOutput(
|
||||
part: unknown,
|
||||
): EditAgentToolOutput | null {
|
||||
if (!part || typeof part !== "object") return null;
|
||||
return parseOutput((part as { output?: unknown }).output);
|
||||
}
|
||||
|
||||
export function isOperationStartedOutput(
|
||||
output: EditAgentToolOutput,
|
||||
): output is OperationStartedResponse {
|
||||
return (
|
||||
output.type === ResponseType.operation_started ||
|
||||
("operation_id" in output && "tool_name" in output)
|
||||
);
|
||||
}
|
||||
|
||||
export function isOperationPendingOutput(
|
||||
output: EditAgentToolOutput,
|
||||
): output is OperationPendingResponse {
|
||||
return output.type === ResponseType.operation_pending;
|
||||
}
|
||||
|
||||
export function isOperationInProgressOutput(
|
||||
output: EditAgentToolOutput,
|
||||
): output is OperationInProgressResponse {
|
||||
return (
|
||||
output.type === ResponseType.operation_in_progress ||
|
||||
"tool_call_id" in output
|
||||
);
|
||||
}
|
||||
|
||||
export function isAgentPreviewOutput(
|
||||
output: EditAgentToolOutput,
|
||||
): output is AgentPreviewResponse {
|
||||
return output.type === ResponseType.agent_preview || "agent_json" in output;
|
||||
}
|
||||
|
||||
export function isAgentSavedOutput(
|
||||
output: EditAgentToolOutput,
|
||||
): output is AgentSavedResponse {
|
||||
return (
|
||||
output.type === ResponseType.agent_saved || "agent_page_link" in output
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationNeededOutput(
|
||||
output: EditAgentToolOutput,
|
||||
): output is ClarificationNeededResponse {
|
||||
return (
|
||||
output.type === ResponseType.clarification_needed || "questions" in output
|
||||
);
|
||||
}
|
||||
|
||||
export function isErrorOutput(
|
||||
output: EditAgentToolOutput,
|
||||
): output is ErrorResponse {
|
||||
return output.type === ResponseType.error || "error" in output;
|
||||
}
|
||||
|
||||
export function getAnimationText(part: {
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}): string {
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
return "Editing agent";
|
||||
case "input-available":
|
||||
return "Updating agent workflow";
|
||||
case "output-available": {
|
||||
const output = parseOutput(part.output);
|
||||
if (!output) return "Agent updated";
|
||||
if (isOperationStartedOutput(output)) return "Agent update started";
|
||||
if (isOperationPendingOutput(output)) return "Agent update in progress";
|
||||
if (isOperationInProgressOutput(output))
|
||||
return "Agent update already in progress";
|
||||
if (isAgentSavedOutput(output)) return `Saved: ${output.agent_name}`;
|
||||
if (isAgentPreviewOutput(output)) return `Preview: ${output.agent_name}`;
|
||||
if (isClarificationNeededOutput(output)) return "Needs clarification";
|
||||
return "Error editing agent";
|
||||
}
|
||||
case "output-error":
|
||||
return "Error editing agent";
|
||||
default:
|
||||
return "Processing";
|
||||
}
|
||||
}
|
||||
|
||||
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
|
||||
switch (state) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
className="h-4 w-4 animate-spin text-muted-foreground"
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
case "output-available":
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
|
||||
case "output-error":
|
||||
return <XCircleIcon className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMaybeJson(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxChars: number): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
return `${trimmed.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ToolUIPart } from "ai";
|
||||
import Link from "next/link";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import {
|
||||
getAgentHref,
|
||||
getAnimationText,
|
||||
getFindAgentsOutput,
|
||||
getSourceLabelFromToolType,
|
||||
isAgentsFoundOutput,
|
||||
StateIcon,
|
||||
} from "./helpers";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
|
||||
export interface FindAgentsToolPart {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
part: FindAgentsToolPart;
|
||||
}
|
||||
|
||||
export function FindAgentsTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
const output = getFindAgentsOutput(part);
|
||||
|
||||
const query =
|
||||
typeof part.input === "object" && part.input !== null
|
||||
? String((part.input as { query?: unknown }).query ?? "").trim()
|
||||
: "";
|
||||
|
||||
const agentsFoundOutput =
|
||||
part.state === "output-available" && output && isAgentsFoundOutput(output)
|
||||
? output
|
||||
: null;
|
||||
|
||||
const hasAgents =
|
||||
!!agentsFoundOutput &&
|
||||
agentsFoundOutput.agents.length > 0 &&
|
||||
(typeof agentsFoundOutput.count !== "number" ||
|
||||
agentsFoundOutput.count > 0);
|
||||
const totalCount = agentsFoundOutput ? agentsFoundOutput.count : 0;
|
||||
const { label: sourceLabel, source } = getSourceLabelFromToolType(part.type);
|
||||
const scopeText =
|
||||
source === "library"
|
||||
? "in your library"
|
||||
: source === "marketplace"
|
||||
? "in marketplace"
|
||||
: "";
|
||||
const accordionDescription = `Found ${totalCount}${scopeText ? ` ${scopeText}` : ""}${
|
||||
query ? ` for "${query}"` : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StateIcon state={part.state} />
|
||||
<MorphingTextAnimation text={text} />
|
||||
</div>
|
||||
|
||||
{hasAgents && agentsFoundOutput && (
|
||||
<ToolAccordion
|
||||
badgeText={sourceLabel}
|
||||
title="Agent results"
|
||||
description={accordionDescription}
|
||||
>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{agentsFoundOutput.agents.map((agent) => {
|
||||
const href = getAgentHref(agent);
|
||||
const agentSource =
|
||||
agent.source === "library"
|
||||
? "Library"
|
||||
: agent.source === "marketplace"
|
||||
? "Marketplace"
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="rounded-2xl border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{agent.name}
|
||||
</p>
|
||||
{agentSource && (
|
||||
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{agentSource}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{agent.description}
|
||||
</p>
|
||||
</div>
|
||||
{href && (
|
||||
<Link
|
||||
href={href}
|
||||
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentInfo } from "@/app/api/__generated__/models/agentInfo";
|
||||
import type { AgentsFoundResponse } from "@/app/api/__generated__/models/agentsFoundResponse";
|
||||
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
|
||||
import type { NoResultsResponse } from "@/app/api/__generated__/models/noResultsResponse";
|
||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
|
||||
export interface FindAgentInput {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export type FindAgentsOutput =
|
||||
| AgentsFoundResponse
|
||||
| NoResultsResponse
|
||||
| ErrorResponse;
|
||||
|
||||
export type FindAgentsToolType =
|
||||
| "tool-find_agent"
|
||||
| "tool-find_library_agent"
|
||||
| (string & {});
|
||||
|
||||
function parseOutput(output: unknown): FindAgentsOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object") {
|
||||
const type = (output as { type?: unknown }).type;
|
||||
if (
|
||||
type === ResponseType.agents_found ||
|
||||
type === ResponseType.no_results ||
|
||||
type === ResponseType.error
|
||||
) {
|
||||
return output as FindAgentsOutput;
|
||||
}
|
||||
if ("agents" in output && "count" in output)
|
||||
return output as AgentsFoundResponse;
|
||||
if ("suggestions" in output && !("error" in output))
|
||||
return output as NoResultsResponse;
|
||||
if ("error" in output || "details" in output)
|
||||
return output as ErrorResponse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getFindAgentsOutput(part: unknown): FindAgentsOutput | null {
|
||||
if (!part || typeof part !== "object") return null;
|
||||
return parseOutput((part as { output?: unknown }).output);
|
||||
}
|
||||
|
||||
export function isAgentsFoundOutput(
|
||||
output: FindAgentsOutput,
|
||||
): output is AgentsFoundResponse {
|
||||
return output.type === ResponseType.agents_found || "agents" in output;
|
||||
}
|
||||
|
||||
export function isNoResultsOutput(
|
||||
output: FindAgentsOutput,
|
||||
): output is NoResultsResponse {
|
||||
return (
|
||||
output.type === ResponseType.no_results ||
|
||||
("suggestions" in output && !("error" in output))
|
||||
);
|
||||
}
|
||||
|
||||
export function isErrorOutput(
|
||||
output: FindAgentsOutput,
|
||||
): output is ErrorResponse {
|
||||
return output.type === ResponseType.error || "error" in output;
|
||||
}
|
||||
|
||||
export function getSourceLabelFromToolType(toolType?: FindAgentsToolType): {
|
||||
source: "marketplace" | "library" | "unknown";
|
||||
label: string;
|
||||
} {
|
||||
if (toolType === "tool-find_library_agent") {
|
||||
return { source: "library", label: "Library" };
|
||||
}
|
||||
if (toolType === "tool-find_agent") {
|
||||
return { source: "marketplace", label: "Marketplace" };
|
||||
}
|
||||
return { source: "unknown", label: "Agents" };
|
||||
}
|
||||
|
||||
export function getAnimationText(part: {
|
||||
type?: FindAgentsToolType;
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}): string {
|
||||
const { label, source } = getSourceLabelFromToolType(part.type);
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
return `Searching ${label.toLowerCase()} agents for you`;
|
||||
|
||||
case "input-available": {
|
||||
const query = (part.input as FindAgentInput | undefined)?.query?.trim();
|
||||
if (query) {
|
||||
return source === "library"
|
||||
? `Finding library agents matching "${query}"`
|
||||
: `Finding marketplace agents matching "${query}"`;
|
||||
}
|
||||
return source === "library" ? "Finding library agents" : "Finding agents";
|
||||
}
|
||||
|
||||
case "output-available": {
|
||||
const output = parseOutput(part.output);
|
||||
const query = (part.input as FindAgentInput | undefined)?.query?.trim();
|
||||
const scope = source === "library" ? "in your library" : "in marketplace";
|
||||
if (!output) {
|
||||
return query ? `Found agents ${scope} for "${query}"` : "Found agents";
|
||||
}
|
||||
if (isNoResultsOutput(output)) {
|
||||
return query
|
||||
? `No agents found ${scope} for "${query}"`
|
||||
: `No agents found ${scope}`;
|
||||
}
|
||||
if (isAgentsFoundOutput(output)) {
|
||||
const count = output.count ?? output.agents?.length ?? 0;
|
||||
const countText = `Found ${count} agent${count === 1 ? "" : "s"}`;
|
||||
if (query) return `${countText} ${scope} for "${query}"`;
|
||||
return `${countText} ${scope}`;
|
||||
}
|
||||
if (isErrorOutput(output)) {
|
||||
return `Error finding agents ${scope}`;
|
||||
}
|
||||
return `Found agents ${scope}`;
|
||||
}
|
||||
|
||||
case "output-error":
|
||||
return source === "library"
|
||||
? "Error finding agents in your library"
|
||||
: "Error finding agents in marketplace";
|
||||
|
||||
default:
|
||||
return "Processing";
|
||||
}
|
||||
}
|
||||
|
||||
export function getAgentHref(agent: AgentInfo): string | null {
|
||||
if (agent.source === "library") {
|
||||
return `/library/agents/${encodeURIComponent(agent.id)}`;
|
||||
}
|
||||
|
||||
const [creator, slug, ...rest] = agent.id.split("/");
|
||||
if (!creator || !slug || rest.length > 0) return null;
|
||||
return `/marketplace/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
|
||||
switch (state) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
className="h-4 w-4 animate-spin text-muted-foreground"
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
case "output-available":
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
|
||||
case "output-error":
|
||||
return <XCircleIcon className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import type { BlockListResponse } from "@/app/api/__generated__/models/blockListResponse";
|
||||
import { ToolUIPart } from "ai";
|
||||
import { getAnimationText, StateIcon } from "./helpers";
|
||||
|
||||
export interface FindBlockInput {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export type FindBlockOutput = BlockListResponse;
|
||||
|
||||
export interface FindBlockToolPart {
|
||||
type: string;
|
||||
toolName?: string;
|
||||
toolCallId: string;
|
||||
state: ToolUIPart["state"];
|
||||
input?: FindBlockInput | unknown;
|
||||
output?: string | FindBlockOutput | unknown;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
part: FindBlockToolPart;
|
||||
}
|
||||
|
||||
export function FindBlocksTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
|
||||
<StateIcon state={part.state} />
|
||||
<MorphingTextAnimation text={text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { ToolUIPart } from "ai";
|
||||
import type { BlockListResponse } from "@/app/api/__generated__/models/blockListResponse";
|
||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
import { FindBlockInput, FindBlockToolPart } from "./FindBlocks";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
function parseOutput(output: unknown): BlockListResponse | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object") {
|
||||
const type = (output as { type?: unknown }).type;
|
||||
if (type === ResponseType.block_list || "blocks" in output) {
|
||||
return output as BlockListResponse;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAnimationText(part: FindBlockToolPart): string {
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
return "Searching blocks for you";
|
||||
|
||||
case "input-available": {
|
||||
const query = (part.input as FindBlockInput).query;
|
||||
return `Finding "${query}" blocks`;
|
||||
}
|
||||
|
||||
case "output-available": {
|
||||
const parsed = parseOutput(part.output);
|
||||
if (parsed) {
|
||||
return `Found ${parsed.count} "${(part.input as FindBlockInput).query}" blocks`;
|
||||
}
|
||||
return "Found blocks";
|
||||
}
|
||||
|
||||
case "output-error":
|
||||
return "Error finding blocks";
|
||||
|
||||
default:
|
||||
return "Processing";
|
||||
}
|
||||
}
|
||||
|
||||
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
|
||||
switch (state) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
className="h-4 w-4 animate-spin text-muted-foreground"
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
case "output-available":
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
|
||||
case "output-error":
|
||||
return <XCircleIcon className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import Link from "next/link";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import {
|
||||
ChatCredentialsSetup,
|
||||
type CredentialInfo,
|
||||
} from "@/components/contextual/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
|
||||
import {
|
||||
formatMaybeJson,
|
||||
getAnimationText,
|
||||
getRunAgentToolOutput,
|
||||
isRunAgentAgentDetailsOutput,
|
||||
isRunAgentErrorOutput,
|
||||
isRunAgentExecutionStartedOutput,
|
||||
isRunAgentNeedLoginOutput,
|
||||
isRunAgentSetupRequirementsOutput,
|
||||
StateIcon,
|
||||
type RunAgentToolOutput,
|
||||
} from "./helpers";
|
||||
|
||||
export interface RunAgentToolPart {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
part: RunAgentToolPart;
|
||||
}
|
||||
|
||||
function getAccordionMeta(output: RunAgentToolOutput): {
|
||||
badgeText: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
} {
|
||||
if (isRunAgentExecutionStartedOutput(output)) {
|
||||
const statusText =
|
||||
typeof output.status === "string" && output.status.trim()
|
||||
? output.status.trim()
|
||||
: "started";
|
||||
return {
|
||||
badgeText: "Run agent",
|
||||
title: output.graph_name,
|
||||
description: `Status: ${statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRunAgentAgentDetailsOutput(output)) {
|
||||
return {
|
||||
badgeText: "Run agent",
|
||||
title: output.agent.name,
|
||||
description: "Inputs required",
|
||||
};
|
||||
}
|
||||
|
||||
if (isRunAgentSetupRequirementsOutput(output)) {
|
||||
const missingCredsCount = Object.keys(
|
||||
(output.setup_info.user_readiness?.missing_credentials ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
).length;
|
||||
return {
|
||||
badgeText: "Run agent",
|
||||
title: output.setup_info.agent_name,
|
||||
description:
|
||||
missingCredsCount > 0
|
||||
? `Missing ${missingCredsCount} credential${missingCredsCount === 1 ? "" : "s"}`
|
||||
: output.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRunAgentNeedLoginOutput(output)) {
|
||||
return { badgeText: "Run agent", title: "Sign in required" };
|
||||
}
|
||||
|
||||
return { badgeText: "Run agent", title: "Error" };
|
||||
}
|
||||
|
||||
function coerceMissingCredentials(
|
||||
rawMissingCredentials: unknown,
|
||||
): CredentialInfo[] {
|
||||
const missing =
|
||||
rawMissingCredentials && typeof rawMissingCredentials === "object"
|
||||
? (rawMissingCredentials as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const validTypes = new Set([
|
||||
"api_key",
|
||||
"oauth2",
|
||||
"user_password",
|
||||
"host_scoped",
|
||||
]);
|
||||
|
||||
const results: CredentialInfo[] = [];
|
||||
|
||||
Object.values(missing).forEach((value) => {
|
||||
if (!value || typeof value !== "object") return;
|
||||
const cred = value as Record<string, unknown>;
|
||||
|
||||
const provider =
|
||||
typeof cred.provider === "string" ? cred.provider.trim() : "";
|
||||
if (!provider) return;
|
||||
|
||||
const providerName =
|
||||
typeof cred.provider_name === "string" && cred.provider_name.trim()
|
||||
? cred.provider_name.trim()
|
||||
: provider.replace(/_/g, " ");
|
||||
|
||||
const title =
|
||||
typeof cred.title === "string" && cred.title.trim()
|
||||
? cred.title.trim()
|
||||
: providerName;
|
||||
|
||||
const types =
|
||||
Array.isArray(cred.types) && cred.types.length > 0
|
||||
? cred.types
|
||||
: typeof cred.type === "string"
|
||||
? [cred.type]
|
||||
: [];
|
||||
|
||||
const credentialTypes = types
|
||||
.map((t) => (typeof t === "string" ? t.trim() : ""))
|
||||
.filter(
|
||||
(t): t is "api_key" | "oauth2" | "user_password" | "host_scoped" =>
|
||||
validTypes.has(t),
|
||||
);
|
||||
|
||||
if (credentialTypes.length === 0) return;
|
||||
|
||||
const scopes = Array.isArray(cred.scopes)
|
||||
? cred.scopes.filter((s): s is string => typeof s === "string")
|
||||
: undefined;
|
||||
|
||||
const item: CredentialInfo = {
|
||||
provider,
|
||||
providerName,
|
||||
credentialTypes,
|
||||
title,
|
||||
};
|
||||
if (scopes && scopes.length > 0) {
|
||||
item.scopes = scopes;
|
||||
}
|
||||
results.push(item);
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function coerceExpectedInputs(rawInputs: unknown): Array<{
|
||||
name: string;
|
||||
title: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
}> {
|
||||
if (!Array.isArray(rawInputs)) return [];
|
||||
const results: Array<{
|
||||
name: string;
|
||||
title: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
}> = [];
|
||||
|
||||
rawInputs.forEach((value, index) => {
|
||||
if (!value || typeof value !== "object") return;
|
||||
const input = value as Record<string, unknown>;
|
||||
|
||||
const name =
|
||||
typeof input.name === "string" && input.name.trim()
|
||||
? input.name.trim()
|
||||
: `input-${index}`;
|
||||
const title =
|
||||
typeof input.title === "string" && input.title.trim()
|
||||
? input.title.trim()
|
||||
: name;
|
||||
const type = typeof input.type === "string" ? input.type : "unknown";
|
||||
const description =
|
||||
typeof input.description === "string" && input.description.trim()
|
||||
? input.description.trim()
|
||||
: undefined;
|
||||
const required = Boolean(input.required);
|
||||
|
||||
const item: {
|
||||
name: string;
|
||||
title: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
} = { name, title, type, required };
|
||||
if (description) item.description = description;
|
||||
results.push(item);
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function RunAgentTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
const { onSend } = useCopilotChatActions();
|
||||
|
||||
const output = getRunAgentToolOutput(part);
|
||||
const hasExpandableContent =
|
||||
part.state === "output-available" &&
|
||||
!!output &&
|
||||
(isRunAgentExecutionStartedOutput(output) ||
|
||||
isRunAgentAgentDetailsOutput(output) ||
|
||||
isRunAgentSetupRequirementsOutput(output) ||
|
||||
isRunAgentNeedLoginOutput(output) ||
|
||||
isRunAgentErrorOutput(output));
|
||||
|
||||
function handleAllCredentialsComplete() {
|
||||
onSend(
|
||||
"I've configured the required credentials. Please check if everything is ready and proceed with running the agent.",
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StateIcon state={part.state} />
|
||||
<MorphingTextAnimation text={text} />
|
||||
</div>
|
||||
|
||||
{hasExpandableContent && output && (
|
||||
<ToolAccordion
|
||||
{...getAccordionMeta(output)}
|
||||
defaultExpanded={
|
||||
isRunAgentSetupRequirementsOutput(output) ||
|
||||
isRunAgentAgentDetailsOutput(output)
|
||||
}
|
||||
>
|
||||
{isRunAgentExecutionStartedOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Execution started
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{output.execution_id}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{output.message}
|
||||
</p>
|
||||
</div>
|
||||
{output.library_agent_link && (
|
||||
<Link
|
||||
href={output.library_agent_link}
|
||||
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunAgentAgentDetailsOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
|
||||
{output.agent.description?.trim() && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{output.agent.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<p className="text-xs font-medium text-foreground">Inputs</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Provide required inputs in chat, or ask to run with defaults.
|
||||
</p>
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.agent.inputs)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunAgentSetupRequirementsOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
|
||||
{coerceMissingCredentials(
|
||||
output.setup_info.user_readiness?.missing_credentials,
|
||||
).length > 0 && (
|
||||
<ChatCredentialsSetup
|
||||
credentials={coerceMissingCredentials(
|
||||
output.setup_info.user_readiness?.missing_credentials,
|
||||
)}
|
||||
agentName={output.setup_info.agent_name}
|
||||
message={output.message}
|
||||
onAllCredentialsComplete={handleAllCredentialsComplete}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{coerceExpectedInputs(
|
||||
(output.setup_info.requirements as Record<string, unknown>)
|
||||
?.inputs,
|
||||
).length > 0 && (
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
Expected inputs
|
||||
</p>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{coerceExpectedInputs(
|
||||
(
|
||||
output.setup_info.requirements as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)?.inputs,
|
||||
).map((input) => (
|
||||
<div key={input.name} className="rounded-xl border p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{input.title}
|
||||
</p>
|
||||
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{input.required ? "Required" : "Optional"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{input.name} • {input.type}
|
||||
{input.description ? ` • ${input.description}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunAgentNeedLoginOutput(output) && (
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
)}
|
||||
|
||||
{isRunAgentErrorOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
{output.error && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.error)}
|
||||
</pre>
|
||||
)}
|
||||
{output.details && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.details)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentDetailsResponse } from "@/app/api/__generated__/models/agentDetailsResponse";
|
||||
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
|
||||
import type { ExecutionStartedResponse } from "@/app/api/__generated__/models/executionStartedResponse";
|
||||
import type { NeedLoginResponse } from "@/app/api/__generated__/models/needLoginResponse";
|
||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
|
||||
|
||||
export interface RunAgentInput {
|
||||
username_agent_slug?: string;
|
||||
library_agent_id?: string;
|
||||
inputs?: Record<string, unknown>;
|
||||
use_defaults?: boolean;
|
||||
schedule_name?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export type RunAgentToolOutput =
|
||||
| SetupRequirementsResponse
|
||||
| ExecutionStartedResponse
|
||||
| AgentDetailsResponse
|
||||
| NeedLoginResponse
|
||||
| ErrorResponse;
|
||||
|
||||
const RUN_AGENT_OUTPUT_TYPES = new Set<string>([
|
||||
ResponseType.setup_requirements,
|
||||
ResponseType.execution_started,
|
||||
ResponseType.agent_details,
|
||||
ResponseType.need_login,
|
||||
ResponseType.error,
|
||||
]);
|
||||
|
||||
export function isRunAgentSetupRequirementsOutput(
|
||||
output: RunAgentToolOutput,
|
||||
): output is SetupRequirementsResponse {
|
||||
return (
|
||||
output.type === ResponseType.setup_requirements ||
|
||||
("setup_info" in output && typeof output.setup_info === "object")
|
||||
);
|
||||
}
|
||||
|
||||
export function isRunAgentExecutionStartedOutput(
|
||||
output: RunAgentToolOutput,
|
||||
): output is ExecutionStartedResponse {
|
||||
return (
|
||||
output.type === ResponseType.execution_started || "execution_id" in output
|
||||
);
|
||||
}
|
||||
|
||||
export function isRunAgentAgentDetailsOutput(
|
||||
output: RunAgentToolOutput,
|
||||
): output is AgentDetailsResponse {
|
||||
return output.type === ResponseType.agent_details || "agent" in output;
|
||||
}
|
||||
|
||||
export function isRunAgentNeedLoginOutput(
|
||||
output: RunAgentToolOutput,
|
||||
): output is NeedLoginResponse {
|
||||
return output.type === ResponseType.need_login;
|
||||
}
|
||||
|
||||
export function isRunAgentErrorOutput(
|
||||
output: RunAgentToolOutput,
|
||||
): output is ErrorResponse {
|
||||
return output.type === ResponseType.error || "error" in output;
|
||||
}
|
||||
|
||||
function parseOutput(output: unknown): RunAgentToolOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object") {
|
||||
const type = (output as { type?: unknown }).type;
|
||||
if (typeof type === "string" && RUN_AGENT_OUTPUT_TYPES.has(type)) {
|
||||
return output as RunAgentToolOutput;
|
||||
}
|
||||
if ("execution_id" in output) return output as ExecutionStartedResponse;
|
||||
if ("setup_info" in output) return output as SetupRequirementsResponse;
|
||||
if ("agent" in output) return output as AgentDetailsResponse;
|
||||
if ("error" in output || "details" in output)
|
||||
return output as ErrorResponse;
|
||||
if (type === ResponseType.need_login) return output as NeedLoginResponse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getRunAgentToolOutput(
|
||||
part: unknown,
|
||||
): RunAgentToolOutput | null {
|
||||
if (!part || typeof part !== "object") return null;
|
||||
return parseOutput((part as { output?: unknown }).output);
|
||||
}
|
||||
|
||||
function getAgentIdentifierText(
|
||||
input: RunAgentInput | undefined,
|
||||
): string | null {
|
||||
if (!input) return null;
|
||||
const slug = input.username_agent_slug?.trim();
|
||||
if (slug) return slug;
|
||||
const libraryId = input.library_agent_id?.trim();
|
||||
if (libraryId) return `Library agent ${libraryId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getExecutionModeText(input: RunAgentInput | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const isSchedule = Boolean(input.schedule_name?.trim() || input.cron?.trim());
|
||||
return isSchedule ? "Scheduled run" : "Run";
|
||||
}
|
||||
|
||||
export function getAnimationText(part: {
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}): string {
|
||||
const input = part.input as RunAgentInput | undefined;
|
||||
const agentIdentifier = getAgentIdentifierText(input);
|
||||
const mode = getExecutionModeText(input);
|
||||
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
return "Preparing to run agent";
|
||||
case "input-available":
|
||||
return agentIdentifier ? `${mode}: ${agentIdentifier}` : "Running agent";
|
||||
case "output-available": {
|
||||
const output = parseOutput(part.output);
|
||||
if (!output) return "Agent run updated";
|
||||
if (isRunAgentExecutionStartedOutput(output)) {
|
||||
return `Started: ${output.graph_name}`;
|
||||
}
|
||||
if (isRunAgentAgentDetailsOutput(output)) {
|
||||
return `Agent inputs: ${output.agent.name}`;
|
||||
}
|
||||
if (isRunAgentSetupRequirementsOutput(output)) {
|
||||
return `Needs setup: ${output.setup_info.agent_name}`;
|
||||
}
|
||||
if (isRunAgentNeedLoginOutput(output))
|
||||
return "Sign in required to run agent";
|
||||
return "Error running agent";
|
||||
}
|
||||
case "output-error":
|
||||
return "Error running agent";
|
||||
default:
|
||||
return "Processing";
|
||||
}
|
||||
}
|
||||
|
||||
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
|
||||
switch (state) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
className="h-4 w-4 animate-spin text-muted-foreground"
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
case "output-available":
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
|
||||
case "output-error":
|
||||
return <XCircleIcon className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMaybeJson(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
|
||||
import {
|
||||
ChatCredentialsSetup,
|
||||
type CredentialInfo,
|
||||
} from "@/components/contextual/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
|
||||
import {
|
||||
formatMaybeJson,
|
||||
getAnimationText,
|
||||
getRunBlockToolOutput,
|
||||
isRunBlockBlockOutput,
|
||||
isRunBlockErrorOutput,
|
||||
isRunBlockSetupRequirementsOutput,
|
||||
StateIcon,
|
||||
type RunBlockToolOutput,
|
||||
} from "./helpers";
|
||||
|
||||
export interface RunBlockToolPart {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
part: RunBlockToolPart;
|
||||
}
|
||||
|
||||
function getAccordionMeta(output: RunBlockToolOutput): {
|
||||
badgeText: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
} {
|
||||
if (isRunBlockBlockOutput(output)) {
|
||||
const keys = Object.keys(output.outputs ?? {});
|
||||
return {
|
||||
badgeText: "Run block",
|
||||
title: output.block_name,
|
||||
description:
|
||||
keys.length > 0
|
||||
? `${keys.length} output key${keys.length === 1 ? "" : "s"}`
|
||||
: output.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRunBlockSetupRequirementsOutput(output)) {
|
||||
const missingCredsCount = Object.keys(
|
||||
(output.setup_info.user_readiness?.missing_credentials ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
).length;
|
||||
return {
|
||||
badgeText: "Run block",
|
||||
title: output.setup_info.agent_name,
|
||||
description:
|
||||
missingCredsCount > 0
|
||||
? `Missing ${missingCredsCount} credential${missingCredsCount === 1 ? "" : "s"}`
|
||||
: output.message,
|
||||
};
|
||||
}
|
||||
|
||||
return { badgeText: "Run block", title: "Error" };
|
||||
}
|
||||
|
||||
function coerceMissingCredentials(
|
||||
rawMissingCredentials: unknown,
|
||||
): CredentialInfo[] {
|
||||
const missing =
|
||||
rawMissingCredentials && typeof rawMissingCredentials === "object"
|
||||
? (rawMissingCredentials as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const validTypes = new Set([
|
||||
"api_key",
|
||||
"oauth2",
|
||||
"user_password",
|
||||
"host_scoped",
|
||||
]);
|
||||
|
||||
const results: CredentialInfo[] = [];
|
||||
|
||||
Object.values(missing).forEach((value) => {
|
||||
if (!value || typeof value !== "object") return;
|
||||
const cred = value as Record<string, unknown>;
|
||||
|
||||
const provider =
|
||||
typeof cred.provider === "string" ? cred.provider.trim() : "";
|
||||
if (!provider) return;
|
||||
|
||||
const providerName =
|
||||
typeof cred.provider_name === "string" && cred.provider_name.trim()
|
||||
? cred.provider_name.trim()
|
||||
: provider.replace(/_/g, " ");
|
||||
|
||||
const title =
|
||||
typeof cred.title === "string" && cred.title.trim()
|
||||
? cred.title.trim()
|
||||
: providerName;
|
||||
|
||||
const types =
|
||||
Array.isArray(cred.types) && cred.types.length > 0
|
||||
? cred.types
|
||||
: typeof cred.type === "string"
|
||||
? [cred.type]
|
||||
: [];
|
||||
|
||||
const credentialTypes = types
|
||||
.map((t) => (typeof t === "string" ? t.trim() : ""))
|
||||
.filter(
|
||||
(t): t is "api_key" | "oauth2" | "user_password" | "host_scoped" =>
|
||||
validTypes.has(t),
|
||||
);
|
||||
|
||||
if (credentialTypes.length === 0) return;
|
||||
|
||||
const scopes = Array.isArray(cred.scopes)
|
||||
? cred.scopes.filter((s): s is string => typeof s === "string")
|
||||
: undefined;
|
||||
|
||||
const item: CredentialInfo = {
|
||||
provider,
|
||||
providerName,
|
||||
credentialTypes,
|
||||
title,
|
||||
};
|
||||
if (scopes && scopes.length > 0) {
|
||||
item.scopes = scopes;
|
||||
}
|
||||
results.push(item);
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function coerceExpectedInputs(rawInputs: unknown): Array<{
|
||||
name: string;
|
||||
title: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
}> {
|
||||
if (!Array.isArray(rawInputs)) return [];
|
||||
const results: Array<{
|
||||
name: string;
|
||||
title: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
}> = [];
|
||||
|
||||
rawInputs.forEach((value, index) => {
|
||||
if (!value || typeof value !== "object") return;
|
||||
const input = value as Record<string, unknown>;
|
||||
|
||||
const name =
|
||||
typeof input.name === "string" && input.name.trim()
|
||||
? input.name.trim()
|
||||
: `input-${index}`;
|
||||
const title =
|
||||
typeof input.title === "string" && input.title.trim()
|
||||
? input.title.trim()
|
||||
: name;
|
||||
const type = typeof input.type === "string" ? input.type : "unknown";
|
||||
const description =
|
||||
typeof input.description === "string" && input.description.trim()
|
||||
? input.description.trim()
|
||||
: undefined;
|
||||
const required = Boolean(input.required);
|
||||
|
||||
const item: {
|
||||
name: string;
|
||||
title: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
} = { name, title, type, required };
|
||||
if (description) item.description = description;
|
||||
results.push(item);
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function RunBlockTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
const { onSend } = useCopilotChatActions();
|
||||
|
||||
const output = getRunBlockToolOutput(part);
|
||||
const hasExpandableContent =
|
||||
part.state === "output-available" &&
|
||||
!!output &&
|
||||
(isRunBlockBlockOutput(output) ||
|
||||
isRunBlockSetupRequirementsOutput(output) ||
|
||||
isRunBlockErrorOutput(output));
|
||||
|
||||
function handleAllCredentialsComplete() {
|
||||
onSend(
|
||||
"I've configured the required credentials. Please re-run the block now.",
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StateIcon state={part.state} />
|
||||
<MorphingTextAnimation text={text} />
|
||||
</div>
|
||||
|
||||
{hasExpandableContent && output && (
|
||||
<ToolAccordion
|
||||
{...getAccordionMeta(output)}
|
||||
defaultExpanded={isRunBlockSetupRequirementsOutput(output)}
|
||||
>
|
||||
{isRunBlockBlockOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
|
||||
{Object.entries(output.outputs ?? {}).map(([key, items]) => (
|
||||
<div key={key} className="rounded-2xl border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{key}
|
||||
</p>
|
||||
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{items.length} item{items.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
|
||||
{formatMaybeJson(items.slice(0, 3))}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunBlockSetupRequirementsOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
|
||||
{coerceMissingCredentials(
|
||||
output.setup_info.user_readiness?.missing_credentials,
|
||||
).length > 0 && (
|
||||
<ChatCredentialsSetup
|
||||
credentials={coerceMissingCredentials(
|
||||
output.setup_info.user_readiness?.missing_credentials,
|
||||
)}
|
||||
agentName={output.setup_info.agent_name}
|
||||
message={output.message}
|
||||
onAllCredentialsComplete={handleAllCredentialsComplete}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{coerceExpectedInputs(
|
||||
(output.setup_info.requirements as Record<string, unknown>)
|
||||
?.inputs,
|
||||
).length > 0 && (
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
Expected inputs
|
||||
</p>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{coerceExpectedInputs(
|
||||
(
|
||||
output.setup_info.requirements as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)?.inputs,
|
||||
).map((input) => (
|
||||
<div key={input.name} className="rounded-xl border p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{input.title}
|
||||
</p>
|
||||
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{input.required ? "Required" : "Optional"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{input.name} • {input.type}
|
||||
{input.description ? ` • ${input.description}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunBlockErrorOutput(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
{output.error && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.error)}
|
||||
</pre>
|
||||
)}
|
||||
{output.details && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.details)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { BlockOutputResponse } from "@/app/api/__generated__/models/blockOutputResponse";
|
||||
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
|
||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
|
||||
|
||||
export interface RunBlockInput {
|
||||
block_id?: string;
|
||||
input_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type RunBlockToolOutput =
|
||||
| SetupRequirementsResponse
|
||||
| BlockOutputResponse
|
||||
| ErrorResponse;
|
||||
|
||||
const RUN_BLOCK_OUTPUT_TYPES = new Set<string>([
|
||||
ResponseType.setup_requirements,
|
||||
ResponseType.block_output,
|
||||
ResponseType.error,
|
||||
]);
|
||||
|
||||
export function isRunBlockSetupRequirementsOutput(
|
||||
output: RunBlockToolOutput,
|
||||
): output is SetupRequirementsResponse {
|
||||
return (
|
||||
output.type === ResponseType.setup_requirements ||
|
||||
("setup_info" in output && typeof output.setup_info === "object")
|
||||
);
|
||||
}
|
||||
|
||||
export function isRunBlockBlockOutput(
|
||||
output: RunBlockToolOutput,
|
||||
): output is BlockOutputResponse {
|
||||
return output.type === ResponseType.block_output || "block_id" in output;
|
||||
}
|
||||
|
||||
export function isRunBlockErrorOutput(
|
||||
output: RunBlockToolOutput,
|
||||
): output is ErrorResponse {
|
||||
return output.type === ResponseType.error || "error" in output;
|
||||
}
|
||||
|
||||
function parseOutput(output: unknown): RunBlockToolOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object") {
|
||||
const type = (output as { type?: unknown }).type;
|
||||
if (typeof type === "string" && RUN_BLOCK_OUTPUT_TYPES.has(type)) {
|
||||
return output as RunBlockToolOutput;
|
||||
}
|
||||
if ("block_id" in output) return output as BlockOutputResponse;
|
||||
if ("setup_info" in output) return output as SetupRequirementsResponse;
|
||||
if ("error" in output || "details" in output)
|
||||
return output as ErrorResponse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getRunBlockToolOutput(
|
||||
part: unknown,
|
||||
): RunBlockToolOutput | null {
|
||||
if (!part || typeof part !== "object") return null;
|
||||
return parseOutput((part as { output?: unknown }).output);
|
||||
}
|
||||
|
||||
function getBlockLabel(input: RunBlockInput | undefined): string | null {
|
||||
const blockId = input?.block_id?.trim();
|
||||
if (!blockId) return null;
|
||||
return `Block ${blockId.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
export function getAnimationText(part: {
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}): string {
|
||||
const input = part.input as RunBlockInput | undefined;
|
||||
const blockLabel = getBlockLabel(input);
|
||||
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
return "Preparing to run block";
|
||||
case "input-available":
|
||||
return blockLabel ? `Running ${blockLabel}` : "Running block";
|
||||
case "output-available": {
|
||||
const output = parseOutput(part.output);
|
||||
if (!output) return "Block run updated";
|
||||
if (isRunBlockBlockOutput(output))
|
||||
return `Block ran: ${output.block_name}`;
|
||||
if (isRunBlockSetupRequirementsOutput(output)) {
|
||||
return `Needs setup: ${output.setup_info.agent_name}`;
|
||||
}
|
||||
return "Error running block";
|
||||
}
|
||||
case "output-error":
|
||||
return "Error running block";
|
||||
default:
|
||||
return "Processing";
|
||||
}
|
||||
}
|
||||
|
||||
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
|
||||
switch (state) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
className="h-4 w-4 animate-spin text-muted-foreground"
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
case "output-available":
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
|
||||
case "output-error":
|
||||
return <XCircleIcon className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMaybeJson(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
getDocsToolOutput,
|
||||
getDocsToolTitle,
|
||||
getToolLabel,
|
||||
getAnimationText,
|
||||
isDocPageOutput,
|
||||
isDocSearchResultsOutput,
|
||||
isErrorOutput,
|
||||
isNoResultsOutput,
|
||||
StateIcon,
|
||||
toDocsUrl,
|
||||
type DocsToolType,
|
||||
} from "./helpers";
|
||||
|
||||
export interface DocsToolPart {
|
||||
type: DocsToolType;
|
||||
toolCallId: string;
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
part: DocsToolPart;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxChars: number): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
return `${trimmed.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function SearchDocsTool({ part }: Props) {
|
||||
const output = getDocsToolOutput(part);
|
||||
const text = getAnimationText(part);
|
||||
|
||||
const normalized = useMemo(() => {
|
||||
if (!output) return null;
|
||||
const title = getDocsToolTitle(part.type, output);
|
||||
const label = getToolLabel(part.type);
|
||||
return { title, label };
|
||||
}, [output, part.type]);
|
||||
|
||||
const isOutputAvailable = part.state === "output-available" && !!output;
|
||||
|
||||
const docSearchOutput =
|
||||
isOutputAvailable && output && isDocSearchResultsOutput(output)
|
||||
? output
|
||||
: null;
|
||||
const docPageOutput =
|
||||
isOutputAvailable && output && isDocPageOutput(output) ? output : null;
|
||||
const noResultsOutput =
|
||||
isOutputAvailable && output && isNoResultsOutput(output) ? output : null;
|
||||
const errorOutput =
|
||||
isOutputAvailable && output && isErrorOutput(output) ? output : null;
|
||||
|
||||
const hasExpandableContent =
|
||||
isOutputAvailable &&
|
||||
((!!docSearchOutput && docSearchOutput.count > 0) ||
|
||||
!!docPageOutput ||
|
||||
!!noResultsOutput ||
|
||||
!!errorOutput);
|
||||
|
||||
const accordionDescription =
|
||||
hasExpandableContent && docSearchOutput
|
||||
? `Found ${docSearchOutput.count} result${docSearchOutput.count === 1 ? "" : "s"} for "${docSearchOutput.query}"`
|
||||
: hasExpandableContent && docPageOutput
|
||||
? docPageOutput.path
|
||||
: hasExpandableContent && (noResultsOutput || errorOutput)
|
||||
? ((noResultsOutput ?? errorOutput)?.message ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StateIcon state={part.state} />
|
||||
<MorphingTextAnimation text={text} />
|
||||
</div>
|
||||
|
||||
{hasExpandableContent && normalized && (
|
||||
<ToolAccordion
|
||||
badgeText={normalized.label}
|
||||
title={normalized.title}
|
||||
description={accordionDescription}
|
||||
>
|
||||
{docSearchOutput && (
|
||||
<div className="grid gap-2">
|
||||
{docSearchOutput.results.map((r) => {
|
||||
const href = r.doc_url ?? toDocsUrl(r.path);
|
||||
return (
|
||||
<div
|
||||
key={r.path}
|
||||
className="rounded-2xl border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{r.title}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{r.path}
|
||||
{r.section ? ` • ${r.section}` : ""}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{truncate(r.snippet, 240)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{docPageOutput && (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{docPageOutput.title}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{docPageOutput.path}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={docPageOutput.doc_url ?? toDocsUrl(docPageOutput.path)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
|
||||
{truncate(docPageOutput.content, 800)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noResultsOutput && (
|
||||
<div>
|
||||
<p className="text-sm text-foreground">
|
||||
{noResultsOutput.message}
|
||||
</p>
|
||||
{noResultsOutput.suggestions &&
|
||||
noResultsOutput.suggestions.length > 0 && (
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-muted-foreground">
|
||||
{noResultsOutput.suggestions.slice(0, 5).map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorOutput && (
|
||||
<div>
|
||||
<p className="text-sm text-foreground">{errorOutput.message}</p>
|
||||
{errorOutput.error && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{errorOutput.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { DocPageResponse } from "@/app/api/__generated__/models/docPageResponse";
|
||||
import type { DocSearchResultsResponse } from "@/app/api/__generated__/models/docSearchResultsResponse";
|
||||
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
|
||||
import type { NoResultsResponse } from "@/app/api/__generated__/models/noResultsResponse";
|
||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
|
||||
export interface SearchDocsInput {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface GetDocPageInput {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type DocsToolOutput =
|
||||
| DocSearchResultsResponse
|
||||
| DocPageResponse
|
||||
| NoResultsResponse
|
||||
| ErrorResponse;
|
||||
|
||||
export type DocsToolType = "tool-search_docs" | "tool-get_doc_page" | string;
|
||||
|
||||
export function getToolLabel(toolType: DocsToolType): string {
|
||||
switch (toolType) {
|
||||
case "tool-search_docs":
|
||||
return "Docs";
|
||||
case "tool-get_doc_page":
|
||||
return "Docs page";
|
||||
default:
|
||||
return "Docs";
|
||||
}
|
||||
}
|
||||
|
||||
function parseOutput(output: unknown): DocsToolOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object") {
|
||||
const type = (output as { type?: unknown }).type;
|
||||
if (
|
||||
type === ResponseType.doc_search_results ||
|
||||
type === ResponseType.doc_page ||
|
||||
type === ResponseType.no_results ||
|
||||
type === ResponseType.error
|
||||
) {
|
||||
return output as DocsToolOutput;
|
||||
}
|
||||
if ("results" in output && "query" in output)
|
||||
return output as DocSearchResultsResponse;
|
||||
if ("content" in output && "path" in output)
|
||||
return output as DocPageResponse;
|
||||
if ("suggestions" in output && !("error" in output))
|
||||
return output as NoResultsResponse;
|
||||
if ("error" in output || "details" in output)
|
||||
return output as ErrorResponse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDocsToolOutput(part: unknown): DocsToolOutput | null {
|
||||
if (!part || typeof part !== "object") return null;
|
||||
return parseOutput((part as { output?: unknown }).output);
|
||||
}
|
||||
|
||||
export function isDocSearchResultsOutput(
|
||||
output: DocsToolOutput,
|
||||
): output is DocSearchResultsResponse {
|
||||
return output.type === ResponseType.doc_search_results || "results" in output;
|
||||
}
|
||||
|
||||
export function isDocPageOutput(
|
||||
output: DocsToolOutput,
|
||||
): output is DocPageResponse {
|
||||
return output.type === ResponseType.doc_page || "content" in output;
|
||||
}
|
||||
|
||||
export function isNoResultsOutput(
|
||||
output: DocsToolOutput,
|
||||
): output is NoResultsResponse {
|
||||
return (
|
||||
output.type === ResponseType.no_results ||
|
||||
("suggestions" in output && !("error" in output))
|
||||
);
|
||||
}
|
||||
|
||||
export function isErrorOutput(output: DocsToolOutput): output is ErrorResponse {
|
||||
return output.type === ResponseType.error || "error" in output;
|
||||
}
|
||||
|
||||
export function getDocsToolTitle(
|
||||
toolType: DocsToolType,
|
||||
output: DocsToolOutput,
|
||||
): string {
|
||||
if (toolType === "tool-search_docs") {
|
||||
if (isDocSearchResultsOutput(output)) return "Documentation results";
|
||||
if (isNoResultsOutput(output)) return "No documentation found";
|
||||
return "Documentation search error";
|
||||
}
|
||||
|
||||
if (isDocPageOutput(output)) return "Documentation page";
|
||||
if (isNoResultsOutput(output)) return "No documentation found";
|
||||
return "Documentation page error";
|
||||
}
|
||||
|
||||
export function getAnimationText(part: {
|
||||
type: DocsToolType;
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}): string {
|
||||
switch (part.type) {
|
||||
case "tool-search_docs": {
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
return "Searching docs for you";
|
||||
case "input-available": {
|
||||
const query = (
|
||||
part.input as SearchDocsInput | undefined
|
||||
)?.query?.trim();
|
||||
return query ? `Searching docs for "${query}"` : "Searching docs";
|
||||
}
|
||||
case "output-available": {
|
||||
const output = parseOutput(part.output);
|
||||
const query = (
|
||||
part.input as SearchDocsInput | undefined
|
||||
)?.query?.trim();
|
||||
if (!output) return "Found documentation";
|
||||
if (isDocSearchResultsOutput(output)) {
|
||||
const count = output.count ?? output.results.length;
|
||||
return query
|
||||
? `Found ${count} doc result${count === 1 ? "" : "s"} for "${query}"`
|
||||
: `Found ${count} doc result${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (isNoResultsOutput(output)) {
|
||||
return query ? `No docs found for "${query}"` : "No docs found";
|
||||
}
|
||||
return "Error searching docs";
|
||||
}
|
||||
case "output-error":
|
||||
return "Error searching docs";
|
||||
default:
|
||||
return "Processing";
|
||||
}
|
||||
}
|
||||
case "tool-get_doc_page": {
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
return "Loading documentation page";
|
||||
case "input-available": {
|
||||
const path = (
|
||||
part.input as GetDocPageInput | undefined
|
||||
)?.path?.trim();
|
||||
return path ? `Loading "${path}"` : "Loading documentation page";
|
||||
}
|
||||
case "output-available": {
|
||||
const output = parseOutput(part.output);
|
||||
if (!output) return "Loaded documentation page";
|
||||
if (isDocPageOutput(output)) return `Loaded "${output.title}"`;
|
||||
if (isNoResultsOutput(output)) return "Documentation page not found";
|
||||
return "Error loading documentation page";
|
||||
}
|
||||
case "output-error":
|
||||
return "Error loading documentation page";
|
||||
default:
|
||||
return "Processing";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Processing";
|
||||
}
|
||||
|
||||
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
|
||||
switch (state) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
className="h-4 w-4 animate-spin text-muted-foreground"
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
case "output-available":
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
|
||||
case "output-error":
|
||||
return <XCircleIcon className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function toDocsUrl(path: string): string {
|
||||
const urlPath = path.includes(".")
|
||||
? path.slice(0, path.lastIndexOf("."))
|
||||
: path;
|
||||
return `https://docs.agpt.co/${urlPath}`;
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import Link from "next/link";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
formatMaybeJson,
|
||||
getAnimationText,
|
||||
getViewAgentOutputToolOutput,
|
||||
isAgentOutputResponse,
|
||||
isErrorResponse,
|
||||
isNoResultsResponse,
|
||||
StateIcon,
|
||||
type ViewAgentOutputToolOutput,
|
||||
} from "./helpers";
|
||||
|
||||
export interface ViewAgentOutputToolPart {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
part: ViewAgentOutputToolPart;
|
||||
}
|
||||
|
||||
function getAccordionMeta(output: ViewAgentOutputToolOutput): {
|
||||
badgeText: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
} {
|
||||
if (isAgentOutputResponse(output)) {
|
||||
const status = output.execution?.status;
|
||||
return {
|
||||
badgeText: "Agent output",
|
||||
title: output.agent_name,
|
||||
description: status ? `Status: ${status}` : output.message,
|
||||
};
|
||||
}
|
||||
if (isNoResultsResponse(output)) {
|
||||
return { badgeText: "Agent output", title: "No results" };
|
||||
}
|
||||
return { badgeText: "Agent output", title: "Error" };
|
||||
}
|
||||
|
||||
export function ViewAgentOutputTool({ part }: Props) {
|
||||
const text = getAnimationText(part);
|
||||
|
||||
const output = getViewAgentOutputToolOutput(part);
|
||||
const hasExpandableContent =
|
||||
part.state === "output-available" &&
|
||||
!!output &&
|
||||
(isAgentOutputResponse(output) ||
|
||||
isNoResultsResponse(output) ||
|
||||
isErrorResponse(output));
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StateIcon state={part.state} />
|
||||
<MorphingTextAnimation text={text} />
|
||||
</div>
|
||||
|
||||
{hasExpandableContent && output && (
|
||||
<ToolAccordion {...getAccordionMeta(output)}>
|
||||
{isAgentOutputResponse(output) && (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
{output.library_agent_link && (
|
||||
<Link
|
||||
href={output.library_agent_link}
|
||||
className="shrink-0 text-xs font-medium text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{output.execution ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
Execution
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{output.execution.execution_id}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Status: {output.execution.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{output.execution.inputs_summary && (
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
Inputs summary
|
||||
</p>
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.execution.inputs_summary)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(output.execution.outputs ?? {}).map(
|
||||
([key, items]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-2xl border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{key}
|
||||
</p>
|
||||
<span className="shrink-0 rounded-full border bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{items.length} item{items.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
|
||||
{formatMaybeJson(items.slice(0, 3))}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border bg-background p-3">
|
||||
<p className="text-sm text-foreground">
|
||||
No execution selected.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Try asking for a specific run or execution_id.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNoResultsResponse(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
{output.suggestions && output.suggestions.length > 0 && (
|
||||
<ul className="mt-1 list-disc space-y-1 pl-5 text-xs text-muted-foreground">
|
||||
{output.suggestions.slice(0, 5).map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isErrorResponse(output) && (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm text-foreground">{output.message}</p>
|
||||
{output.error && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.error)}
|
||||
</pre>
|
||||
)}
|
||||
{output.details && (
|
||||
<pre className="whitespace-pre-wrap rounded-2xl border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
{formatMaybeJson(output.details)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ToolAccordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentOutputResponse } from "@/app/api/__generated__/models/agentOutputResponse";
|
||||
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
|
||||
import type { NoResultsResponse } from "@/app/api/__generated__/models/noResultsResponse";
|
||||
import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
|
||||
export interface ViewAgentOutputInput {
|
||||
agent_name?: string;
|
||||
library_agent_id?: string;
|
||||
store_slug?: string;
|
||||
execution_id?: string;
|
||||
run_time?: string;
|
||||
}
|
||||
|
||||
export type ViewAgentOutputToolOutput =
|
||||
| AgentOutputResponse
|
||||
| NoResultsResponse
|
||||
| ErrorResponse;
|
||||
|
||||
function parseOutput(output: unknown): ViewAgentOutputToolOutput | null {
|
||||
if (!output) return null;
|
||||
if (typeof output === "string") {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return parseOutput(JSON.parse(trimmed) as unknown);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof output === "object") {
|
||||
const type = (output as { type?: unknown }).type;
|
||||
if (
|
||||
type === ResponseType.agent_output ||
|
||||
type === ResponseType.no_results ||
|
||||
type === ResponseType.error
|
||||
) {
|
||||
return output as ViewAgentOutputToolOutput;
|
||||
}
|
||||
if ("agent_id" in output && "agent_name" in output) {
|
||||
return output as AgentOutputResponse;
|
||||
}
|
||||
if ("suggestions" in output && !("error" in output)) {
|
||||
return output as NoResultsResponse;
|
||||
}
|
||||
if ("error" in output || "details" in output)
|
||||
return output as ErrorResponse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isAgentOutputResponse(
|
||||
output: ViewAgentOutputToolOutput,
|
||||
): output is AgentOutputResponse {
|
||||
return output.type === ResponseType.agent_output || "agent_id" in output;
|
||||
}
|
||||
|
||||
export function isNoResultsResponse(
|
||||
output: ViewAgentOutputToolOutput,
|
||||
): output is NoResultsResponse {
|
||||
return (
|
||||
output.type === ResponseType.no_results ||
|
||||
("suggestions" in output && !("error" in output))
|
||||
);
|
||||
}
|
||||
|
||||
export function isErrorResponse(
|
||||
output: ViewAgentOutputToolOutput,
|
||||
): output is ErrorResponse {
|
||||
return output.type === ResponseType.error || "error" in output;
|
||||
}
|
||||
|
||||
export function getViewAgentOutputToolOutput(
|
||||
part: unknown,
|
||||
): ViewAgentOutputToolOutput | null {
|
||||
if (!part || typeof part !== "object") return null;
|
||||
return parseOutput((part as { output?: unknown }).output);
|
||||
}
|
||||
|
||||
function getAgentIdentifierText(
|
||||
input: ViewAgentOutputInput | undefined,
|
||||
): string | null {
|
||||
if (!input) return null;
|
||||
const libraryId = input.library_agent_id?.trim();
|
||||
if (libraryId) return `Library agent ${libraryId}`;
|
||||
const slug = input.store_slug?.trim();
|
||||
if (slug) return slug;
|
||||
const name = input.agent_name?.trim();
|
||||
if (name) return name;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAnimationText(part: {
|
||||
state: ToolUIPart["state"];
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
}): string {
|
||||
const input = part.input as ViewAgentOutputInput | undefined;
|
||||
const agent = getAgentIdentifierText(input);
|
||||
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
return "Looking up agent outputs";
|
||||
case "input-available":
|
||||
return agent ? `Loading outputs: ${agent}` : "Loading agent outputs";
|
||||
case "output-available": {
|
||||
const output = parseOutput(part.output);
|
||||
if (!output) return "Loaded agent outputs";
|
||||
if (isAgentOutputResponse(output)) {
|
||||
if (output.execution)
|
||||
return `Loaded output (${output.execution.status})`;
|
||||
return "Loaded agent outputs";
|
||||
}
|
||||
if (isNoResultsResponse(output)) return "No outputs found";
|
||||
return "Error loading agent output";
|
||||
}
|
||||
case "output-error":
|
||||
return "Error loading agent output";
|
||||
default:
|
||||
return "Processing";
|
||||
}
|
||||
}
|
||||
|
||||
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
|
||||
switch (state) {
|
||||
case "input-streaming":
|
||||
case "input-available":
|
||||
return (
|
||||
<CircleNotchIcon
|
||||
className="h-4 w-4 animate-spin text-muted-foreground"
|
||||
weight="bold"
|
||||
/>
|
||||
);
|
||||
case "output-available":
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
|
||||
case "output-error":
|
||||
return <XCircleIcon className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMaybeJson(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useRef } from "react";
|
||||
import { useCopilotStore } from "../../copilot-page-store";
|
||||
import { useCopilotSessionId } from "../../useCopilotSessionId";
|
||||
import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer";
|
||||
@@ -69,16 +70,41 @@ export function useCopilotShell() {
|
||||
});
|
||||
|
||||
const stopStream = useChatStore((s) => s.stopStream);
|
||||
const onStreamComplete = useChatStore((s) => s.onStreamComplete);
|
||||
const isStreaming = useCopilotStore((s) => s.isStreaming);
|
||||
const isCreatingSession = useCopilotStore((s) => s.isCreatingSession);
|
||||
const setIsSwitchingSession = useCopilotStore((s) => s.setIsSwitchingSession);
|
||||
const openInterruptModal = useCopilotStore((s) => s.openInterruptModal);
|
||||
|
||||
function handleSessionClick(sessionId: string) {
|
||||
if (sessionId === currentSessionId) return;
|
||||
const pendingActionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Stop current stream - SSE reconnection allows resuming later
|
||||
if (currentSessionId) {
|
||||
async function stopCurrentStream() {
|
||||
if (!currentSessionId) return;
|
||||
|
||||
setIsSwitchingSession(true);
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = onStreamComplete((completedId) => {
|
||||
if (completedId === currentSessionId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}, 3000);
|
||||
stopStream(currentSessionId);
|
||||
}
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(currentSessionId),
|
||||
});
|
||||
setIsSwitchingSession(false);
|
||||
}
|
||||
|
||||
function selectSession(sessionId: string) {
|
||||
if (sessionId === currentSessionId) return;
|
||||
if (recentlyCreatedSessionsRef.current.has(sessionId)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(sessionId),
|
||||
@@ -88,12 +114,7 @@ export function useCopilotShell() {
|
||||
if (isMobile) handleCloseDrawer();
|
||||
}
|
||||
|
||||
function handleNewChatClick() {
|
||||
// Stop current stream - SSE reconnection allows resuming later
|
||||
if (currentSessionId) {
|
||||
stopStream(currentSessionId);
|
||||
}
|
||||
|
||||
function startNewChat() {
|
||||
resetPagination();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
@@ -102,6 +123,32 @@ export function useCopilotShell() {
|
||||
if (isMobile) handleCloseDrawer();
|
||||
}
|
||||
|
||||
function handleSessionClick(sessionId: string) {
|
||||
if (sessionId === currentSessionId) return;
|
||||
|
||||
if (isStreaming) {
|
||||
pendingActionRef.current = async () => {
|
||||
await stopCurrentStream();
|
||||
selectSession(sessionId);
|
||||
};
|
||||
openInterruptModal(pendingActionRef.current);
|
||||
} else {
|
||||
selectSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewChatClick() {
|
||||
if (isStreaming) {
|
||||
pendingActionRef.current = async () => {
|
||||
await stopCurrentStream();
|
||||
startNewChat();
|
||||
};
|
||||
openInterruptModal(pendingActionRef.current);
|
||||
} else {
|
||||
startNewChat();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isDrawerOpen,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { environment } from "@/services/environment";
|
||||
import { getServerAuthToken } from "@/lib/autogpt-server-api/helpers";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* SSE Proxy for task stream reconnection.
|
||||
*
|
||||
* This endpoint allows clients to reconnect to an ongoing or recently completed
|
||||
* background task's stream. It replays missed messages from Redis Streams and
|
||||
* subscribes to live updates if the task is still running.
|
||||
*
|
||||
* Client contract:
|
||||
* 1. When receiving an operation_started event, store the task_id
|
||||
* 2. To reconnect: GET /api/chat/tasks/{taskId}/stream?last_message_id={idx}
|
||||
* 3. Messages are replayed from the last_message_id position
|
||||
* 4. Stream ends when "finish" event is received
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ taskId: string }> },
|
||||
) {
|
||||
const { taskId } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const lastMessageId = searchParams.get("last_message_id") || "0-0";
|
||||
|
||||
try {
|
||||
// Get auth token from server-side session
|
||||
const token = await getServerAuthToken();
|
||||
|
||||
// Build backend URL
|
||||
const backendUrl = environment.getAGPTServerBaseUrl();
|
||||
const streamUrl = new URL(`/api/chat/tasks/${taskId}/stream`, backendUrl);
|
||||
streamUrl.searchParams.set("last_message_id", lastMessageId);
|
||||
|
||||
// Forward request to backend with auth header
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(streamUrl.toString(), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return new Response(error, {
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Return the SSE stream directly
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Task stream proxy error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to connect to task stream",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -917,28 +917,6 @@
|
||||
"security": [{ "HTTPBearerJWT": [] }]
|
||||
}
|
||||
},
|
||||
"/api/chat/config/ttl": {
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Get Ttl Config",
|
||||
"description": "Get the stream TTL configuration.\n\nReturns the Time-To-Live settings for chat streams, which determines\nhow long clients can reconnect to an active stream.\n\nReturns:\n dict: TTL configuration with seconds and milliseconds values.",
|
||||
"operationId": "getV2GetTtlConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object",
|
||||
"title": "Response Getv2Getttlconfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/chat/health": {
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
@@ -961,63 +939,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/chat/operations/{operation_id}/complete": {
|
||||
"post": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Complete Operation",
|
||||
"description": "External completion webhook for long-running operations.\n\nCalled by Agent Generator (or other services) when an operation completes.\nThis triggers the stream registry to publish completion and continue LLM generation.\n\nArgs:\n operation_id: The operation ID to complete.\n request: Completion payload with success status and result/error.\n x_api_key: Internal API key for authentication.\n\nReturns:\n dict: Status of the completion.\n\nRaises:\n HTTPException: If API key is invalid or operation not found.",
|
||||
"operationId": "postV2CompleteOperation",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "operation_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "Operation Id" }
|
||||
},
|
||||
{
|
||||
"name": "x-api-key",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "X-Api-Key"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OperationCompleteRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"title": "Response Postv2Completeoperation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/chat/sessions": {
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
@@ -1101,7 +1022,7 @@
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Get Session",
|
||||
"description": "Retrieve the details of a specific chat session.\n\nLooks up a chat session by ID for the given user (if authenticated) and returns all session data including messages.\nIf there's an active stream for this session, returns the task_id for reconnection.\n\nArgs:\n session_id: The unique identifier for the desired chat session.\n user_id: The optional authenticated user ID, or None for anonymous access.\n\nReturns:\n SessionDetailResponse: Details for the requested session, including active_stream info if applicable.",
|
||||
"description": "Retrieve the details of a specific chat session.\n\nLooks up a chat session by ID for the given user (if authenticated) and returns all session data including messages.\n\nArgs:\n session_id: The unique identifier for the desired chat session.\n user_id: The optional authenticated user ID, or None for anonymous access.\n\nReturns:\n SessionDetailResponse: Details for the requested session, or None if not found.",
|
||||
"operationId": "getV2GetSession",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
@@ -1236,7 +1157,7 @@
|
||||
"post": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Stream Chat Post",
|
||||
"description": "Stream chat responses for a session (POST with context support).\n\nStreams the AI/completion responses in real time over Server-Sent Events (SSE), including:\n - Text fragments as they are generated\n - Tool call UI elements (if invoked)\n - Tool execution results\n\nThe AI generation runs in a background task that continues even if the client disconnects.\nAll chunks are written to Redis for reconnection support. If the client disconnects,\nthey can reconnect using GET /tasks/{task_id}/stream to resume from where they left off.\n\nArgs:\n session_id: The chat session identifier to associate with the streamed messages.\n request: Request body containing message, is_user_message, and optional context.\n user_id: Optional authenticated user ID.\nReturns:\n StreamingResponse: SSE-formatted response chunks. First chunk is a \"start\" event\n containing the task_id for reconnection.",
|
||||
"description": "Stream chat responses for a session (POST with context support).\n\nStreams the AI/completion responses in real time over Server-Sent Events (SSE), including:\n - Text fragments as they are generated\n - Tool call UI elements (if invoked)\n - Tool execution results\n\nArgs:\n session_id: The chat session identifier to associate with the streamed messages.\n request: Request body containing message, is_user_message, and optional context.\n user_id: Optional authenticated user ID.\nReturns:\n StreamingResponse: SSE-formatted response chunks.",
|
||||
"operationId": "postV2StreamChatPost",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
@@ -1274,94 +1195,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/chat/tasks/{task_id}": {
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Get Task Status",
|
||||
"description": "Get the status of a long-running task.\n\nArgs:\n task_id: The task ID to check.\n user_id: Authenticated user ID for ownership validation.\n\nReturns:\n dict: Task status including task_id, status, tool_name, and operation_id.\n\nRaises:\n NotFoundError: If task_id is not found or user doesn't have access.",
|
||||
"operationId": "getV2GetTaskStatus",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "task_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "Task Id" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"title": "Response Getv2Gettaskstatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/chat/tasks/{task_id}/stream": {
|
||||
"get": {
|
||||
"tags": ["v2", "chat", "chat"],
|
||||
"summary": "Stream Task",
|
||||
"description": "Reconnect to a long-running task's SSE stream.\n\nWhen a long-running operation (like agent generation) starts, the client\nreceives a task_id. If the connection drops, the client can reconnect\nusing this endpoint to resume receiving updates.\n\nArgs:\n task_id: The task ID from the operation_started response.\n user_id: Authenticated user ID for ownership validation.\n last_message_id: Last Redis Stream message ID received (\"0-0\" for full replay).\n\nReturns:\n StreamingResponse: SSE-formatted response chunks starting after last_message_id.\n\nRaises:\n HTTPException: 404 if task not found, 410 if task expired, 403 if access denied.",
|
||||
"operationId": "getV2StreamTask",
|
||||
"security": [{ "HTTPBearerJWT": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "task_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string", "title": "Task Id" }
|
||||
},
|
||||
{
|
||||
"name": "last_message_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Last Redis Stream message ID received (e.g., '1706540123456-0'). Use '0-0' for full replay.",
|
||||
"default": "0-0",
|
||||
"title": "Last Message Id"
|
||||
},
|
||||
"description": "Last Redis Stream message ID received (e.g., '1706540123456-0'). Use '0-0' for full replay."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": { "application/json": { "schema": {} } }
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credits": {
|
||||
"get": {
|
||||
"tags": ["v1", "credits"],
|
||||
@@ -6335,18 +6168,6 @@
|
||||
"title": "AccuracyTrendsResponse",
|
||||
"description": "Response model for accuracy trends and alerts."
|
||||
},
|
||||
"ActiveStreamInfo": {
|
||||
"properties": {
|
||||
"task_id": { "type": "string", "title": "Task Id" },
|
||||
"last_message_id": { "type": "string", "title": "Last Message Id" },
|
||||
"operation_id": { "type": "string", "title": "Operation Id" },
|
||||
"tool_name": { "type": "string", "title": "Tool Name" }
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["task_id", "last_message_id", "operation_id", "tool_name"],
|
||||
"title": "ActiveStreamInfo",
|
||||
"description": "Information about an active stream for reconnection."
|
||||
},
|
||||
"AddUserCreditsResponse": {
|
||||
"properties": {
|
||||
"new_balance": { "type": "integer", "title": "New Balance" },
|
||||
@@ -9002,27 +8823,6 @@
|
||||
],
|
||||
"title": "OnboardingStep"
|
||||
},
|
||||
"OperationCompleteRequest": {
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "title": "Success" },
|
||||
"result": {
|
||||
"anyOf": [
|
||||
{ "additionalProperties": true, "type": "object" },
|
||||
{ "type": "string" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Result"
|
||||
},
|
||||
"error": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||
"title": "Error"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["success"],
|
||||
"title": "OperationCompleteRequest",
|
||||
"description": "Request model for external completion webhook."
|
||||
},
|
||||
"Pagination": {
|
||||
"properties": {
|
||||
"total_items": {
|
||||
@@ -9878,12 +9678,6 @@
|
||||
"items": { "additionalProperties": true, "type": "object" },
|
||||
"type": "array",
|
||||
"title": "Messages"
|
||||
},
|
||||
"active_stream": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/components/schemas/ActiveStreamInfo" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@source "../node_modules/streamdown/dist/*.js";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@@ -30,14 +29,6 @@
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -65,14 +56,6 @@
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && (
|
||||
<div className="text-neutral-500 dark:text-neutral-400">{icon}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-white dark:dark:bg-neutral-950 dark:dark:hover:bg-neutral-800 dark:hover:bg-neutral-100",
|
||||
className,
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,338 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[95%] flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-full min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:w-fit",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-neutral-100 group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-neutral-950 dark:group-[.is-user]:bg-neutral-800 dark:group-[.is-user]:text-neutral-50",
|
||||
"group-[.is-assistant]:text-neutral-950 dark:group-[.is-assistant]:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
interface MessageBranchContextType {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
}
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("MessageBranch components must be used within");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden",
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from: _from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className={cn(
|
||||
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||
className,
|
||||
)}
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-neutral-500 shadow-none dark:text-neutral-400",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className,
|
||||
)}
|
||||
plugins={{ code, mermaid, math, cjk }}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children,
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCopilotSessionId } from "@/app/(platform)/copilot/useCopilotSessionId";
|
||||
import { useCopilotStore } from "@/app/(platform)/copilot/copilot-page-store";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -24,8 +25,8 @@ export function Chat({
|
||||
}: ChatProps) {
|
||||
const { urlSessionId } = useCopilotSessionId();
|
||||
const hasHandledNotFoundRef = useRef(false);
|
||||
const isSwitchingSession = useCopilotStore((s) => s.isSwitchingSession);
|
||||
const {
|
||||
session,
|
||||
messages,
|
||||
isLoading,
|
||||
isCreating,
|
||||
@@ -37,18 +38,6 @@ export function Chat({
|
||||
startPollingForOperation,
|
||||
} = useChat({ urlSessionId });
|
||||
|
||||
// Extract active stream info for reconnection
|
||||
const activeStream = (
|
||||
session as {
|
||||
active_stream?: {
|
||||
task_id: string;
|
||||
last_message_id: string;
|
||||
operation_id: string;
|
||||
tool_name: string;
|
||||
};
|
||||
}
|
||||
)?.active_stream;
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSessionNotFound) return;
|
||||
if (!urlSessionId) return;
|
||||
@@ -64,7 +53,8 @@ export function Chat({
|
||||
isCreating,
|
||||
]);
|
||||
|
||||
const shouldShowLoader = showLoader && (isLoading || isCreating);
|
||||
const shouldShowLoader =
|
||||
(showLoader && (isLoading || isCreating)) || isSwitchingSession;
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
@@ -76,19 +66,21 @@ export function Chat({
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<LoadingSpinner size="large" className="text-neutral-400" />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chat...
|
||||
{isSwitchingSession
|
||||
? "Switching chat..."
|
||||
: "Loading your chat..."}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
{error && !isLoading && !isSwitchingSession && (
|
||||
<ChatErrorState error={error} onRetry={createSession} />
|
||||
)}
|
||||
|
||||
{/* Session Content */}
|
||||
{sessionId && !isLoading && !error && (
|
||||
{sessionId && !isLoading && !error && !isSwitchingSession && (
|
||||
<ChatContainer
|
||||
sessionId={sessionId}
|
||||
initialMessages={messages}
|
||||
@@ -96,16 +88,6 @@ export function Chat({
|
||||
className="flex-1"
|
||||
onStreamingChange={onStreamingChange}
|
||||
onOperationStarted={startPollingForOperation}
|
||||
activeStream={
|
||||
activeStream
|
||||
? {
|
||||
taskId: activeStream.task_id,
|
||||
lastMessageId: activeStream.last_message_id,
|
||||
operationId: activeStream.operation_id,
|
||||
toolName: activeStream.tool_name,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
# SSE Reconnection Contract for Long-Running Operations
|
||||
|
||||
This document describes the client-side contract for handling SSE (Server-Sent Events) disconnections and reconnecting to long-running background tasks.
|
||||
|
||||
## Overview
|
||||
|
||||
When a user triggers a long-running operation (like agent generation), the backend:
|
||||
|
||||
1. Spawns a background task that survives SSE disconnections
|
||||
2. Returns an `operation_started` response with a `task_id`
|
||||
3. Stores stream messages in Redis Streams for replay
|
||||
|
||||
Clients can reconnect to the task stream at any time to receive missed messages.
|
||||
|
||||
## Client-Side Flow
|
||||
|
||||
### 1. Receiving Operation Started
|
||||
|
||||
When you receive an `operation_started` tool response:
|
||||
|
||||
```typescript
|
||||
// The response includes a task_id for reconnection
|
||||
{
|
||||
type: "operation_started",
|
||||
tool_name: "generate_agent",
|
||||
operation_id: "uuid-...",
|
||||
task_id: "task-uuid-...", // <-- Store this for reconnection
|
||||
message: "Operation started. You can close this tab."
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Storing Task Info
|
||||
|
||||
Use the chat store to track the active task:
|
||||
|
||||
```typescript
|
||||
import { useChatStore } from "./chat-store";
|
||||
|
||||
// When operation_started is received:
|
||||
useChatStore.getState().setActiveTask(sessionId, {
|
||||
taskId: response.task_id,
|
||||
operationId: response.operation_id,
|
||||
toolName: response.tool_name,
|
||||
lastMessageId: "0",
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Reconnecting to a Task
|
||||
|
||||
To reconnect (e.g., after page refresh or tab reopen):
|
||||
|
||||
```typescript
|
||||
const { reconnectToTask, getActiveTask } = useChatStore.getState();
|
||||
|
||||
// Check if there's an active task for this session
|
||||
const activeTask = getActiveTask(sessionId);
|
||||
|
||||
if (activeTask) {
|
||||
// Reconnect to the task stream
|
||||
await reconnectToTask(
|
||||
sessionId,
|
||||
activeTask.taskId,
|
||||
activeTask.lastMessageId, // Resume from last position
|
||||
(chunk) => {
|
||||
// Handle incoming chunks
|
||||
console.log("Received chunk:", chunk);
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Tracking Message Position
|
||||
|
||||
To enable precise replay, update the last message ID as chunks arrive:
|
||||
|
||||
```typescript
|
||||
const { updateTaskLastMessageId } = useChatStore.getState();
|
||||
|
||||
function handleChunk(chunk: StreamChunk) {
|
||||
// If chunk has an index/id, track it
|
||||
if (chunk.idx !== undefined) {
|
||||
updateTaskLastMessageId(sessionId, String(chunk.idx));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Task Stream Reconnection
|
||||
|
||||
```
|
||||
GET /api/chat/tasks/{taskId}/stream?last_message_id={idx}
|
||||
```
|
||||
|
||||
- `taskId`: The task ID from `operation_started`
|
||||
- `last_message_id`: Last received message index (default: "0" for full replay)
|
||||
|
||||
Returns: SSE stream of missed messages + live updates
|
||||
|
||||
## Chunk Types
|
||||
|
||||
The reconnected stream follows the same Vercel AI SDK protocol:
|
||||
|
||||
| Type | Description |
|
||||
| ----------------------- | ----------------------- |
|
||||
| `start` | Message lifecycle start |
|
||||
| `text-delta` | Streaming text content |
|
||||
| `text-end` | Text block completed |
|
||||
| `tool-output-available` | Tool result available |
|
||||
| `finish` | Stream completed |
|
||||
| `error` | Error occurred |
|
||||
|
||||
## Error Handling
|
||||
|
||||
If reconnection fails:
|
||||
|
||||
1. Check if task still exists (may have expired - default TTL: 1 hour)
|
||||
2. Fall back to polling the session for final state
|
||||
3. Show appropriate UI message to user
|
||||
|
||||
## Persistence Considerations
|
||||
|
||||
For robust reconnection across browser restarts:
|
||||
|
||||
```typescript
|
||||
// Store in localStorage/sessionStorage
|
||||
const ACTIVE_TASKS_KEY = "chat_active_tasks";
|
||||
|
||||
function persistActiveTask(sessionId: string, task: ActiveTaskInfo) {
|
||||
const tasks = JSON.parse(localStorage.getItem(ACTIVE_TASKS_KEY) || "{}");
|
||||
tasks[sessionId] = task;
|
||||
localStorage.setItem(ACTIVE_TASKS_KEY, JSON.stringify(tasks));
|
||||
}
|
||||
|
||||
function loadPersistedTasks(): Record<string, ActiveTaskInfo> {
|
||||
return JSON.parse(localStorage.getItem(ACTIVE_TASKS_KEY) || "{}");
|
||||
}
|
||||
```
|
||||
|
||||
## Backend Configuration
|
||||
|
||||
The following backend settings affect reconnection behavior:
|
||||
|
||||
| Setting | Default | Description |
|
||||
| ------------------- | ------- | ---------------------------------- |
|
||||
| `stream_ttl` | 3600s | How long streams are kept in Redis |
|
||||
| `stream_max_length` | 1000 | Max messages per stream |
|
||||
|
||||
## Testing
|
||||
|
||||
To test reconnection locally:
|
||||
|
||||
1. Start a long-running operation (e.g., agent generation)
|
||||
2. Note the `task_id` from the `operation_started` response
|
||||
3. Close the browser tab
|
||||
4. Reopen and call `reconnectToTask` with the saved `task_id`
|
||||
5. Verify that missed messages are replayed
|
||||
|
||||
See the main README for full local development setup.
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Constants for the chat system.
|
||||
*
|
||||
* Centralizes magic strings and values used across chat components.
|
||||
*/
|
||||
|
||||
// LocalStorage keys
|
||||
export const STORAGE_KEY_ACTIVE_TASKS = "chat_active_tasks";
|
||||
|
||||
// Redis Stream IDs
|
||||
export const INITIAL_MESSAGE_ID = "0";
|
||||
export const INITIAL_STREAM_ID = "0-0";
|
||||
|
||||
// TTL values (in milliseconds)
|
||||
export const COMPLETED_STREAM_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
export const ACTIVE_TASK_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
ACTIVE_TASK_TTL_MS,
|
||||
COMPLETED_STREAM_TTL_MS,
|
||||
INITIAL_STREAM_ID,
|
||||
STORAGE_KEY_ACTIVE_TASKS,
|
||||
} from "./chat-constants";
|
||||
import type {
|
||||
ActiveStream,
|
||||
StreamChunk,
|
||||
@@ -14,59 +8,15 @@ import type {
|
||||
StreamResult,
|
||||
StreamStatus,
|
||||
} from "./chat-types";
|
||||
import { executeStream, executeTaskReconnect } from "./stream-executor";
|
||||
import { executeStream } from "./stream-executor";
|
||||
|
||||
export interface ActiveTaskInfo {
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
lastMessageId: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/** Load active tasks from localStorage */
|
||||
function loadPersistedTasks(): Map<string, ActiveTaskInfo> {
|
||||
if (typeof window === "undefined") return new Map();
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_ACTIVE_TASKS);
|
||||
if (!stored) return new Map();
|
||||
const parsed = JSON.parse(stored) as Record<string, ActiveTaskInfo>;
|
||||
const now = Date.now();
|
||||
const tasks = new Map<string, ActiveTaskInfo>();
|
||||
// Filter out expired tasks
|
||||
for (const [sessionId, task] of Object.entries(parsed)) {
|
||||
if (now - task.startedAt < ACTIVE_TASK_TTL_MS) {
|
||||
tasks.set(sessionId, task);
|
||||
}
|
||||
}
|
||||
return tasks;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/** Save active tasks to localStorage */
|
||||
function persistTasks(tasks: Map<string, ActiveTaskInfo>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const obj: Record<string, ActiveTaskInfo> = {};
|
||||
for (const [sessionId, task] of tasks) {
|
||||
obj[sessionId] = task;
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY_ACTIVE_TASKS, JSON.stringify(obj));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
const COMPLETED_STREAM_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface ChatStoreState {
|
||||
activeStreams: Map<string, ActiveStream>;
|
||||
completedStreams: Map<string, StreamResult>;
|
||||
activeSessions: Set<string>;
|
||||
streamCompleteCallbacks: Set<StreamCompleteCallback>;
|
||||
/** Active tasks for SSE reconnection - keyed by sessionId */
|
||||
activeTasks: Map<string, ActiveTaskInfo>;
|
||||
}
|
||||
|
||||
interface ChatStoreActions {
|
||||
@@ -91,24 +41,6 @@ interface ChatStoreActions {
|
||||
unregisterActiveSession: (sessionId: string) => void;
|
||||
isSessionActive: (sessionId: string) => boolean;
|
||||
onStreamComplete: (callback: StreamCompleteCallback) => () => void;
|
||||
/** Track active task for SSE reconnection */
|
||||
setActiveTask: (
|
||||
sessionId: string,
|
||||
taskInfo: Omit<ActiveTaskInfo, "sessionId" | "startedAt">,
|
||||
) => void;
|
||||
/** Get active task for a session */
|
||||
getActiveTask: (sessionId: string) => ActiveTaskInfo | undefined;
|
||||
/** Clear active task when operation completes */
|
||||
clearActiveTask: (sessionId: string) => void;
|
||||
/** Reconnect to an existing task stream */
|
||||
reconnectToTask: (
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
lastMessageId?: string,
|
||||
onChunk?: (chunk: StreamChunk) => void,
|
||||
) => Promise<void>;
|
||||
/** Update last message ID for a task (for tracking replay position) */
|
||||
updateTaskLastMessageId: (sessionId: string, lastMessageId: string) => void;
|
||||
}
|
||||
|
||||
type ChatStore = ChatStoreState & ChatStoreActions;
|
||||
@@ -132,126 +64,18 @@ function cleanupExpiredStreams(
|
||||
const now = Date.now();
|
||||
const cleaned = new Map(completedStreams);
|
||||
for (const [sessionId, result] of cleaned) {
|
||||
if (now - result.completedAt > COMPLETED_STREAM_TTL_MS) {
|
||||
if (now - result.completedAt > COMPLETED_STREAM_TTL) {
|
||||
cleaned.delete(sessionId);
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a stream by moving it from activeStreams to completedStreams.
|
||||
* Also handles cleanup and notifications.
|
||||
*/
|
||||
function finalizeStream(
|
||||
sessionId: string,
|
||||
stream: ActiveStream,
|
||||
onChunk: ((chunk: StreamChunk) => void) | undefined,
|
||||
get: () => ChatStoreState & ChatStoreActions,
|
||||
set: (state: Partial<ChatStoreState>) => void,
|
||||
): void {
|
||||
if (onChunk) stream.onChunkCallbacks.delete(onChunk);
|
||||
|
||||
if (stream.status !== "streaming") {
|
||||
const currentState = get();
|
||||
const finalActiveStreams = new Map(currentState.activeStreams);
|
||||
let finalCompletedStreams = new Map(currentState.completedStreams);
|
||||
|
||||
const storedStream = finalActiveStreams.get(sessionId);
|
||||
if (storedStream === stream) {
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: stream.status,
|
||||
chunks: stream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: stream.error,
|
||||
};
|
||||
finalCompletedStreams.set(sessionId, result);
|
||||
finalActiveStreams.delete(sessionId);
|
||||
finalCompletedStreams = cleanupExpiredStreams(finalCompletedStreams);
|
||||
set({
|
||||
activeStreams: finalActiveStreams,
|
||||
completedStreams: finalCompletedStreams,
|
||||
});
|
||||
|
||||
if (stream.status === "completed" || stream.status === "error") {
|
||||
notifyStreamComplete(currentState.streamCompleteCallbacks, sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up an existing stream for a session and move it to completed streams.
|
||||
* Returns updated maps for both active and completed streams.
|
||||
*/
|
||||
function cleanupExistingStream(
|
||||
sessionId: string,
|
||||
activeStreams: Map<string, ActiveStream>,
|
||||
completedStreams: Map<string, StreamResult>,
|
||||
callbacks: Set<StreamCompleteCallback>,
|
||||
): {
|
||||
activeStreams: Map<string, ActiveStream>;
|
||||
completedStreams: Map<string, StreamResult>;
|
||||
} {
|
||||
const newActiveStreams = new Map(activeStreams);
|
||||
let newCompletedStreams = new Map(completedStreams);
|
||||
|
||||
const existingStream = newActiveStreams.get(sessionId);
|
||||
if (existingStream) {
|
||||
existingStream.abortController.abort();
|
||||
const normalizedStatus =
|
||||
existingStream.status === "streaming"
|
||||
? "completed"
|
||||
: existingStream.status;
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: normalizedStatus,
|
||||
chunks: existingStream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: existingStream.error,
|
||||
};
|
||||
newCompletedStreams.set(sessionId, result);
|
||||
newActiveStreams.delete(sessionId);
|
||||
newCompletedStreams = cleanupExpiredStreams(newCompletedStreams);
|
||||
if (normalizedStatus === "completed" || normalizedStatus === "error") {
|
||||
notifyStreamComplete(callbacks, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new active stream with initial state.
|
||||
*/
|
||||
function createActiveStream(
|
||||
sessionId: string,
|
||||
onChunk?: (chunk: StreamChunk) => void,
|
||||
): ActiveStream {
|
||||
const abortController = new AbortController();
|
||||
const initialCallbacks = new Set<(chunk: StreamChunk) => void>();
|
||||
if (onChunk) initialCallbacks.add(onChunk);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
abortController,
|
||||
status: "streaming",
|
||||
startedAt: Date.now(),
|
||||
chunks: [],
|
||||
onChunkCallbacks: initialCallbacks,
|
||||
};
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
activeStreams: new Map(),
|
||||
completedStreams: new Map(),
|
||||
activeSessions: new Set(),
|
||||
streamCompleteCallbacks: new Set(),
|
||||
activeTasks: loadPersistedTasks(),
|
||||
|
||||
startStream: async function startStream(
|
||||
sessionId,
|
||||
@@ -261,21 +85,45 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
onChunk,
|
||||
) {
|
||||
const state = get();
|
||||
const newActiveStreams = new Map(state.activeStreams);
|
||||
let newCompletedStreams = new Map(state.completedStreams);
|
||||
const callbacks = state.streamCompleteCallbacks;
|
||||
|
||||
// Clean up any existing stream for this session
|
||||
const {
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
} = cleanupExistingStream(
|
||||
sessionId,
|
||||
state.activeStreams,
|
||||
state.completedStreams,
|
||||
callbacks,
|
||||
);
|
||||
const existingStream = newActiveStreams.get(sessionId);
|
||||
if (existingStream) {
|
||||
existingStream.abortController.abort();
|
||||
const normalizedStatus =
|
||||
existingStream.status === "streaming"
|
||||
? "completed"
|
||||
: existingStream.status;
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: normalizedStatus,
|
||||
chunks: existingStream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: existingStream.error,
|
||||
};
|
||||
newCompletedStreams.set(sessionId, result);
|
||||
newActiveStreams.delete(sessionId);
|
||||
newCompletedStreams = cleanupExpiredStreams(newCompletedStreams);
|
||||
if (normalizedStatus === "completed" || normalizedStatus === "error") {
|
||||
notifyStreamComplete(callbacks, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const initialCallbacks = new Set<(chunk: StreamChunk) => void>();
|
||||
if (onChunk) initialCallbacks.add(onChunk);
|
||||
|
||||
const stream: ActiveStream = {
|
||||
sessionId,
|
||||
abortController,
|
||||
status: "streaming",
|
||||
startedAt: Date.now(),
|
||||
chunks: [],
|
||||
onChunkCallbacks: initialCallbacks,
|
||||
};
|
||||
|
||||
// Create new stream
|
||||
const stream = createActiveStream(sessionId, onChunk);
|
||||
newActiveStreams.set(sessionId, stream);
|
||||
set({
|
||||
activeStreams: newActiveStreams,
|
||||
@@ -285,7 +133,36 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
try {
|
||||
await executeStream(stream, message, isUserMessage, context);
|
||||
} finally {
|
||||
finalizeStream(sessionId, stream, onChunk, get, set);
|
||||
if (onChunk) stream.onChunkCallbacks.delete(onChunk);
|
||||
if (stream.status !== "streaming") {
|
||||
const currentState = get();
|
||||
const finalActiveStreams = new Map(currentState.activeStreams);
|
||||
let finalCompletedStreams = new Map(currentState.completedStreams);
|
||||
|
||||
const storedStream = finalActiveStreams.get(sessionId);
|
||||
if (storedStream === stream) {
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: stream.status,
|
||||
chunks: stream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: stream.error,
|
||||
};
|
||||
finalCompletedStreams.set(sessionId, result);
|
||||
finalActiveStreams.delete(sessionId);
|
||||
finalCompletedStreams = cleanupExpiredStreams(finalCompletedStreams);
|
||||
set({
|
||||
activeStreams: finalActiveStreams,
|
||||
completedStreams: finalCompletedStreams,
|
||||
});
|
||||
if (stream.status === "completed" || stream.status === "error") {
|
||||
notifyStreamComplete(
|
||||
currentState.streamCompleteCallbacks,
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -409,93 +286,4 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
set({ streamCompleteCallbacks: cleanedCallbacks });
|
||||
};
|
||||
},
|
||||
|
||||
setActiveTask: function setActiveTask(sessionId, taskInfo) {
|
||||
const state = get();
|
||||
const newActiveTasks = new Map(state.activeTasks);
|
||||
newActiveTasks.set(sessionId, {
|
||||
...taskInfo,
|
||||
sessionId,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
set({ activeTasks: newActiveTasks });
|
||||
persistTasks(newActiveTasks);
|
||||
},
|
||||
|
||||
getActiveTask: function getActiveTask(sessionId) {
|
||||
return get().activeTasks.get(sessionId);
|
||||
},
|
||||
|
||||
clearActiveTask: function clearActiveTask(sessionId) {
|
||||
const state = get();
|
||||
if (!state.activeTasks.has(sessionId)) return;
|
||||
|
||||
const newActiveTasks = new Map(state.activeTasks);
|
||||
newActiveTasks.delete(sessionId);
|
||||
set({ activeTasks: newActiveTasks });
|
||||
persistTasks(newActiveTasks);
|
||||
},
|
||||
|
||||
reconnectToTask: async function reconnectToTask(
|
||||
sessionId,
|
||||
taskId,
|
||||
lastMessageId = INITIAL_STREAM_ID,
|
||||
onChunk,
|
||||
) {
|
||||
const state = get();
|
||||
const callbacks = state.streamCompleteCallbacks;
|
||||
|
||||
// Clean up any existing stream for this session
|
||||
const {
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
} = cleanupExistingStream(
|
||||
sessionId,
|
||||
state.activeStreams,
|
||||
state.completedStreams,
|
||||
callbacks,
|
||||
);
|
||||
|
||||
// Create new stream for reconnection
|
||||
const stream = createActiveStream(sessionId, onChunk);
|
||||
newActiveStreams.set(sessionId, stream);
|
||||
set({
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
});
|
||||
|
||||
try {
|
||||
await executeTaskReconnect(stream, taskId, lastMessageId);
|
||||
} finally {
|
||||
finalizeStream(sessionId, stream, onChunk, get, set);
|
||||
|
||||
// Clear active task on completion
|
||||
if (stream.status === "completed" || stream.status === "error") {
|
||||
const taskState = get();
|
||||
if (taskState.activeTasks.has(sessionId)) {
|
||||
const newActiveTasks = new Map(taskState.activeTasks);
|
||||
newActiveTasks.delete(sessionId);
|
||||
set({ activeTasks: newActiveTasks });
|
||||
persistTasks(newActiveTasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updateTaskLastMessageId: function updateTaskLastMessageId(
|
||||
sessionId,
|
||||
lastMessageId,
|
||||
) {
|
||||
const state = get();
|
||||
const task = state.activeTasks.get(sessionId);
|
||||
if (!task) return;
|
||||
|
||||
const newActiveTasks = new Map(state.activeTasks);
|
||||
newActiveTasks.set(sessionId, {
|
||||
...task,
|
||||
lastMessageId,
|
||||
});
|
||||
set({ activeTasks: newActiveTasks });
|
||||
persistTasks(newActiveTasks);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,6 @@ export type StreamStatus = "idle" | "streaming" | "completed" | "error";
|
||||
|
||||
export interface StreamChunk {
|
||||
type:
|
||||
| "stream_start"
|
||||
| "text_chunk"
|
||||
| "text_ended"
|
||||
| "tool_call"
|
||||
@@ -16,7 +15,6 @@ export interface StreamChunk {
|
||||
| "error"
|
||||
| "usage"
|
||||
| "stream_end";
|
||||
taskId?: string;
|
||||
timestamp?: string;
|
||||
content?: string;
|
||||
message?: string;
|
||||
@@ -43,7 +41,7 @@ export interface StreamChunk {
|
||||
}
|
||||
|
||||
export type VercelStreamChunk =
|
||||
| { type: "start"; messageId: string; taskId?: string }
|
||||
| { type: "start"; messageId: string }
|
||||
| { type: "finish" }
|
||||
| { type: "text-start"; id: string }
|
||||
| { type: "text-delta"; id: string; delta: string }
|
||||
@@ -94,70 +92,3 @@ export interface StreamResult {
|
||||
}
|
||||
|
||||
export type StreamCompleteCallback = (sessionId: string) => void;
|
||||
|
||||
// Type guards for message types
|
||||
|
||||
/**
|
||||
* Check if a message has a toolId property.
|
||||
*/
|
||||
export function hasToolId<T extends { type: string }>(
|
||||
msg: T,
|
||||
): msg is T & { toolId: string } {
|
||||
return (
|
||||
"toolId" in msg &&
|
||||
typeof (msg as Record<string, unknown>).toolId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has an operationId property.
|
||||
*/
|
||||
export function hasOperationId<T extends { type: string }>(
|
||||
msg: T,
|
||||
): msg is T & { operationId: string } {
|
||||
return (
|
||||
"operationId" in msg &&
|
||||
typeof (msg as Record<string, unknown>).operationId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has a toolCallId property.
|
||||
*/
|
||||
export function hasToolCallId<T extends { type: string }>(
|
||||
msg: T,
|
||||
): msg is T & { toolCallId: string } {
|
||||
return (
|
||||
"toolCallId" in msg &&
|
||||
typeof (msg as Record<string, unknown>).toolCallId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is an operation message type.
|
||||
*/
|
||||
export function isOperationMessage<T extends { type: string }>(
|
||||
msg: T,
|
||||
): msg is T & {
|
||||
type: "operation_started" | "operation_pending" | "operation_in_progress";
|
||||
} {
|
||||
return (
|
||||
msg.type === "operation_started" ||
|
||||
msg.type === "operation_pending" ||
|
||||
msg.type === "operation_in_progress"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tool ID from a message if available.
|
||||
* Checks toolId, operationId, and toolCallId properties.
|
||||
*/
|
||||
export function getToolIdFromMessage<T extends { type: string }>(
|
||||
msg: T,
|
||||
): string | undefined {
|
||||
const record = msg as Record<string, unknown>;
|
||||
if (typeof record.toolId === "string") return record.toolId;
|
||||
if (typeof record.operationId === "string") return record.operationId;
|
||||
if (typeof record.toolCallId === "string") return record.toolCallId;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -17,13 +17,6 @@ export interface ChatContainerProps {
|
||||
className?: string;
|
||||
onStreamingChange?: (isStreaming: boolean) => void;
|
||||
onOperationStarted?: () => void;
|
||||
/** Active stream info from the server for reconnection */
|
||||
activeStream?: {
|
||||
taskId: string;
|
||||
lastMessageId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
@@ -33,7 +26,6 @@ export function ChatContainer({
|
||||
className,
|
||||
onStreamingChange,
|
||||
onOperationStarted,
|
||||
activeStream,
|
||||
}: ChatContainerProps) {
|
||||
const {
|
||||
messages,
|
||||
@@ -49,7 +41,6 @@ export function ChatContainer({
|
||||
initialMessages,
|
||||
initialPrompt,
|
||||
onOperationStarted,
|
||||
activeStream,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { toast } from "sonner";
|
||||
import type { StreamChunk } from "../../chat-types";
|
||||
import type { HandlerDependencies } from "./handlers";
|
||||
import {
|
||||
getErrorDisplayMessage,
|
||||
handleError,
|
||||
handleLoginNeeded,
|
||||
handleStreamEnd,
|
||||
@@ -25,22 +24,16 @@ export function createStreamEventDispatcher(
|
||||
chunk.type === "need_login" ||
|
||||
chunk.type === "error"
|
||||
) {
|
||||
if (!deps.hasResponseRef.current) {
|
||||
console.info("[ChatStream] First response chunk:", {
|
||||
type: chunk.type,
|
||||
sessionId: deps.sessionId,
|
||||
});
|
||||
}
|
||||
deps.hasResponseRef.current = true;
|
||||
}
|
||||
|
||||
switch (chunk.type) {
|
||||
case "stream_start":
|
||||
// Store task ID for SSE reconnection
|
||||
if (chunk.taskId && deps.onActiveTaskStarted) {
|
||||
deps.onActiveTaskStarted({
|
||||
taskId: chunk.taskId,
|
||||
operationId: chunk.taskId,
|
||||
toolName: "chat",
|
||||
toolCallId: "chat_stream",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "text_chunk":
|
||||
handleTextChunk(chunk, deps);
|
||||
break;
|
||||
@@ -63,7 +56,11 @@ export function createStreamEventDispatcher(
|
||||
break;
|
||||
|
||||
case "stream_end":
|
||||
// Note: "finish" type from backend gets normalized to "stream_end" by normalizeStreamChunk
|
||||
console.info("[ChatStream] Stream ended:", {
|
||||
sessionId: deps.sessionId,
|
||||
hasResponse: deps.hasResponseRef.current,
|
||||
chunkCount: deps.streamingChunksRef.current.length,
|
||||
});
|
||||
handleStreamEnd(chunk, deps);
|
||||
break;
|
||||
|
||||
@@ -73,7 +70,7 @@ export function createStreamEventDispatcher(
|
||||
// Show toast at dispatcher level to avoid circular dependencies
|
||||
if (!isRegionBlocked) {
|
||||
toast.error("Chat Error", {
|
||||
description: getErrorDisplayMessage(chunk),
|
||||
description: chunk.message || chunk.content || "An error occurred",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -18,19 +18,11 @@ export interface HandlerDependencies {
|
||||
setStreamingChunks: Dispatch<SetStateAction<string[]>>;
|
||||
streamingChunksRef: MutableRefObject<string[]>;
|
||||
hasResponseRef: MutableRefObject<boolean>;
|
||||
textFinalizedRef: MutableRefObject<boolean>;
|
||||
streamEndedRef: MutableRefObject<boolean>;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
|
||||
setIsStreamingInitiated: Dispatch<SetStateAction<boolean>>;
|
||||
setIsRegionBlockedModalOpen: Dispatch<SetStateAction<boolean>>;
|
||||
sessionId: string;
|
||||
onOperationStarted?: () => void;
|
||||
onActiveTaskStarted?: (taskInfo: {
|
||||
taskId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function isRegionBlockedError(chunk: StreamChunk): boolean {
|
||||
@@ -40,25 +32,6 @@ export function isRegionBlockedError(chunk: StreamChunk): boolean {
|
||||
return message.toLowerCase().includes("not available in your region");
|
||||
}
|
||||
|
||||
export function getUserFriendlyErrorMessage(
|
||||
code: string | undefined,
|
||||
): string | undefined {
|
||||
switch (code) {
|
||||
case "TASK_EXPIRED":
|
||||
return "This operation has expired. Please try again.";
|
||||
case "TASK_NOT_FOUND":
|
||||
return "Could not find the requested operation.";
|
||||
case "ACCESS_DENIED":
|
||||
return "You do not have access to this operation.";
|
||||
case "QUEUE_OVERFLOW":
|
||||
return "Connection was interrupted. Please refresh to continue.";
|
||||
case "MODEL_NOT_AVAILABLE_REGION":
|
||||
return "This model is not available in your region.";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTextChunk(chunk: StreamChunk, deps: HandlerDependencies) {
|
||||
if (!chunk.content) return;
|
||||
deps.setHasTextChunks(true);
|
||||
@@ -73,15 +46,10 @@ export function handleTextEnded(
|
||||
_chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
if (deps.textFinalizedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completedText = deps.streamingChunksRef.current.join("");
|
||||
if (completedText.trim()) {
|
||||
deps.textFinalizedRef.current = true;
|
||||
|
||||
deps.setMessages((prev) => {
|
||||
// Check if this exact message already exists to prevent duplicates
|
||||
const exists = prev.some(
|
||||
(msg) =>
|
||||
msg.type === "message" &&
|
||||
@@ -108,14 +76,9 @@ export function handleToolCallStart(
|
||||
chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
// Use deterministic fallback instead of Date.now() to ensure same ID on replay
|
||||
const toolId =
|
||||
chunk.tool_id ||
|
||||
`tool-${deps.sessionId}-${chunk.idx ?? "unknown"}-${chunk.tool_name || "unknown"}`;
|
||||
|
||||
const toolCallMessage: Extract<ChatMessageData, { type: "tool_call" }> = {
|
||||
type: "tool_call",
|
||||
toolId,
|
||||
toolId: chunk.tool_id || `tool-${Date.now()}-${chunk.idx || 0}`,
|
||||
toolName: chunk.tool_name || "Executing",
|
||||
arguments: chunk.arguments || {},
|
||||
timestamp: new Date(),
|
||||
@@ -148,29 +111,6 @@ export function handleToolCallStart(
|
||||
deps.setMessages(updateToolCallMessages);
|
||||
}
|
||||
|
||||
const TOOL_RESPONSE_TYPES = new Set([
|
||||
"tool_response",
|
||||
"operation_started",
|
||||
"operation_pending",
|
||||
"operation_in_progress",
|
||||
"execution_started",
|
||||
"agent_carousel",
|
||||
"clarification_needed",
|
||||
]);
|
||||
|
||||
function hasResponseForTool(
|
||||
messages: ChatMessageData[],
|
||||
toolId: string,
|
||||
): boolean {
|
||||
return messages.some((msg) => {
|
||||
if (!TOOL_RESPONSE_TYPES.has(msg.type)) return false;
|
||||
const msgToolId =
|
||||
(msg as { toolId?: string }).toolId ||
|
||||
(msg as { toolCallId?: string }).toolCallId;
|
||||
return msgToolId === toolId;
|
||||
});
|
||||
}
|
||||
|
||||
export function handleToolResponse(
|
||||
chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
@@ -212,49 +152,31 @@ export function handleToolResponse(
|
||||
) {
|
||||
const inputsMessage = extractInputsNeeded(parsedResult, chunk.tool_name);
|
||||
if (inputsMessage) {
|
||||
deps.setMessages((prev) => {
|
||||
// Check for duplicate inputs_needed message
|
||||
const exists = prev.some((msg) => msg.type === "inputs_needed");
|
||||
if (exists) return prev;
|
||||
return [...prev, inputsMessage];
|
||||
});
|
||||
deps.setMessages((prev) => [...prev, inputsMessage]);
|
||||
}
|
||||
const credentialsMessage = extractCredentialsNeeded(
|
||||
parsedResult,
|
||||
chunk.tool_name,
|
||||
);
|
||||
if (credentialsMessage) {
|
||||
deps.setMessages((prev) => {
|
||||
// Check for duplicate credentials_needed message
|
||||
const exists = prev.some((msg) => msg.type === "credentials_needed");
|
||||
if (exists) return prev;
|
||||
return [...prev, credentialsMessage];
|
||||
});
|
||||
deps.setMessages((prev) => [...prev, credentialsMessage]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Trigger polling when operation_started is received
|
||||
if (responseMessage.type === "operation_started") {
|
||||
deps.onOperationStarted?.();
|
||||
const taskId = (responseMessage as { taskId?: string }).taskId;
|
||||
if (taskId && deps.onActiveTaskStarted) {
|
||||
deps.onActiveTaskStarted({
|
||||
taskId,
|
||||
operationId:
|
||||
(responseMessage as { operationId?: string }).operationId || "",
|
||||
toolName: (responseMessage as { toolName?: string }).toolName || "",
|
||||
toolCallId: (responseMessage as { toolId?: string }).toolId || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deps.setMessages((prev) => {
|
||||
const toolCallIndex = prev.findIndex(
|
||||
(msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
|
||||
);
|
||||
if (hasResponseForTool(prev, chunk.tool_id!)) {
|
||||
return prev;
|
||||
}
|
||||
const hasResponse = prev.some(
|
||||
(msg) => msg.type === "tool_response" && msg.toolId === chunk.tool_id,
|
||||
);
|
||||
if (hasResponse) return prev;
|
||||
if (toolCallIndex !== -1) {
|
||||
const newMessages = [...prev];
|
||||
newMessages.splice(toolCallIndex + 1, 0, responseMessage);
|
||||
@@ -276,48 +198,28 @@ export function handleLoginNeeded(
|
||||
agentInfo: chunk.agent_info,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
deps.setMessages((prev) => {
|
||||
// Check for duplicate login_needed message
|
||||
const exists = prev.some((msg) => msg.type === "login_needed");
|
||||
if (exists) return prev;
|
||||
return [...prev, loginNeededMessage];
|
||||
});
|
||||
deps.setMessages((prev) => [...prev, loginNeededMessage]);
|
||||
}
|
||||
|
||||
export function handleStreamEnd(
|
||||
_chunk: StreamChunk,
|
||||
deps: HandlerDependencies,
|
||||
) {
|
||||
if (deps.streamEndedRef.current) {
|
||||
return;
|
||||
}
|
||||
deps.streamEndedRef.current = true;
|
||||
|
||||
const completedContent = deps.streamingChunksRef.current.join("");
|
||||
if (!completedContent.trim() && !deps.hasResponseRef.current) {
|
||||
deps.setMessages((prev) => {
|
||||
const exists = prev.some(
|
||||
(msg) =>
|
||||
msg.type === "message" &&
|
||||
msg.role === "assistant" &&
|
||||
msg.content === "No response received. Please try again.",
|
||||
);
|
||||
if (exists) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "No response received. Please try again.",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
];
|
||||
});
|
||||
deps.setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "No response received. Please try again.",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (completedContent.trim() && !deps.textFinalizedRef.current) {
|
||||
deps.textFinalizedRef.current = true;
|
||||
|
||||
if (completedContent.trim()) {
|
||||
deps.setMessages((prev) => {
|
||||
// Check if this exact message already exists to prevent duplicates
|
||||
const exists = prev.some(
|
||||
(msg) =>
|
||||
msg.type === "message" &&
|
||||
@@ -342,6 +244,8 @@ export function handleStreamEnd(
|
||||
}
|
||||
|
||||
export function handleError(chunk: StreamChunk, deps: HandlerDependencies) {
|
||||
const errorMessage = chunk.message || chunk.content || "An error occurred";
|
||||
console.error("Stream error:", errorMessage);
|
||||
if (isRegionBlockedError(chunk)) {
|
||||
deps.setIsRegionBlockedModalOpen(true);
|
||||
}
|
||||
@@ -349,14 +253,4 @@ export function handleError(chunk: StreamChunk, deps: HandlerDependencies) {
|
||||
deps.setHasTextChunks(false);
|
||||
deps.setStreamingChunks([]);
|
||||
deps.streamingChunksRef.current = [];
|
||||
deps.textFinalizedRef.current = false;
|
||||
deps.streamEndedRef.current = true;
|
||||
}
|
||||
|
||||
export function getErrorDisplayMessage(chunk: StreamChunk): string {
|
||||
const friendlyMessage = getUserFriendlyErrorMessage(chunk.code);
|
||||
if (friendlyMessage) {
|
||||
return friendlyMessage;
|
||||
}
|
||||
return chunk.message || chunk.content || "An error occurred";
|
||||
}
|
||||
|
||||
@@ -349,7 +349,6 @@ export function parseToolResponse(
|
||||
toolName: (parsedResult.tool_name as string) || toolName,
|
||||
toolId,
|
||||
operationId: (parsedResult.operation_id as string) || "",
|
||||
taskId: (parsedResult.task_id as string) || undefined, // For SSE reconnection
|
||||
message:
|
||||
(parsedResult.message as string) ||
|
||||
"Operation started. You can close this tab.",
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { INITIAL_STREAM_ID } from "../../chat-constants";
|
||||
import { useChatStore } from "../../chat-store";
|
||||
import { toast } from "sonner";
|
||||
import { useChatStream } from "../../useChatStream";
|
||||
import { usePageContext } from "../../usePageContext";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import {
|
||||
getToolIdFromMessage,
|
||||
hasToolId,
|
||||
isOperationMessage,
|
||||
type StreamChunk,
|
||||
} from "../../chat-types";
|
||||
import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
|
||||
import {
|
||||
createUserMessage,
|
||||
@@ -21,13 +14,6 @@ import {
|
||||
processInitialMessages,
|
||||
} from "./helpers";
|
||||
|
||||
const TOOL_RESULT_TYPES = new Set([
|
||||
"tool_response",
|
||||
"agent_carousel",
|
||||
"execution_started",
|
||||
"clarification_needed",
|
||||
]);
|
||||
|
||||
// Helper to generate deduplication key for a message
|
||||
function getMessageKey(msg: ChatMessageData): string {
|
||||
if (msg.type === "message") {
|
||||
@@ -37,18 +23,14 @@ function getMessageKey(msg: ChatMessageData): string {
|
||||
return `msg:${msg.role}:${msg.content}`;
|
||||
} else if (msg.type === "tool_call") {
|
||||
return `toolcall:${msg.toolId}`;
|
||||
} else if (TOOL_RESULT_TYPES.has(msg.type)) {
|
||||
// Unified key for all tool result types - same toolId with different types
|
||||
// (tool_response vs agent_carousel) should deduplicate to the same key
|
||||
const toolId = getToolIdFromMessage(msg);
|
||||
// If no toolId, fall back to content-based key to avoid empty key collisions
|
||||
if (!toolId) {
|
||||
return `toolresult:content:${JSON.stringify(msg).slice(0, 200)}`;
|
||||
}
|
||||
return `toolresult:${toolId}`;
|
||||
} else if (isOperationMessage(msg)) {
|
||||
const toolId = getToolIdFromMessage(msg) || "";
|
||||
return `op:${toolId}:${msg.toolName}`;
|
||||
} else if (msg.type === "tool_response") {
|
||||
return `toolresponse:${(msg as any).toolId}`;
|
||||
} else if (
|
||||
msg.type === "operation_started" ||
|
||||
msg.type === "operation_pending" ||
|
||||
msg.type === "operation_in_progress"
|
||||
) {
|
||||
return `op:${(msg as any).toolId || (msg as any).operationId || (msg as any).toolCallId || ""}:${msg.toolName}`;
|
||||
} else {
|
||||
return `${msg.type}:${JSON.stringify(msg).slice(0, 100)}`;
|
||||
}
|
||||
@@ -59,13 +41,6 @@ interface Args {
|
||||
initialMessages: SessionDetailResponse["messages"];
|
||||
initialPrompt?: string;
|
||||
onOperationStarted?: () => void;
|
||||
/** Active stream info from the server for reconnection */
|
||||
activeStream?: {
|
||||
taskId: string;
|
||||
lastMessageId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useChatContainer({
|
||||
@@ -73,7 +48,6 @@ export function useChatContainer({
|
||||
initialMessages,
|
||||
initialPrompt,
|
||||
onOperationStarted,
|
||||
activeStream,
|
||||
}: Args) {
|
||||
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
||||
const [streamingChunks, setStreamingChunks] = useState<string[]>([]);
|
||||
@@ -83,8 +57,6 @@ export function useChatContainer({
|
||||
useState(false);
|
||||
const hasResponseRef = useRef(false);
|
||||
const streamingChunksRef = useRef<string[]>([]);
|
||||
const textFinalizedRef = useRef(false);
|
||||
const streamEndedRef = useRef(false);
|
||||
const previousSessionIdRef = useRef<string | null>(null);
|
||||
const {
|
||||
error,
|
||||
@@ -93,182 +65,44 @@ export function useChatContainer({
|
||||
} = useChatStream();
|
||||
const activeStreams = useChatStore((s) => s.activeStreams);
|
||||
const subscribeToStream = useChatStore((s) => s.subscribeToStream);
|
||||
const setActiveTask = useChatStore((s) => s.setActiveTask);
|
||||
const getActiveTask = useChatStore((s) => s.getActiveTask);
|
||||
const reconnectToTask = useChatStore((s) => s.reconnectToTask);
|
||||
const isStreaming = isStreamingInitiated || hasTextChunks;
|
||||
// Track whether we've already connected to this activeStream to avoid duplicate connections
|
||||
const connectedActiveStreamRef = useRef<string | null>(null);
|
||||
// Track if component is mounted to prevent state updates after unmount
|
||||
const isMountedRef = useRef(true);
|
||||
// Track current dispatcher to prevent multiple dispatchers from adding messages
|
||||
const currentDispatcherIdRef = useRef(0);
|
||||
|
||||
// Set mounted flag - reset on every mount, cleanup on unmount
|
||||
useEffect(function trackMountedState() {
|
||||
isMountedRef.current = true;
|
||||
return function cleanup() {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Callback to store active task info for SSE reconnection
|
||||
function handleActiveTaskStarted(taskInfo: {
|
||||
taskId: string;
|
||||
operationId: string;
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
}) {
|
||||
if (!sessionId) return;
|
||||
setActiveTask(sessionId, {
|
||||
taskId: taskInfo.taskId,
|
||||
operationId: taskInfo.operationId,
|
||||
toolName: taskInfo.toolName,
|
||||
lastMessageId: INITIAL_STREAM_ID,
|
||||
});
|
||||
}
|
||||
|
||||
// Create dispatcher for stream events - stable reference for current sessionId
|
||||
// Each dispatcher gets a unique ID to prevent stale dispatchers from updating state
|
||||
function createDispatcher() {
|
||||
if (!sessionId) return () => {};
|
||||
// Increment dispatcher ID - only the most recent dispatcher should update state
|
||||
const dispatcherId = ++currentDispatcherIdRef.current;
|
||||
|
||||
const baseDispatcher = createStreamEventDispatcher({
|
||||
setHasTextChunks,
|
||||
setStreamingChunks,
|
||||
streamingChunksRef,
|
||||
hasResponseRef,
|
||||
textFinalizedRef,
|
||||
streamEndedRef,
|
||||
setMessages,
|
||||
setIsRegionBlockedModalOpen,
|
||||
sessionId,
|
||||
setIsStreamingInitiated,
|
||||
onOperationStarted,
|
||||
onActiveTaskStarted: handleActiveTaskStarted,
|
||||
});
|
||||
|
||||
// Wrap dispatcher to check if it's still the current one
|
||||
return function guardedDispatcher(chunk: StreamChunk) {
|
||||
// Skip if component unmounted or this is a stale dispatcher
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (dispatcherId !== currentDispatcherIdRef.current) {
|
||||
return;
|
||||
}
|
||||
baseDispatcher(chunk);
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(
|
||||
function handleSessionChange() {
|
||||
const isSessionChange = sessionId !== previousSessionIdRef.current;
|
||||
if (sessionId === previousSessionIdRef.current) return;
|
||||
|
||||
// Handle session change - reset state
|
||||
if (isSessionChange) {
|
||||
const prevSession = previousSessionIdRef.current;
|
||||
if (prevSession) {
|
||||
stopStreaming(prevSession);
|
||||
}
|
||||
previousSessionIdRef.current = sessionId;
|
||||
connectedActiveStreamRef.current = null;
|
||||
setMessages([]);
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
setIsStreamingInitiated(false);
|
||||
hasResponseRef.current = false;
|
||||
textFinalizedRef.current = false;
|
||||
streamEndedRef.current = false;
|
||||
const prevSession = previousSessionIdRef.current;
|
||||
if (prevSession) {
|
||||
stopStreaming(prevSession);
|
||||
}
|
||||
previousSessionIdRef.current = sessionId;
|
||||
setMessages([]);
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
setIsStreamingInitiated(false);
|
||||
hasResponseRef.current = false;
|
||||
|
||||
if (!sessionId) return;
|
||||
|
||||
// Priority 1: Check if server told us there's an active stream (most authoritative)
|
||||
if (activeStream) {
|
||||
const streamKey = `${sessionId}:${activeStream.taskId}`;
|
||||
const activeStream = activeStreams.get(sessionId);
|
||||
if (!activeStream || activeStream.status !== "streaming") return;
|
||||
|
||||
if (connectedActiveStreamRef.current === streamKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if there's already an active stream for this session in the store
|
||||
const existingStream = activeStreams.get(sessionId);
|
||||
if (existingStream && existingStream.status === "streaming") {
|
||||
connectedActiveStreamRef.current = streamKey;
|
||||
return;
|
||||
}
|
||||
|
||||
connectedActiveStreamRef.current = streamKey;
|
||||
|
||||
// Clear all state before reconnection to prevent duplicates
|
||||
// Server's initialMessages is authoritative; local state will be rebuilt from SSE replay
|
||||
setMessages([]);
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
textFinalizedRef.current = false;
|
||||
streamEndedRef.current = false;
|
||||
hasResponseRef.current = false;
|
||||
|
||||
setIsStreamingInitiated(true);
|
||||
setActiveTask(sessionId, {
|
||||
taskId: activeStream.taskId,
|
||||
operationId: activeStream.operationId,
|
||||
toolName: activeStream.toolName,
|
||||
lastMessageId: activeStream.lastMessageId,
|
||||
});
|
||||
reconnectToTask(
|
||||
sessionId,
|
||||
activeStream.taskId,
|
||||
activeStream.lastMessageId,
|
||||
createDispatcher(),
|
||||
);
|
||||
// Don't return cleanup here - the guarded dispatcher handles stale events
|
||||
// and the stream will complete naturally. Cleanup would prematurely stop
|
||||
// the stream when effect re-runs due to activeStreams changing.
|
||||
return;
|
||||
}
|
||||
|
||||
// Only check localStorage/in-memory on session change
|
||||
if (!isSessionChange) return;
|
||||
|
||||
// Priority 2: Check localStorage for active task
|
||||
const activeTask = getActiveTask(sessionId);
|
||||
if (activeTask) {
|
||||
// Clear all state before reconnection to prevent duplicates
|
||||
// Server's initialMessages is authoritative; local state will be rebuilt from SSE replay
|
||||
setMessages([]);
|
||||
setStreamingChunks([]);
|
||||
streamingChunksRef.current = [];
|
||||
setHasTextChunks(false);
|
||||
textFinalizedRef.current = false;
|
||||
streamEndedRef.current = false;
|
||||
hasResponseRef.current = false;
|
||||
|
||||
setIsStreamingInitiated(true);
|
||||
reconnectToTask(
|
||||
sessionId,
|
||||
activeTask.taskId,
|
||||
activeTask.lastMessageId,
|
||||
createDispatcher(),
|
||||
);
|
||||
// Don't return cleanup here - the guarded dispatcher handles stale events
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 3: Check for an in-memory active stream (same-tab scenario)
|
||||
const inMemoryStream = activeStreams.get(sessionId);
|
||||
if (!inMemoryStream || inMemoryStream.status !== "streaming") {
|
||||
return;
|
||||
}
|
||||
const dispatcher = createStreamEventDispatcher({
|
||||
setHasTextChunks,
|
||||
setStreamingChunks,
|
||||
streamingChunksRef,
|
||||
hasResponseRef,
|
||||
setMessages,
|
||||
setIsRegionBlockedModalOpen,
|
||||
sessionId,
|
||||
setIsStreamingInitiated,
|
||||
onOperationStarted,
|
||||
});
|
||||
|
||||
setIsStreamingInitiated(true);
|
||||
const skipReplay = initialMessages.length > 0;
|
||||
return subscribeToStream(sessionId, createDispatcher(), skipReplay);
|
||||
return subscribeToStream(sessionId, dispatcher, skipReplay);
|
||||
},
|
||||
[
|
||||
sessionId,
|
||||
@@ -276,10 +110,6 @@ export function useChatContainer({
|
||||
activeStreams,
|
||||
subscribeToStream,
|
||||
onOperationStarted,
|
||||
getActiveTask,
|
||||
reconnectToTask,
|
||||
activeStream,
|
||||
setActiveTask,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -294,7 +124,7 @@ export function useChatContainer({
|
||||
msg.type === "agent_carousel" ||
|
||||
msg.type === "execution_started"
|
||||
) {
|
||||
const toolId = hasToolId(msg) ? msg.toolId : undefined;
|
||||
const toolId = (msg as any).toolId;
|
||||
if (toolId) {
|
||||
ids.add(toolId);
|
||||
}
|
||||
@@ -311,8 +141,12 @@ export function useChatContainer({
|
||||
|
||||
setMessages((prev) => {
|
||||
const filtered = prev.filter((msg) => {
|
||||
if (isOperationMessage(msg)) {
|
||||
const toolId = getToolIdFromMessage(msg);
|
||||
if (
|
||||
msg.type === "operation_started" ||
|
||||
msg.type === "operation_pending" ||
|
||||
msg.type === "operation_in_progress"
|
||||
) {
|
||||
const toolId = (msg as any).toolId || (msg as any).toolCallId;
|
||||
if (toolId && completedToolIds.has(toolId)) {
|
||||
return false; // Remove - operation completed
|
||||
}
|
||||
@@ -340,8 +174,12 @@ export function useChatContainer({
|
||||
// Filter local messages: remove duplicates and completed operation messages
|
||||
const newLocalMessages = messages.filter((msg) => {
|
||||
// Remove operation messages for completed tools
|
||||
if (isOperationMessage(msg)) {
|
||||
const toolId = getToolIdFromMessage(msg);
|
||||
if (
|
||||
msg.type === "operation_started" ||
|
||||
msg.type === "operation_pending" ||
|
||||
msg.type === "operation_in_progress"
|
||||
) {
|
||||
const toolId = (msg as any).toolId || (msg as any).toolCallId;
|
||||
if (toolId && completedToolIds.has(toolId)) {
|
||||
return false;
|
||||
}
|
||||
@@ -352,70 +190,7 @@ export function useChatContainer({
|
||||
});
|
||||
|
||||
// Server messages first (correct order), then new local messages
|
||||
const combined = [...processedInitial, ...newLocalMessages];
|
||||
|
||||
// Post-processing: Remove duplicate assistant messages that can occur during
|
||||
// race conditions (e.g., rapid screen switching during SSE reconnection).
|
||||
// Two assistant messages are considered duplicates if:
|
||||
// - They are both text messages with role "assistant"
|
||||
// - One message's content starts with the other's content (partial vs complete)
|
||||
// - Or they have very similar content (>80% overlap at the start)
|
||||
const deduplicated: ChatMessageData[] = [];
|
||||
for (let i = 0; i < combined.length; i++) {
|
||||
const current = combined[i];
|
||||
|
||||
// Check if this is an assistant text message
|
||||
if (current.type !== "message" || current.role !== "assistant") {
|
||||
deduplicated.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for duplicate assistant messages in the rest of the array
|
||||
let dominated = false;
|
||||
for (let j = 0; j < combined.length; j++) {
|
||||
if (i === j) continue;
|
||||
const other = combined[j];
|
||||
if (other.type !== "message" || other.role !== "assistant") continue;
|
||||
|
||||
const currentContent = current.content || "";
|
||||
const otherContent = other.content || "";
|
||||
|
||||
// Skip empty messages
|
||||
if (!currentContent.trim() || !otherContent.trim()) continue;
|
||||
|
||||
// Check if current is a prefix of other (current is incomplete version)
|
||||
if (
|
||||
otherContent.length > currentContent.length &&
|
||||
otherContent.startsWith(currentContent.slice(0, 100))
|
||||
) {
|
||||
// Current is a shorter/incomplete version of other - skip it
|
||||
dominated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if messages are nearly identical (within a small difference)
|
||||
// This catches cases where content differs only slightly
|
||||
const minLen = Math.min(currentContent.length, otherContent.length);
|
||||
const compareLen = Math.min(minLen, 200); // Compare first 200 chars
|
||||
if (
|
||||
compareLen > 50 &&
|
||||
currentContent.slice(0, compareLen) ===
|
||||
otherContent.slice(0, compareLen)
|
||||
) {
|
||||
// Same prefix - keep the longer one
|
||||
if (otherContent.length > currentContent.length) {
|
||||
dominated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dominated) {
|
||||
deduplicated.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicated;
|
||||
return [...processedInitial, ...newLocalMessages];
|
||||
}, [initialMessages, messages, completedToolIds]);
|
||||
|
||||
async function sendMessage(
|
||||
@@ -423,8 +198,10 @@ export function useChatContainer({
|
||||
isUserMessage: boolean = true,
|
||||
context?: { url: string; content: string },
|
||||
) {
|
||||
if (!sessionId) return;
|
||||
|
||||
if (!sessionId) {
|
||||
console.error("[useChatContainer] Cannot send message: no session ID");
|
||||
return;
|
||||
}
|
||||
setIsRegionBlockedModalOpen(false);
|
||||
if (isUserMessage) {
|
||||
const userMessage = createUserMessage(content);
|
||||
@@ -437,19 +214,31 @@ export function useChatContainer({
|
||||
setHasTextChunks(false);
|
||||
setIsStreamingInitiated(true);
|
||||
hasResponseRef.current = false;
|
||||
textFinalizedRef.current = false;
|
||||
streamEndedRef.current = false;
|
||||
|
||||
const dispatcher = createStreamEventDispatcher({
|
||||
setHasTextChunks,
|
||||
setStreamingChunks,
|
||||
streamingChunksRef,
|
||||
hasResponseRef,
|
||||
setMessages,
|
||||
setIsRegionBlockedModalOpen,
|
||||
sessionId,
|
||||
setIsStreamingInitiated,
|
||||
onOperationStarted,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendStreamMessage(
|
||||
sessionId,
|
||||
content,
|
||||
createDispatcher(),
|
||||
dispatcher,
|
||||
isUserMessage,
|
||||
context,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[useChatContainer] Failed to send message:", err);
|
||||
setIsStreamingInitiated(false);
|
||||
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
|
||||
const errorMessage =
|
||||
|
||||
@@ -11,13 +11,12 @@ import { useChatInput } from "./useChatInput";
|
||||
import { useVoiceRecording } from "./useVoiceRecording";
|
||||
|
||||
export interface Props {
|
||||
onSend: (message: string) => void | Promise<void>;
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
onStop?: () => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -27,7 +26,6 @@ export function ChatInput({
|
||||
onStop,
|
||||
placeholder = "Type your message...",
|
||||
className,
|
||||
inputId = "chat-input",
|
||||
}: Props) {
|
||||
const inputId = "chat-input";
|
||||
const {
|
||||
|
||||
@@ -21,7 +21,6 @@ export function useChatInput({
|
||||
}: Args) {
|
||||
const [value, setValue] = useState("");
|
||||
const [hasMultipleLines, setHasMultipleLines] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
useEffect(
|
||||
function focusOnMount() {
|
||||
@@ -101,40 +100,34 @@ export function useChatInput({
|
||||
}
|
||||
}, [value, maxRows, inputId]);
|
||||
|
||||
async function handleSend() {
|
||||
if (disabled || isSending || !value.trim()) return;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
await onSend(value.trim());
|
||||
setValue("");
|
||||
setHasMultipleLines(false);
|
||||
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
||||
const wrapper = document.getElementById(
|
||||
`${inputId}-wrapper`,
|
||||
) as HTMLDivElement;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
}
|
||||
if (wrapper) {
|
||||
wrapper.style.height = "";
|
||||
wrapper.style.maxHeight = "";
|
||||
}
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
const handleSend = () => {
|
||||
if (disabled || !value.trim()) return;
|
||||
onSend(value.trim());
|
||||
setValue("");
|
||||
setHasMultipleLines(false);
|
||||
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
||||
const wrapper = document.getElementById(
|
||||
`${inputId}-wrapper`,
|
||||
) as HTMLDivElement;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
}
|
||||
}
|
||||
if (wrapper) {
|
||||
wrapper.style.height = "";
|
||||
wrapper.style.maxHeight = "";
|
||||
}
|
||||
};
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
handleSend();
|
||||
}
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLTextAreaElement>) {
|
||||
@@ -149,6 +142,5 @@ export function useChatInput({
|
||||
handleSubmit,
|
||||
handleChange,
|
||||
hasMultipleLines,
|
||||
isSending,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ export type ChatMessageData =
|
||||
toolName: string;
|
||||
toolId: string;
|
||||
operationId: string;
|
||||
taskId?: string; // For SSE reconnection
|
||||
message: string;
|
||||
timestamp?: string | Date;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ export function MessageList({
|
||||
isStreaming,
|
||||
});
|
||||
|
||||
/**
|
||||
* Keeps this for debugging purposes 💆🏽
|
||||
*/
|
||||
console.log(messages);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{/* Top fade shadow */}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { INITIAL_STREAM_ID } from "./chat-constants";
|
||||
import type {
|
||||
ActiveStream,
|
||||
StreamChunk,
|
||||
@@ -11,14 +10,8 @@ import {
|
||||
parseSSELine,
|
||||
} from "./stream-utils";
|
||||
|
||||
function notifySubscribers(
|
||||
stream: ActiveStream,
|
||||
chunk: StreamChunk,
|
||||
skipStore = false,
|
||||
) {
|
||||
if (!skipStore) {
|
||||
stream.chunks.push(chunk);
|
||||
}
|
||||
function notifySubscribers(stream: ActiveStream, chunk: StreamChunk) {
|
||||
stream.chunks.push(chunk);
|
||||
for (const callback of stream.onChunkCallbacks) {
|
||||
try {
|
||||
callback(chunk);
|
||||
@@ -28,114 +21,36 @@ function notifySubscribers(
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamExecutionOptions {
|
||||
stream: ActiveStream;
|
||||
mode: "new" | "reconnect";
|
||||
message?: string;
|
||||
isUserMessage?: boolean;
|
||||
context?: { url: string; content: string };
|
||||
taskId?: string;
|
||||
lastMessageId?: string;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
async function executeStreamInternal(
|
||||
options: StreamExecutionOptions,
|
||||
export async function executeStream(
|
||||
stream: ActiveStream,
|
||||
message: string,
|
||||
isUserMessage: boolean,
|
||||
context?: { url: string; content: string },
|
||||
retryCount: number = 0,
|
||||
): Promise<void> {
|
||||
const {
|
||||
stream,
|
||||
mode,
|
||||
message,
|
||||
isUserMessage,
|
||||
context,
|
||||
taskId,
|
||||
lastMessageId = INITIAL_STREAM_ID,
|
||||
retryCount = 0,
|
||||
} = options;
|
||||
|
||||
const { sessionId, abortController } = stream;
|
||||
const isReconnect = mode === "reconnect";
|
||||
|
||||
if (isReconnect) {
|
||||
if (!taskId) {
|
||||
throw new Error("taskId is required for reconnect mode");
|
||||
}
|
||||
if (lastMessageId === null || lastMessageId === undefined) {
|
||||
throw new Error("lastMessageId is required for reconnect mode");
|
||||
}
|
||||
} else {
|
||||
if (!message) {
|
||||
throw new Error("message is required for new stream mode");
|
||||
}
|
||||
if (isUserMessage === undefined) {
|
||||
throw new Error("isUserMessage is required for new stream mode");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let url: string;
|
||||
let fetchOptions: RequestInit;
|
||||
const url = `/api/chat/sessions/${sessionId}/stream`;
|
||||
const body = JSON.stringify({
|
||||
message,
|
||||
is_user_message: isUserMessage,
|
||||
context: context || null,
|
||||
});
|
||||
|
||||
if (isReconnect) {
|
||||
url = `/api/chat/tasks/${taskId}/stream?last_message_id=${encodeURIComponent(lastMessageId)}`;
|
||||
fetchOptions = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
signal: abortController.signal,
|
||||
};
|
||||
} else {
|
||||
url = `/api/chat/sessions/${sessionId}/stream`;
|
||||
fetchOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
is_user_message: isUserMessage,
|
||||
context: context || null,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorCode: string | undefined;
|
||||
let errorMessage = errorText || `HTTP ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(errorText);
|
||||
if (parsed.detail) {
|
||||
const detail =
|
||||
typeof parsed.detail === "string"
|
||||
? parsed.detail
|
||||
: parsed.detail.message || JSON.stringify(parsed.detail);
|
||||
errorMessage = detail;
|
||||
errorCode =
|
||||
typeof parsed.detail === "object" ? parsed.detail.code : undefined;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const isPermanentError =
|
||||
isReconnect &&
|
||||
(response.status === 404 ||
|
||||
response.status === 403 ||
|
||||
response.status === 410);
|
||||
|
||||
const error = new Error(errorMessage) as Error & {
|
||||
status?: number;
|
||||
isPermanent?: boolean;
|
||||
taskErrorCode?: string;
|
||||
};
|
||||
error.status = response.status;
|
||||
error.isPermanent = isPermanentError;
|
||||
error.taskErrorCode = errorCode;
|
||||
throw error;
|
||||
throw new Error(errorText || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
@@ -189,7 +104,9 @@ async function executeStreamInternal(
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.warn("[StreamExecutor] Failed to parse SSE chunk:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,17 +117,19 @@ async function executeStreamInternal(
|
||||
return;
|
||||
}
|
||||
|
||||
const isPermanentError =
|
||||
err instanceof Error &&
|
||||
(err as Error & { isPermanent?: boolean }).isPermanent;
|
||||
|
||||
if (!isPermanentError && retryCount < MAX_RETRIES) {
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
const retryDelay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
|
||||
console.log(
|
||||
`[StreamExecutor] Retrying in ${retryDelay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
return executeStreamInternal({
|
||||
...options,
|
||||
retryCount: retryCount + 1,
|
||||
});
|
||||
return executeStream(
|
||||
stream,
|
||||
message,
|
||||
isUserMessage,
|
||||
context,
|
||||
retryCount + 1,
|
||||
);
|
||||
}
|
||||
|
||||
stream.status = "error";
|
||||
@@ -221,35 +140,3 @@ async function executeStreamInternal(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeStream(
|
||||
stream: ActiveStream,
|
||||
message: string,
|
||||
isUserMessage: boolean,
|
||||
context?: { url: string; content: string },
|
||||
retryCount: number = 0,
|
||||
): Promise<void> {
|
||||
return executeStreamInternal({
|
||||
stream,
|
||||
mode: "new",
|
||||
message,
|
||||
isUserMessage,
|
||||
context,
|
||||
retryCount,
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeTaskReconnect(
|
||||
stream: ActiveStream,
|
||||
taskId: string,
|
||||
lastMessageId: string = INITIAL_STREAM_ID,
|
||||
retryCount: number = 0,
|
||||
): Promise<void> {
|
||||
return executeStreamInternal({
|
||||
stream,
|
||||
mode: "reconnect",
|
||||
taskId,
|
||||
lastMessageId,
|
||||
retryCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export function normalizeStreamChunk(
|
||||
|
||||
switch (chunk.type) {
|
||||
case "text-delta":
|
||||
// Vercel AI SDK sends "delta" for text content
|
||||
return { type: "text_chunk", content: chunk.delta };
|
||||
case "text-end":
|
||||
return { type: "text_ended" };
|
||||
@@ -64,10 +63,6 @@ export function normalizeStreamChunk(
|
||||
case "finish":
|
||||
return { type: "stream_end" };
|
||||
case "start":
|
||||
// Start event with optional taskId for reconnection
|
||||
return chunk.taskId
|
||||
? { type: "stream_start", taskId: chunk.taskId }
|
||||
: null;
|
||||
case "text-start":
|
||||
return null;
|
||||
case "tool-input-start":
|
||||
|
||||
@@ -62,7 +62,7 @@ export function Navbar() {
|
||||
<PreviewBanner branchName={previewBranchName} />
|
||||
) : null}
|
||||
<nav
|
||||
className="inline-flex w-full items-center border border-none bg-[#FAFAFA] p-3 backdrop-blur-[26px]"
|
||||
className="border-zinc-[#EFEFF0] inline-flex w-full items-center border border-[#EFEFF0] bg-[#F3F4F6]/20 p-3 backdrop-blur-[26px]"
|
||||
style={{ height: NAVBAR_HEIGHT_PX }}
|
||||
>
|
||||
{/* Left section */}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"shadow-xs flex items-center gap-2 rounded-md border border-neutral-200 bg-neutral-100 px-4 text-sm font-medium dark:border-neutral-800 dark:bg-neutral-800 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"relative !m-0 self-stretch bg-neutral-200 data-[orientation=vertical]:h-auto dark:bg-neutral-800",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-neutral-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90",
|
||||
destructive:
|
||||
"bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90",
|
||||
outline:
|
||||
"border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
|
||||
secondary:
|
||||
"bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
|
||||
ghost:
|
||||
"hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
|
||||
link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
"icon-sm": "h-8 w-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-neutral-200 bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-neutral-950 placeholder:text-neutral-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:file:text-neutral-50 dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-300 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-neutral-200 dark:bg-neutral-800",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -1,143 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-neutral-950",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity data-[state=open]:bg-neutral-100 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none dark:ring-offset-neutral-950 dark:data-[state=open]:bg-neutral-800 dark:focus:ring-neutral-300">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold text-neutral-950 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -1,781 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "20rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "20rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarProvider.displayName = "SidebarProvider";
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
SidebarTrigger.displayName = "SidebarTrigger";
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarRail.displayName = "SidebarRail";
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"main">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-white dark:bg-neutral-950",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInset.displayName = "SidebarInset";
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-white shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring dark:bg-neutral-950",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInput.displayName = "SidebarInput";
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarFooter.displayName = "SidebarFooter";
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarSeparator.displayName = "SidebarSeparator";
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarContent.displayName = "SidebarContent";
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroup.displayName = "SidebarGroup";
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent";
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenu.displayName = "SidebarMenu";
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-white shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))] dark:bg-neutral-950",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge";
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub";
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />);
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse rounded-md bg-neutral-900/10 dark:bg-neutral-50/10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md bg-neutral-900 px-3 py-1.5 text-xs text-neutral-50 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:bg-neutral-50 dark:text-neutral-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const defaultFlags = {
|
||||
[Flag.AGENT_FAVORITING]: false,
|
||||
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,
|
||||
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
|
||||
[Flag.CHAT]: true,
|
||||
[Flag.CHAT]: false,
|
||||
};
|
||||
|
||||
type FlagValues = typeof defaultFlags;
|
||||
|
||||
@@ -65,16 +65,6 @@ const config = {
|
||||
"600": "#282828",
|
||||
"700": "#272727",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
"0": "0rem",
|
||||
|
||||
60
classic/frontend/build/web/flutter_service_worker.js
generated
60
classic/frontend/build/web/flutter_service_worker.js
generated
@@ -3,45 +3,45 @@ const MANIFEST = 'flutter-app-manifest';
|
||||
const TEMP = 'flutter-temp-cache';
|
||||
const CACHE_NAME = 'flutter-app-cache';
|
||||
|
||||
const RESOURCES = {"canvaskit/skwasm.worker.js": "51253d3321b11ddb8d73fa8aa87d3b15",
|
||||
"canvaskit/skwasm.js": "95f16c6690f955a45b2317496983dbe9",
|
||||
"canvaskit/canvaskit.wasm": "d9f69e0f428f695dc3d66b3a83a4aa8e",
|
||||
"canvaskit/skwasm.wasm": "d1fde2560be92c0b07ad9cf9acb10d05",
|
||||
"canvaskit/canvaskit.js": "5caccb235fad20e9b72ea6da5a0094e6",
|
||||
"canvaskit/chromium/canvaskit.wasm": "393ec8fb05d94036734f8104fa550a67",
|
||||
"canvaskit/chromium/canvaskit.js": "ffb2bb6484d5689d91f393b60664d530",
|
||||
"icons/Icon-maskable-192.png": "c457ef57daa1d16f64b27b786ec2ea3c",
|
||||
"icons/Icon-maskable-512.png": "301a7604d45b3e739efc881eb04896ea",
|
||||
const RESOURCES = {"flutter.js": "6fef97aeca90b426343ba6c5c9dc5d4a",
|
||||
"icons/Icon-512.png": "96e752610906ba2a93c65f8abe1645f1",
|
||||
"icons/Icon-maskable-512.png": "301a7604d45b3e739efc881eb04896ea",
|
||||
"icons/Icon-192.png": "ac9a721a12bbc803b44f645561ecb1e1",
|
||||
"icons/Icon-maskable-192.png": "c457ef57daa1d16f64b27b786ec2ea3c",
|
||||
"manifest.json": "0fa552613b8ec0fda5cda565914e3b16",
|
||||
"favicon.png": "5dcef449791fa27946b3d35ad8803796",
|
||||
"version.json": "46a52461e018faa623d9196334aa3f50",
|
||||
"index.html": "e6981504a32bf86f892909c1875df208",
|
||||
"/": "e6981504a32bf86f892909c1875df208",
|
||||
"main.dart.js": "6fcbf8bbcb0a76fae9029f72ac7fbdc3",
|
||||
"assets/AssetManifest.json": "1b1e4a4276722b65eb1ef765e2991840",
|
||||
"assets/packages/cupertino_icons/assets/CupertinoIcons.ttf": "055d9e87e4a40dbf72b2af1a20865d57",
|
||||
"assets/packages/fluttertoast/assets/toastify.js": "56e2c9cedd97f10e7e5f1cebd85d53e3",
|
||||
"assets/packages/fluttertoast/assets/toastify.css": "a85675050054f179444bc5ad70ffc635",
|
||||
"index.html": "3442c510a9ea217672c82e799ae070f7",
|
||||
"/": "3442c510a9ea217672c82e799ae070f7",
|
||||
"assets/shaders/ink_sparkle.frag": "f8b80e740d33eb157090be4e995febdf",
|
||||
"assets/fonts/MaterialIcons-Regular.otf": "245e0462249d95ad589a087f1c9f58e1",
|
||||
"assets/assets/images/twitter_logo.png": "af6c11b96a5e732b8dfda86a2351ecab",
|
||||
"assets/assets/images/discord_logo.png": "0e4a4162c5de8665a7d63ae9665405ae",
|
||||
"assets/assets/images/google_logo.svg.png": "0e29f8e1acfb8996437dbb2b0f591f19",
|
||||
"assets/assets/images/autogpt_logo.png": "6a5362a7d1f2f840e43ee259e733476c",
|
||||
"assets/assets/images/github_logo.svg.png": "ba087b073efdc4996b035d3a12bad0e4",
|
||||
"assets/assets/scrape_synthesize_tree_structure.json": "a9665c1b465bb0cb939c7210f2bf0b13",
|
||||
"assets/assets/tree_structure.json": "cda9b1a239f956c547411efad9f7c794",
|
||||
"assets/assets/coding_tree_structure.json": "017a857cf3e274346a0a7eab4ce02eed",
|
||||
"assets/assets/general_tree_structure.json": "41dfbcdc2349dcdda2b082e597c6d5ee",
|
||||
"assets/assets/google_logo.svg.png": "0e29f8e1acfb8996437dbb2b0f591f19",
|
||||
"assets/assets/tree_structure.json": "cda9b1a239f956c547411efad9f7c794",
|
||||
"assets/assets/data_tree_structure.json": "5f9627548304155821968182f3883ca7",
|
||||
"assets/assets/github_logo.svg.png": "ba087b073efdc4996b035d3a12bad0e4",
|
||||
"assets/assets/images/discord_logo.png": "0e4a4162c5de8665a7d63ae9665405ae",
|
||||
"assets/assets/images/github_logo.svg.png": "ba087b073efdc4996b035d3a12bad0e4",
|
||||
"assets/assets/images/twitter_logo.png": "af6c11b96a5e732b8dfda86a2351ecab",
|
||||
"assets/assets/images/google_logo.svg.png": "0e29f8e1acfb8996437dbb2b0f591f19",
|
||||
"assets/assets/images/autogpt_logo.png": "6a5362a7d1f2f840e43ee259e733476c",
|
||||
"assets/assets/google_logo.svg.png": "0e29f8e1acfb8996437dbb2b0f591f19",
|
||||
"assets/assets/scrape_synthesize_tree_structure.json": "a9665c1b465bb0cb939c7210f2bf0b13",
|
||||
"assets/assets/data_tree_structure.json": "5f9627548304155821968182f3883ca7",
|
||||
"assets/fonts/MaterialIcons-Regular.otf": "245e0462249d95ad589a087f1c9f58e1",
|
||||
"assets/NOTICES": "28ba0c63fc6e4d1ef829af7441e27f78",
|
||||
"assets/AssetManifest.bin": "791447d17744ac2ade3999c1672fdbe8",
|
||||
"assets/packages/fluttertoast/assets/toastify.css": "a85675050054f179444bc5ad70ffc635",
|
||||
"assets/packages/fluttertoast/assets/toastify.js": "56e2c9cedd97f10e7e5f1cebd85d53e3",
|
||||
"assets/packages/cupertino_icons/assets/CupertinoIcons.ttf": "055d9e87e4a40dbf72b2af1a20865d57",
|
||||
"assets/FontManifest.json": "dc3d03800ccca4601324923c0b1d6d57",
|
||||
"flutter.js": "6fef97aeca90b426343ba6c5c9dc5d4a"};
|
||||
"assets/AssetManifest.bin": "791447d17744ac2ade3999c1672fdbe8",
|
||||
"assets/AssetManifest.json": "1b1e4a4276722b65eb1ef765e2991840",
|
||||
"canvaskit/chromium/canvaskit.wasm": "393ec8fb05d94036734f8104fa550a67",
|
||||
"canvaskit/chromium/canvaskit.js": "ffb2bb6484d5689d91f393b60664d530",
|
||||
"canvaskit/skwasm.worker.js": "51253d3321b11ddb8d73fa8aa87d3b15",
|
||||
"canvaskit/skwasm.js": "95f16c6690f955a45b2317496983dbe9",
|
||||
"canvaskit/canvaskit.wasm": "d9f69e0f428f695dc3d66b3a83a4aa8e",
|
||||
"canvaskit/canvaskit.js": "5caccb235fad20e9b72ea6da5a0094e6",
|
||||
"canvaskit/skwasm.wasm": "d1fde2560be92c0b07ad9cf9acb10d05",
|
||||
"favicon.png": "5dcef449791fa27946b3d35ad8803796",
|
||||
"version.json": "46a52461e018faa623d9196334aa3f50",
|
||||
"main.dart.js": "6fcbf8bbcb0a76fae9029f72ac7fbdc3"};
|
||||
// The application shell files that are downloaded before a service worker can
|
||||
// start.
|
||||
const CORE = ["main.dart.js",
|
||||
|
||||
2
classic/frontend/build/web/index.html
generated
2
classic/frontend/build/web/index.html
generated
@@ -35,7 +35,7 @@
|
||||
|
||||
<script>
|
||||
// The value below is injected by flutter build, do not touch.
|
||||
const serviceWorkerVersion = "726743092";
|
||||
const serviceWorkerVersion = "1550046101";
|
||||
</script>
|
||||
<!-- This script adds the flutter initialization JS code -->
|
||||
<script src="flutter.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user