mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-07 21:35:34 -05:00
Compare commits
36 Commits
dev
...
abhi/check
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ed8749d6 | ||
|
|
1c9680b6f2 | ||
|
|
251d26a643 | ||
|
|
090c576b3e | ||
|
|
4b036bfe22 | ||
|
|
62edd73020 | ||
|
|
5a878e0af0 | ||
|
|
321733360f | ||
|
|
1f2fc1ba6f | ||
|
|
3805995b09 | ||
|
|
e317a9c18a | ||
|
|
b45e1bc79c | ||
|
|
6fce1f6084 | ||
|
|
df21b96fed | ||
|
|
2502fd6391 | ||
|
|
640b894405 | ||
|
|
ea9f289647 | ||
|
|
d3018cc8ea | ||
|
|
b06868f453 | ||
|
|
7772c71a15 | ||
|
|
8c381faa06 | ||
|
|
d2a1abe3f8 | ||
|
|
15464786c3 | ||
|
|
1b0e1f6e72 | ||
|
|
6730293036 | ||
|
|
432bda5c70 | ||
|
|
e434b59003 | ||
|
|
31ec5f5c17 | ||
|
|
6e0fbdea3c | ||
|
|
b5d6853223 | ||
|
|
afb74a8ff1 | ||
|
|
4c9957dc26 | ||
|
|
26add35418 | ||
|
|
c6e5f83de8 | ||
|
|
c3a126e705 | ||
|
|
73d8323fe4 |
@@ -18,6 +18,10 @@ class ResponseType(str, Enum):
|
|||||||
START = "start"
|
START = "start"
|
||||||
FINISH = "finish"
|
FINISH = "finish"
|
||||||
|
|
||||||
|
# Step lifecycle (one LLM API call within a message)
|
||||||
|
START_STEP = "start-step"
|
||||||
|
FINISH_STEP = "finish-step"
|
||||||
|
|
||||||
# Text streaming
|
# Text streaming
|
||||||
TEXT_START = "text-start"
|
TEXT_START = "text-start"
|
||||||
TEXT_DELTA = "text-delta"
|
TEXT_DELTA = "text-delta"
|
||||||
@@ -57,6 +61,16 @@ class StreamStart(StreamBaseResponse):
|
|||||||
description="Task ID for SSE reconnection. Clients can reconnect using GET /tasks/{taskId}/stream",
|
description="Task ID for SSE reconnection. Clients can reconnect using GET /tasks/{taskId}/stream",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def to_sse(self) -> str:
|
||||||
|
"""Convert to SSE format, excluding non-protocol fields like taskId."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"type": self.type.value,
|
||||||
|
"messageId": self.messageId,
|
||||||
|
}
|
||||||
|
return f"data: {json.dumps(data)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
class StreamFinish(StreamBaseResponse):
|
class StreamFinish(StreamBaseResponse):
|
||||||
"""End of message/stream."""
|
"""End of message/stream."""
|
||||||
@@ -64,6 +78,26 @@ class StreamFinish(StreamBaseResponse):
|
|||||||
type: ResponseType = ResponseType.FINISH
|
type: ResponseType = ResponseType.FINISH
|
||||||
|
|
||||||
|
|
||||||
|
class StreamStartStep(StreamBaseResponse):
|
||||||
|
"""Start of a step (one LLM API call within a message).
|
||||||
|
|
||||||
|
The AI SDK uses this to add a step-start boundary to message.parts,
|
||||||
|
enabling visual separation between multiple LLM calls in a single message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: ResponseType = ResponseType.START_STEP
|
||||||
|
|
||||||
|
|
||||||
|
class StreamFinishStep(StreamBaseResponse):
|
||||||
|
"""End of a step (one LLM API call within a message).
|
||||||
|
|
||||||
|
The AI SDK uses this to reset activeTextParts and activeReasoningParts,
|
||||||
|
so the next LLM call in a tool-call continuation starts with clean state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: ResponseType = ResponseType.FINISH_STEP
|
||||||
|
|
||||||
|
|
||||||
# ========== Text Streaming ==========
|
# ========== Text Streaming ==========
|
||||||
|
|
||||||
|
|
||||||
@@ -117,7 +151,7 @@ class StreamToolOutputAvailable(StreamBaseResponse):
|
|||||||
type: ResponseType = ResponseType.TOOL_OUTPUT_AVAILABLE
|
type: ResponseType = ResponseType.TOOL_OUTPUT_AVAILABLE
|
||||||
toolCallId: str = Field(..., description="Tool call ID this responds to")
|
toolCallId: str = Field(..., description="Tool call ID this responds to")
|
||||||
output: str | dict[str, Any] = Field(..., description="Tool execution output")
|
output: str | dict[str, Any] = Field(..., description="Tool execution output")
|
||||||
# Additional fields for internal use (not part of AI SDK spec but useful)
|
# Keep these for internal backend use
|
||||||
toolName: str | None = Field(
|
toolName: str | None = Field(
|
||||||
default=None, description="Name of the tool that was executed"
|
default=None, description="Name of the tool that was executed"
|
||||||
)
|
)
|
||||||
@@ -125,6 +159,17 @@ class StreamToolOutputAvailable(StreamBaseResponse):
|
|||||||
default=True, description="Whether the tool execution succeeded"
|
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 ==========
|
# ========== Other ==========
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from collections.abc import AsyncGenerator
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from autogpt_libs import auth
|
from autogpt_libs import auth
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Security
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, Security
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -17,7 +17,29 @@ from . import stream_registry
|
|||||||
from .completion_handler import process_operation_failure, process_operation_success
|
from .completion_handler import process_operation_failure, process_operation_success
|
||||||
from .config import ChatConfig
|
from .config import ChatConfig
|
||||||
from .model import ChatSession, create_chat_session, get_chat_session, get_user_sessions
|
from .model import ChatSession, create_chat_session, get_chat_session, get_user_sessions
|
||||||
from .response_model import StreamFinish, StreamHeartbeat, StreamStart
|
from .response_model import StreamFinish, StreamHeartbeat
|
||||||
|
from .tools.models import (
|
||||||
|
AgentDetailsResponse,
|
||||||
|
AgentOutputResponse,
|
||||||
|
AgentPreviewResponse,
|
||||||
|
AgentSavedResponse,
|
||||||
|
AgentsFoundResponse,
|
||||||
|
BlockListResponse,
|
||||||
|
BlockOutputResponse,
|
||||||
|
ClarificationNeededResponse,
|
||||||
|
DocPageResponse,
|
||||||
|
DocSearchResultsResponse,
|
||||||
|
ErrorResponse,
|
||||||
|
ExecutionStartedResponse,
|
||||||
|
InputValidationErrorResponse,
|
||||||
|
NeedLoginResponse,
|
||||||
|
NoResultsResponse,
|
||||||
|
OperationInProgressResponse,
|
||||||
|
OperationPendingResponse,
|
||||||
|
OperationStartedResponse,
|
||||||
|
SetupRequirementsResponse,
|
||||||
|
UnderstandingUpdatedResponse,
|
||||||
|
)
|
||||||
|
|
||||||
config = ChatConfig()
|
config = ChatConfig()
|
||||||
|
|
||||||
@@ -284,10 +306,6 @@ async def stream_chat_post(
|
|||||||
# Background task that runs the AI generation independently of SSE connection
|
# Background task that runs the AI generation independently of SSE connection
|
||||||
async def run_ai_generation():
|
async def run_ai_generation():
|
||||||
try:
|
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(
|
async for chunk in chat_service.stream_chat_completion(
|
||||||
session_id,
|
session_id,
|
||||||
request.message,
|
request.message,
|
||||||
@@ -295,6 +313,7 @@ async def stream_chat_post(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
session=session, # Pass pre-fetched session to avoid double-fetch
|
session=session, # Pass pre-fetched session to avoid double-fetch
|
||||||
context=request.context,
|
context=request.context,
|
||||||
|
_task_id=task_id, # Pass task_id so service emits start with taskId for reconnection
|
||||||
):
|
):
|
||||||
# Write to Redis (subscribers will receive via XREAD)
|
# Write to Redis (subscribers will receive via XREAD)
|
||||||
await stream_registry.publish_chunk(task_id, chunk)
|
await stream_registry.publish_chunk(task_id, chunk)
|
||||||
@@ -374,63 +393,73 @@ async def stream_chat_post(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/sessions/{session_id}/stream",
|
"/sessions/{session_id}/stream",
|
||||||
)
|
)
|
||||||
async def stream_chat_get(
|
async def resume_session_stream(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
message: Annotated[str, Query(min_length=1, max_length=10000)],
|
|
||||||
user_id: str | None = Depends(auth.get_user_id),
|
user_id: str | None = Depends(auth.get_user_id),
|
||||||
is_user_message: bool = Query(default=True),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Stream chat responses for a session (GET - legacy endpoint).
|
Resume an active stream for a session.
|
||||||
|
|
||||||
Streams the AI/completion responses in real time over Server-Sent Events (SSE), including:
|
Called by the AI SDK's ``useChat(resume: true)`` on page load.
|
||||||
- Text fragments as they are generated
|
Checks for an active (in-progress) task on the session and either replays
|
||||||
- Tool call UI elements (if invoked)
|
the full SSE stream or returns 204 No Content if nothing is running.
|
||||||
- Tool execution results
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session_id: The chat session identifier to associate with the streamed messages.
|
session_id: The chat session identifier.
|
||||||
message: The user's new message to process.
|
|
||||||
user_id: Optional authenticated user ID.
|
user_id: Optional authenticated user ID.
|
||||||
is_user_message: Whether the message is a user message.
|
|
||||||
Returns:
|
|
||||||
StreamingResponse: SSE-formatted response chunks.
|
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse (SSE) when an active stream exists,
|
||||||
|
or 204 No Content when there is nothing to resume.
|
||||||
"""
|
"""
|
||||||
session = await _validate_and_get_session(session_id, user_id)
|
import asyncio
|
||||||
|
|
||||||
|
active_task, _last_id = await stream_registry.get_active_task_for_session(
|
||||||
|
session_id, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not active_task:
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
subscriber_queue = await stream_registry.subscribe_to_task(
|
||||||
|
task_id=active_task.task_id,
|
||||||
|
user_id=user_id,
|
||||||
|
last_message_id="0-0", # Full replay so useChat rebuilds the message
|
||||||
|
)
|
||||||
|
|
||||||
|
if subscriber_queue is None:
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
async def event_generator() -> AsyncGenerator[str, None]:
|
async def event_generator() -> AsyncGenerator[str, None]:
|
||||||
chunk_count = 0
|
try:
|
||||||
first_chunk_type: str | None = None
|
while True:
|
||||||
async for chunk in chat_service.stream_chat_completion(
|
try:
|
||||||
session_id,
|
chunk = await asyncio.wait_for(
|
||||||
message,
|
subscriber_queue.get(), timeout=30.0
|
||||||
is_user_message=is_user_message,
|
)
|
||||||
user_id=user_id,
|
yield chunk.to_sse()
|
||||||
session=session, # Pass pre-fetched session to avoid double-fetch
|
|
||||||
):
|
if isinstance(chunk, StreamFinish):
|
||||||
if chunk_count < 3:
|
break
|
||||||
logger.info(
|
except asyncio.TimeoutError:
|
||||||
"Chat stream chunk",
|
yield StreamHeartbeat().to_sse()
|
||||||
extra={
|
except GeneratorExit:
|
||||||
"session_id": session_id,
|
pass
|
||||||
"chunk_type": str(chunk.type),
|
except Exception as e:
|
||||||
},
|
logger.error(
|
||||||
|
f"Error in resume stream for session {session_id}: {e}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await stream_registry.unsubscribe_from_task(
|
||||||
|
active_task.task_id, subscriber_queue
|
||||||
)
|
)
|
||||||
if not first_chunk_type:
|
except Exception as unsub_err:
|
||||||
first_chunk_type = str(chunk.type)
|
logger.error(
|
||||||
chunk_count += 1
|
f"Error unsubscribing from task {active_task.task_id}: {unsub_err}",
|
||||||
yield chunk.to_sse()
|
exc_info=True,
|
||||||
logger.info(
|
)
|
||||||
"Chat stream completed",
|
yield "data: [DONE]\n\n"
|
||||||
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(
|
return StreamingResponse(
|
||||||
event_generator(),
|
event_generator(),
|
||||||
@@ -438,8 +467,8 @@ async def stream_chat_get(
|
|||||||
headers={
|
headers={
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"X-Accel-Buffering": "no", # Disable nginx buffering
|
"X-Accel-Buffering": "no",
|
||||||
"x-vercel-ai-ui-message-stream": "v1", # AI SDK protocol header
|
"x-vercel-ai-ui-message-stream": "v1",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -751,3 +780,42 @@ async def health_check() -> dict:
|
|||||||
"service": "chat",
|
"service": "chat",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Schema Export (for OpenAPI / Orval codegen) ==========
|
||||||
|
|
||||||
|
ToolResponseUnion = (
|
||||||
|
AgentsFoundResponse
|
||||||
|
| NoResultsResponse
|
||||||
|
| AgentDetailsResponse
|
||||||
|
| SetupRequirementsResponse
|
||||||
|
| ExecutionStartedResponse
|
||||||
|
| NeedLoginResponse
|
||||||
|
| ErrorResponse
|
||||||
|
| InputValidationErrorResponse
|
||||||
|
| AgentOutputResponse
|
||||||
|
| UnderstandingUpdatedResponse
|
||||||
|
| AgentPreviewResponse
|
||||||
|
| AgentSavedResponse
|
||||||
|
| ClarificationNeededResponse
|
||||||
|
| BlockListResponse
|
||||||
|
| BlockOutputResponse
|
||||||
|
| DocSearchResultsResponse
|
||||||
|
| DocPageResponse
|
||||||
|
| OperationStartedResponse
|
||||||
|
| OperationPendingResponse
|
||||||
|
| OperationInProgressResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/schema/tool-responses",
|
||||||
|
response_model=ToolResponseUnion,
|
||||||
|
include_in_schema=True,
|
||||||
|
summary="[Dummy] Tool response type export for codegen",
|
||||||
|
description="This endpoint is not meant to be called. It exists solely to "
|
||||||
|
"expose tool response models in the OpenAPI schema for frontend codegen.",
|
||||||
|
)
|
||||||
|
async def _tool_response_schema() -> ToolResponseUnion: # type: ignore[return]
|
||||||
|
"""Never called at runtime. Exists only so Orval generates TS types."""
|
||||||
|
raise HTTPException(status_code=501, detail="Schema-only endpoint")
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ from .response_model import (
|
|||||||
StreamBaseResponse,
|
StreamBaseResponse,
|
||||||
StreamError,
|
StreamError,
|
||||||
StreamFinish,
|
StreamFinish,
|
||||||
|
StreamFinishStep,
|
||||||
StreamHeartbeat,
|
StreamHeartbeat,
|
||||||
StreamStart,
|
StreamStart,
|
||||||
|
StreamStartStep,
|
||||||
StreamTextDelta,
|
StreamTextDelta,
|
||||||
StreamTextEnd,
|
StreamTextEnd,
|
||||||
StreamTextStart,
|
StreamTextStart,
|
||||||
@@ -351,6 +353,10 @@ async def stream_chat_completion(
|
|||||||
retry_count: int = 0,
|
retry_count: int = 0,
|
||||||
session: ChatSession | None = None,
|
session: ChatSession | None = None,
|
||||||
context: dict[str, str] | None = None, # {url: str, content: str}
|
context: dict[str, str] | None = None, # {url: str, content: str}
|
||||||
|
_continuation_message_id: (
|
||||||
|
str | None
|
||||||
|
) = None, # Internal: reuse message ID for tool call continuations
|
||||||
|
_task_id: str | None = None, # Internal: task ID for SSE reconnection support
|
||||||
) -> AsyncGenerator[StreamBaseResponse, None]:
|
) -> AsyncGenerator[StreamBaseResponse, None]:
|
||||||
"""Main entry point for streaming chat completions with database handling.
|
"""Main entry point for streaming chat completions with database handling.
|
||||||
|
|
||||||
@@ -479,11 +485,17 @@ async def stream_chat_completion(
|
|||||||
# Generate unique IDs for AI SDK protocol
|
# Generate unique IDs for AI SDK protocol
|
||||||
import uuid as uuid_module
|
import uuid as uuid_module
|
||||||
|
|
||||||
message_id = str(uuid_module.uuid4())
|
is_continuation = _continuation_message_id is not None
|
||||||
|
message_id = _continuation_message_id or str(uuid_module.uuid4())
|
||||||
text_block_id = str(uuid_module.uuid4())
|
text_block_id = str(uuid_module.uuid4())
|
||||||
|
|
||||||
# Yield message start
|
# Only yield message start for the initial call, not for continuations.
|
||||||
yield StreamStart(messageId=message_id)
|
# This is the single place where StreamStart is emitted (removed from routes.py).
|
||||||
|
if not is_continuation:
|
||||||
|
yield StreamStart(messageId=message_id, taskId=_task_id)
|
||||||
|
|
||||||
|
# Emit start-step before each LLM call (AI SDK uses this to add step boundaries)
|
||||||
|
yield StreamStartStep()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for chunk in _stream_chat_chunks(
|
async for chunk in _stream_chat_chunks(
|
||||||
@@ -585,6 +597,10 @@ async def stream_chat_completion(
|
|||||||
)
|
)
|
||||||
yield chunk
|
yield chunk
|
||||||
elif isinstance(chunk, StreamFinish):
|
elif isinstance(chunk, StreamFinish):
|
||||||
|
if has_done_tool_call:
|
||||||
|
# Tool calls happened — close the step but don't send message-level finish.
|
||||||
|
# The continuation will open a new step, and finish will come at the end.
|
||||||
|
yield StreamFinishStep()
|
||||||
if not has_done_tool_call:
|
if not has_done_tool_call:
|
||||||
# Emit text-end before finish if we received text but haven't closed it
|
# Emit text-end before finish if we received text but haven't closed it
|
||||||
if has_received_text and not text_streaming_ended:
|
if has_received_text and not text_streaming_ended:
|
||||||
@@ -616,6 +632,8 @@ async def stream_chat_completion(
|
|||||||
has_saved_assistant_message = True
|
has_saved_assistant_message = True
|
||||||
|
|
||||||
has_yielded_end = True
|
has_yielded_end = True
|
||||||
|
# Emit finish-step before finish (resets AI SDK text/reasoning state)
|
||||||
|
yield StreamFinishStep()
|
||||||
yield chunk
|
yield chunk
|
||||||
elif isinstance(chunk, StreamError):
|
elif isinstance(chunk, StreamError):
|
||||||
has_yielded_error = True
|
has_yielded_error = True
|
||||||
@@ -700,6 +718,7 @@ async def stream_chat_completion(
|
|||||||
error_response = StreamError(errorText=error_message)
|
error_response = StreamError(errorText=error_message)
|
||||||
yield error_response
|
yield error_response
|
||||||
if not has_yielded_end:
|
if not has_yielded_end:
|
||||||
|
yield StreamFinishStep()
|
||||||
yield StreamFinish()
|
yield StreamFinish()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -714,6 +733,8 @@ async def stream_chat_completion(
|
|||||||
retry_count=retry_count + 1,
|
retry_count=retry_count + 1,
|
||||||
session=session,
|
session=session,
|
||||||
context=context,
|
context=context,
|
||||||
|
_continuation_message_id=message_id, # Reuse message ID since start was already sent
|
||||||
|
_task_id=_task_id,
|
||||||
):
|
):
|
||||||
yield chunk
|
yield chunk
|
||||||
return # Exit after retry to avoid double-saving in finally block
|
return # Exit after retry to avoid double-saving in finally block
|
||||||
@@ -783,6 +804,8 @@ async def stream_chat_completion(
|
|||||||
session=session, # Pass session object to avoid Redis refetch
|
session=session, # Pass session object to avoid Redis refetch
|
||||||
context=context,
|
context=context,
|
||||||
tool_call_response=str(tool_response_messages),
|
tool_call_response=str(tool_response_messages),
|
||||||
|
_continuation_message_id=message_id, # Reuse message ID to avoid duplicates
|
||||||
|
_task_id=_task_id,
|
||||||
):
|
):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
@@ -1565,6 +1588,7 @@ async def _execute_long_running_tool_with_streaming(
|
|||||||
task_id,
|
task_id,
|
||||||
StreamError(errorText=str(e)),
|
StreamError(errorText=str(e)),
|
||||||
)
|
)
|
||||||
|
await stream_registry.publish_chunk(task_id, StreamFinishStep())
|
||||||
await stream_registry.publish_chunk(task_id, StreamFinish())
|
await stream_registry.publish_chunk(task_id, StreamFinish())
|
||||||
|
|
||||||
await _update_pending_operation(
|
await _update_pending_operation(
|
||||||
@@ -1822,6 +1846,7 @@ async def _generate_llm_continuation_with_streaming(
|
|||||||
|
|
||||||
# Publish start event
|
# Publish start event
|
||||||
await stream_registry.publish_chunk(task_id, StreamStart(messageId=message_id))
|
await stream_registry.publish_chunk(task_id, StreamStart(messageId=message_id))
|
||||||
|
await stream_registry.publish_chunk(task_id, StreamStartStep())
|
||||||
await stream_registry.publish_chunk(task_id, StreamTextStart(id=text_block_id))
|
await stream_registry.publish_chunk(task_id, StreamTextStart(id=text_block_id))
|
||||||
|
|
||||||
# Stream the response
|
# Stream the response
|
||||||
@@ -1845,6 +1870,7 @@ async def _generate_llm_continuation_with_streaming(
|
|||||||
|
|
||||||
# Publish end events
|
# Publish end events
|
||||||
await stream_registry.publish_chunk(task_id, StreamTextEnd(id=text_block_id))
|
await stream_registry.publish_chunk(task_id, StreamTextEnd(id=text_block_id))
|
||||||
|
await stream_registry.publish_chunk(task_id, StreamFinishStep())
|
||||||
|
|
||||||
if assistant_content:
|
if assistant_content:
|
||||||
# Reload session from DB to avoid race condition with user messages
|
# Reload session from DB to avoid race condition with user messages
|
||||||
@@ -1886,4 +1912,5 @@ async def _generate_llm_continuation_with_streaming(
|
|||||||
task_id,
|
task_id,
|
||||||
StreamError(errorText=f"Failed to generate response: {e}"),
|
StreamError(errorText=f"Failed to generate response: {e}"),
|
||||||
)
|
)
|
||||||
|
await stream_registry.publish_chunk(task_id, StreamFinishStep())
|
||||||
await stream_registry.publish_chunk(task_id, StreamFinish())
|
await stream_registry.publish_chunk(task_id, StreamFinish())
|
||||||
|
|||||||
@@ -598,8 +598,10 @@ def _reconstruct_chunk(chunk_data: dict) -> StreamBaseResponse | None:
|
|||||||
ResponseType,
|
ResponseType,
|
||||||
StreamError,
|
StreamError,
|
||||||
StreamFinish,
|
StreamFinish,
|
||||||
|
StreamFinishStep,
|
||||||
StreamHeartbeat,
|
StreamHeartbeat,
|
||||||
StreamStart,
|
StreamStart,
|
||||||
|
StreamStartStep,
|
||||||
StreamTextDelta,
|
StreamTextDelta,
|
||||||
StreamTextEnd,
|
StreamTextEnd,
|
||||||
StreamTextStart,
|
StreamTextStart,
|
||||||
@@ -613,6 +615,8 @@ def _reconstruct_chunk(chunk_data: dict) -> StreamBaseResponse | None:
|
|||||||
type_to_class: dict[str, type[StreamBaseResponse]] = {
|
type_to_class: dict[str, type[StreamBaseResponse]] = {
|
||||||
ResponseType.START.value: StreamStart,
|
ResponseType.START.value: StreamStart,
|
||||||
ResponseType.FINISH.value: StreamFinish,
|
ResponseType.FINISH.value: StreamFinish,
|
||||||
|
ResponseType.START_STEP.value: StreamStartStep,
|
||||||
|
ResponseType.FINISH_STEP.value: StreamFinishStep,
|
||||||
ResponseType.TEXT_START.value: StreamTextStart,
|
ResponseType.TEXT_START.value: StreamTextStart,
|
||||||
ResponseType.TEXT_DELTA.value: StreamTextDelta,
|
ResponseType.TEXT_DELTA.value: StreamTextDelta,
|
||||||
ResponseType.TEXT_END.value: StreamTextEnd,
|
ResponseType.TEXT_END.value: StreamTextEnd,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"defaults"
|
"defaults"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "3.0.61",
|
||||||
"@faker-js/faker": "10.0.0",
|
"@faker-js/faker": "10.0.0",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@next/third-parties": "15.4.6",
|
"@next/third-parties": "15.4.6",
|
||||||
@@ -60,6 +61,10 @@
|
|||||||
"@rjsf/utils": "6.1.2",
|
"@rjsf/utils": "6.1.2",
|
||||||
"@rjsf/validator-ajv8": "6.1.2",
|
"@rjsf/validator-ajv8": "6.1.2",
|
||||||
"@sentry/nextjs": "10.27.0",
|
"@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/ssr": "0.7.0",
|
||||||
"@supabase/supabase-js": "2.78.0",
|
"@supabase/supabase-js": "2.78.0",
|
||||||
"@tanstack/react-query": "5.90.6",
|
"@tanstack/react-query": "5.90.6",
|
||||||
@@ -68,6 +73,7 @@
|
|||||||
"@vercel/analytics": "1.5.0",
|
"@vercel/analytics": "1.5.0",
|
||||||
"@vercel/speed-insights": "1.2.0",
|
"@vercel/speed-insights": "1.2.0",
|
||||||
"@xyflow/react": "12.9.2",
|
"@xyflow/react": "12.9.2",
|
||||||
|
"ai": "6.0.59",
|
||||||
"boring-avatars": "1.11.2",
|
"boring-avatars": "1.11.2",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -112,9 +118,11 @@
|
|||||||
"remark-math": "6.0.0",
|
"remark-math": "6.0.0",
|
||||||
"shepherd.js": "14.5.1",
|
"shepherd.js": "14.5.1",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
|
"streamdown": "2.1.0",
|
||||||
"tailwind-merge": "2.6.0",
|
"tailwind-merge": "2.6.0",
|
||||||
"tailwind-scrollbar": "3.1.0",
|
"tailwind-scrollbar": "3.1.0",
|
||||||
"tailwindcss-animate": "1.0.7",
|
"tailwindcss-animate": "1.0.7",
|
||||||
|
"use-stick-to-bottom": "1.1.2",
|
||||||
"uuid": "11.1.0",
|
"uuid": "11.1.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"zod": "3.25.76",
|
"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
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
|
||||||
|
import { UIDataTypes, UIMessage, UITools } from "ai";
|
||||||
|
import { LayoutGroup, motion } from "framer-motion";
|
||||||
|
import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer";
|
||||||
|
import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider";
|
||||||
|
import { EmptySession } from "../EmptySession/EmptySession";
|
||||||
|
|
||||||
|
export interface ChatContainerProps {
|
||||||
|
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||||
|
status: string;
|
||||||
|
error: Error | undefined;
|
||||||
|
sessionId: string | null;
|
||||||
|
isLoadingSession: boolean;
|
||||||
|
isCreatingSession: boolean;
|
||||||
|
onCreateSession: () => void | Promise<string>;
|
||||||
|
onSend: (message: string) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
export const ChatContainer = ({
|
||||||
|
messages,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
sessionId,
|
||||||
|
isLoadingSession,
|
||||||
|
isCreatingSession,
|
||||||
|
onCreateSession,
|
||||||
|
onSend,
|
||||||
|
}: ChatContainerProps) => {
|
||||||
|
const inputLayoutId = "copilot-2-chat-input";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CopilotChatActionsProvider onSend={onSend}>
|
||||||
|
<LayoutGroup id="copilot-2-chat-layout">
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col bg-[#f8f8f9] px-2 lg:px-0">
|
||||||
|
{sessionId ? (
|
||||||
|
<div className="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col">
|
||||||
|
<ChatMessagesContainer
|
||||||
|
messages={messages}
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
isLoading={isLoadingSession}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
layoutId={inputLayoutId}
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
|
||||||
|
className="relative px-3 pb-2 pt-2"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute left-0 right-0 top-[-18px] z-10 h-6 bg-gradient-to-b from-transparent to-[#f8f8f9]" />
|
||||||
|
<ChatInput
|
||||||
|
inputId="chat-input-session"
|
||||||
|
onSend={onSend}
|
||||||
|
disabled={status === "streaming"}
|
||||||
|
isStreaming={status === "streaming"}
|
||||||
|
onStop={() => {}}
|
||||||
|
placeholder="What else can I help with?"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptySession
|
||||||
|
inputLayoutId={inputLayoutId}
|
||||||
|
isCreatingSession={isCreatingSession}
|
||||||
|
onCreateSession={onCreateSession}
|
||||||
|
onSend={onSend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LayoutGroup>
|
||||||
|
</CopilotChatActionsProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
Conversation,
|
||||||
|
ConversationContent,
|
||||||
|
ConversationScrollButton,
|
||||||
|
} from "@/components/ai-elements/conversation";
|
||||||
|
import {
|
||||||
|
Message,
|
||||||
|
MessageContent,
|
||||||
|
MessageResponse,
|
||||||
|
} from "@/components/ai-elements/message";
|
||||||
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
|
import { UIDataTypes, UIMessage, 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;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatMessagesContainer = ({
|
||||||
|
messages,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
}: ChatMessagesContainerProps) => {
|
||||||
|
return (
|
||||||
|
<Conversation className="min-h-0 flex-1">
|
||||||
|
<ConversationContent className="gap-6 px-3 py-6">
|
||||||
|
{isLoading && messages.length === 0 && (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<LoadingSpinner size="large" className="text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((message) => (
|
||||||
|
<Message from={message.role} key={message.id}>
|
||||||
|
<MessageContent
|
||||||
|
className={
|
||||||
|
"text-[1rem] leading-relaxed " +
|
||||||
|
"group-[.is-user]:rounded-xl group-[.is-user]:bg-purple-100 group-[.is-user]:px-3 group-[.is-user]:py-2.5 group-[.is-user]:text-slate-900 group-[.is-user]:[border-bottom-right-radius:0] " +
|
||||||
|
"group-[.is-assistant]:bg-transparent 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 className="text-[1rem] leading-relaxed">
|
||||||
|
<span className="inline-block animate-shimmer bg-gradient-to-r from-neutral-400 via-neutral-600 to-neutral-400 bg-[length:200%_100%] bg-clip-text text-transparent">
|
||||||
|
Thinking...
|
||||||
|
</span>
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-3 text-red-600">
|
||||||
|
Error: {error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConversationContent>
|
||||||
|
<ConversationScrollButton />
|
||||||
|
</Conversation>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PlusCircleIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SpinnerGapIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { parseAsString, useQueryState } from "nuqs";
|
||||||
|
|
||||||
|
export function ChatSidebar() {
|
||||||
|
const { state } = useSidebar();
|
||||||
|
const isCollapsed = state === "collapsed";
|
||||||
|
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
|
||||||
|
|
||||||
|
const { data: sessionsResponse, isLoading: isLoadingSessions } =
|
||||||
|
useGetV2ListSessions({ limit: 50 });
|
||||||
|
|
||||||
|
const sessions =
|
||||||
|
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
|
||||||
|
|
||||||
|
function handleNewChat() {
|
||||||
|
setSessionId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
|
||||||
|
const day = date.getDate();
|
||||||
|
const ordinal =
|
||||||
|
day % 10 === 1 && day !== 11
|
||||||
|
? "st"
|
||||||
|
: day % 10 === 2 && day !== 12
|
||||||
|
? "nd"
|
||||||
|
: day % 10 === 3 && day !== 13
|
||||||
|
? "rd"
|
||||||
|
: "th";
|
||||||
|
const month = date.toLocaleDateString("en-US", { month: "short" });
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
return `${day}${ordinal} ${month} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
variant="inset"
|
||||||
|
collapsible="icon"
|
||||||
|
className="!top-[50px] !h-[calc(100vh-50px)] border-r border-zinc-100 px-0"
|
||||||
|
>
|
||||||
|
{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="flex flex-col items-center gap-3 pt-4"
|
||||||
|
initial={{ opacity: 0, filter: "blur(3px)" }}
|
||||||
|
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||||
|
transition={{ type: "spring", bounce: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleNewChat}
|
||||||
|
style={{ minWidth: "auto", width: "auto" }}
|
||||||
|
>
|
||||||
|
<PlusCircleIcon className="!size-5" />
|
||||||
|
<span className="sr-only">New Chat</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</SidebarHeader>
|
||||||
|
)}
|
||||||
|
<SidebarContent className="gap-4 overflow-y-auto px-4 py-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
{!isCollapsed && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2, delay: 0.1 }}
|
||||||
|
className="flex items-center justify-between px-3"
|
||||||
|
>
|
||||||
|
<Text variant="h3" size="body-medium">
|
||||||
|
Your chats
|
||||||
|
</Text>
|
||||||
|
<div className="relative left-6">
|
||||||
|
<SidebarTrigger />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2, delay: 0.15 }}
|
||||||
|
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(
|
||||||
|
"w-full rounded-lg px-3 py-2.5 text-left transition-colors",
|
||||||
|
session.id === sessionId
|
||||||
|
? "bg-zinc-100"
|
||||||
|
: "hover:bg-zinc-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 max-w-full flex-col overflow-hidden">
|
||||||
|
<div className="min-w-0 max-w-full">
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
className={cn(
|
||||||
|
"truncate font-normal",
|
||||||
|
session.id === sessionId
|
||||||
|
? "text-zinc-600"
|
||||||
|
: "text-zinc-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{session.title || `Untitled chat`}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Text variant="small" className="text-neutral-400">
|
||||||
|
{formatDate(session.updated_at)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</SidebarContent>
|
||||||
|
{!isCollapsed && sessionId && (
|
||||||
|
<SidebarFooter className="shrink-0 bg-zinc-50 p-3 pb-1 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleNewChat}
|
||||||
|
className="w-full"
|
||||||
|
leftIcon={<PlusIcon className="h-4 w-4" weight="bold" />}
|
||||||
|
>
|
||||||
|
New Chat
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
</SidebarFooter>
|
||||||
|
)}
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"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 };
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getGreetingName,
|
||||||
|
getInputPlaceholder,
|
||||||
|
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 { SpinnerGapIcon } from "@phosphor-icons/react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useEffect, 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);
|
||||||
|
const [inputPlaceholder, setInputPlaceholder] = useState(
|
||||||
|
getInputPlaceholder(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInputPlaceholder(getInputPlaceholder(window.innerWidth));
|
||||||
|
}, [window.innerWidth]);
|
||||||
|
|
||||||
|
async function handleQuickActionClick(action: string) {
|
||||||
|
if (isCreatingSession || loadingAction) return;
|
||||||
|
|
||||||
|
setLoadingAction(action);
|
||||||
|
try {
|
||||||
|
await onSend(action);
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-0 py-5 md:px-6 md:py-10">
|
||||||
|
<motion.div
|
||||||
|
className="w-full max-w-3xl text-center"
|
||||||
|
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 max-w-3xl">
|
||||||
|
<Text variant="h3" className="mb-1 !text-[1.375rem] text-zinc-700">
|
||||||
|
Hey, <span className="text-violet-600">{greetingName}</span>
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" className="mb-8 !font-normal">
|
||||||
|
Tell me about your work — I'll find what to automate.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<motion.div
|
||||||
|
layoutId={inputLayoutId}
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
|
||||||
|
className="w-full px-2"
|
||||||
|
>
|
||||||
|
<ChatInput
|
||||||
|
inputId="chat-input-empty"
|
||||||
|
onSend={onSend}
|
||||||
|
disabled={isCreatingSession}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-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-300 px-3 py-2 text-[.9rem] text-zinc-600"
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function getInputPlaceholder(width?: number) {
|
||||||
|
if (!width) return "What's your role and what eats up most of your day?";
|
||||||
|
|
||||||
|
if (width < 500) {
|
||||||
|
return "I'm a chef and I hate...";
|
||||||
|
}
|
||||||
|
if (width <= 1080) {
|
||||||
|
return "What's your role and what eats up most of your day?";
|
||||||
|
}
|
||||||
|
return "What's your role and what eats up most of your day? e.g. 'I'm a recruiter and I hate...'";
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PlusIcon, SpinnerGapIcon, X } from "@phosphor-icons/react";
|
||||||
|
import { Drawer } from "vaul";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
sessions: SessionSummaryResponse[];
|
||||||
|
currentSessionId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onSelectSession: (sessionId: string) => void;
|
||||||
|
onNewChat: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
|
||||||
|
const day = date.getDate();
|
||||||
|
const ordinal =
|
||||||
|
day % 10 === 1 && day !== 11
|
||||||
|
? "st"
|
||||||
|
: day % 10 === 2 && day !== 12
|
||||||
|
? "nd"
|
||||||
|
: day % 10 === 3 && day !== 13
|
||||||
|
? "rd"
|
||||||
|
: "th";
|
||||||
|
const month = date.toLocaleDateString("en-US", { month: "short" });
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
return `${day}${ordinal} ${month} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileDrawer({
|
||||||
|
isOpen,
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
isLoading,
|
||||||
|
onSelectSession,
|
||||||
|
onNewChat,
|
||||||
|
onClose,
|
||||||
|
onOpenChange,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Drawer.Root open={isOpen} onOpenChange={onOpenChange} direction="left">
|
||||||
|
<Drawer.Portal>
|
||||||
|
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
|
||||||
|
<Drawer.Content className="fixed left-0 top-0 z-[70] flex h-full w-80 flex-col border-r border-zinc-200 bg-zinc-50">
|
||||||
|
<div className="shrink-0 border-b border-zinc-200 px-4 py-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Drawer.Title className="text-lg font-semibold text-zinc-800">
|
||||||
|
Your chats
|
||||||
|
</Drawer.Title>
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Close sessions"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X width="1rem" height="1rem" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-3 py-3",
|
||||||
|
scrollbarStyles,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<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={() => onSelectSession(session.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-lg px-3 py-2.5 text-left transition-colors",
|
||||||
|
session.id === currentSessionId
|
||||||
|
? "bg-zinc-100"
|
||||||
|
: "hover:bg-zinc-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 max-w-full flex-col overflow-hidden">
|
||||||
|
<div className="min-w-0 max-w-full">
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
className={cn(
|
||||||
|
"truncate font-normal",
|
||||||
|
session.id === currentSessionId
|
||||||
|
? "text-zinc-600"
|
||||||
|
: "text-zinc-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{session.title || "Untitled chat"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Text variant="small" className="text-neutral-400">
|
||||||
|
{formatDate(session.updated_at)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{currentSessionId && (
|
||||||
|
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={onNewChat}
|
||||||
|
className="w-full"
|
||||||
|
leftIcon={<PlusIcon width="1rem" height="1rem" />}
|
||||||
|
>
|
||||||
|
New Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Portal>
|
||||||
|
</Drawer.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
|
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||||
|
import { ListIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onOpenDrawer: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileHeader({ onOpenDrawer }: Props) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Open sessions"
|
||||||
|
onClick={onOpenDrawer}
|
||||||
|
className="fixed z-50 bg-white shadow-md"
|
||||||
|
style={{ left: "1rem", top: `${NAVBAR_HEIGHT_PX + 20}px` }}
|
||||||
|
>
|
||||||
|
<ListIcon width="1.25rem" height="1.25rem" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MorphingTextAnimation({ text, className }: Props) {
|
||||||
|
const letters = text.split("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(className)}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { useId } from "react";
|
||||||
|
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-lg border 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="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||||
|
import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
|
||||||
|
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
|
||||||
|
import { MobileHeader } from "./components/MobileHeader/MobileHeader";
|
||||||
|
import { useCopilotPage } from "./useCopilotPage";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const {
|
||||||
|
sessionId,
|
||||||
|
messages,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
isLoadingSession,
|
||||||
|
isCreatingSession,
|
||||||
|
createSession,
|
||||||
|
onSend,
|
||||||
|
// Mobile drawer
|
||||||
|
isMobile,
|
||||||
|
isDrawerOpen,
|
||||||
|
sessions,
|
||||||
|
isLoadingSessions,
|
||||||
|
handleOpenDrawer,
|
||||||
|
handleCloseDrawer,
|
||||||
|
handleDrawerOpenChange,
|
||||||
|
handleSelectSession,
|
||||||
|
handleNewChat,
|
||||||
|
} = useCopilotPage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider
|
||||||
|
defaultOpen={true}
|
||||||
|
className="h-[calc(100vh-72px)] min-h-0"
|
||||||
|
>
|
||||||
|
{!isMobile && <ChatSidebar />}
|
||||||
|
<div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0">
|
||||||
|
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ChatContainer
|
||||||
|
messages={messages}
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
sessionId={sessionId}
|
||||||
|
isLoadingSession={isLoadingSession}
|
||||||
|
isCreatingSession={isCreatingSession}
|
||||||
|
onCreateSession={createSession}
|
||||||
|
onSend={onSend}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isMobile && (
|
||||||
|
<MobileDrawer
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
sessions={sessions}
|
||||||
|
currentSessionId={sessionId}
|
||||||
|
isLoading={isLoadingSessions}
|
||||||
|
onSelectSession={handleSelectSession}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
onClose={handleCloseDrawer}
|
||||||
|
onOpenChange={handleDrawerOpenChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"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,
|
||||||
|
ToolIcon,
|
||||||
|
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 isStreaming =
|
||||||
|
part.state === "input-streaming" || part.state === "input-available";
|
||||||
|
|
||||||
|
const output = getCreateAgentToolOutput(part);
|
||||||
|
const isError =
|
||||||
|
part.state === "output-error" || (!!output && isErrorOutput(output));
|
||||||
|
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">
|
||||||
|
<ToolIcon isStreaming={isStreaming} isError={isError} />
|
||||||
|
<MorphingTextAnimation
|
||||||
|
text={text}
|
||||||
|
className={isError ? "text-red-500" : undefined}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type { ToolUIPart } from "ai";
|
||||||
|
import { PlusIcon } 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":
|
||||||
|
case "input-available":
|
||||||
|
return "Creating a new agent";
|
||||||
|
case "output-available": {
|
||||||
|
const output = parseOutput(part.output);
|
||||||
|
if (!output) return "Creating a new agent";
|
||||||
|
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 "Creating a new agent";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolIcon({
|
||||||
|
isStreaming,
|
||||||
|
isError,
|
||||||
|
}: {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PlusIcon
|
||||||
|
size={14}
|
||||||
|
weight="regular"
|
||||||
|
className={
|
||||||
|
isError
|
||||||
|
? "text-red-500"
|
||||||
|
: isStreaming
|
||||||
|
? "text-neutral-500"
|
||||||
|
: "text-neutral-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()}…`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"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,
|
||||||
|
ToolIcon,
|
||||||
|
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 isStreaming =
|
||||||
|
part.state === "input-streaming" || part.state === "input-available";
|
||||||
|
|
||||||
|
const output = getEditAgentToolOutput(part);
|
||||||
|
const isError =
|
||||||
|
part.state === "output-error" || (!!output && isErrorOutput(output));
|
||||||
|
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">
|
||||||
|
<ToolIcon isStreaming={isStreaming} isError={isError} />
|
||||||
|
<MorphingTextAnimation
|
||||||
|
text={text}
|
||||||
|
className={isError ? "text-red-500" : undefined}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type { ToolUIPart } from "ai";
|
||||||
|
import { PencilLineIcon } 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":
|
||||||
|
case "input-available":
|
||||||
|
return "Editing the agent";
|
||||||
|
case "output-available": {
|
||||||
|
const output = parseOutput(part.output);
|
||||||
|
if (!output) return "Editing the agent";
|
||||||
|
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 "Editing the agent";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolIcon({
|
||||||
|
isStreaming,
|
||||||
|
isError,
|
||||||
|
}: {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PencilLineIcon
|
||||||
|
size={14}
|
||||||
|
weight="regular"
|
||||||
|
className={
|
||||||
|
isError
|
||||||
|
? "text-red-500"
|
||||||
|
: isStreaming
|
||||||
|
? "text-neutral-500"
|
||||||
|
: "text-neutral-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()}…`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ToolUIPart } from "ai";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||||
|
import {
|
||||||
|
getAgentHref,
|
||||||
|
getAnimationText,
|
||||||
|
getFindAgentsOutput,
|
||||||
|
getSourceLabelFromToolType,
|
||||||
|
isAgentsFoundOutput,
|
||||||
|
isErrorOutput,
|
||||||
|
ToolIcon,
|
||||||
|
} 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 isStreaming =
|
||||||
|
part.state === "input-streaming" || part.state === "input-available";
|
||||||
|
const isError =
|
||||||
|
part.state === "output-error" || (!!output && isErrorOutput(output));
|
||||||
|
|
||||||
|
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">
|
||||||
|
<ToolIcon
|
||||||
|
toolType={part.type}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isError={isError}
|
||||||
|
/>
|
||||||
|
<MorphingTextAnimation
|
||||||
|
text={text}
|
||||||
|
className={isError ? "text-red-500" : undefined}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { ToolUIPart } from "ai";
|
||||||
|
import { MagnifyingGlassIcon, SquaresFourIcon } 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 { source } = getSourceLabelFromToolType(part.type);
|
||||||
|
const query = (part.input as FindAgentInput | undefined)?.query?.trim();
|
||||||
|
|
||||||
|
// Action phrase matching legacy ToolCallMessage
|
||||||
|
const actionPhrase =
|
||||||
|
source === "library"
|
||||||
|
? "Looking for library agents"
|
||||||
|
: "Looking for agents in the marketplace";
|
||||||
|
|
||||||
|
const queryText = query ? ` matching "${query}"` : "";
|
||||||
|
|
||||||
|
switch (part.state) {
|
||||||
|
case "input-streaming":
|
||||||
|
case "input-available":
|
||||||
|
return `${actionPhrase}${queryText}`;
|
||||||
|
|
||||||
|
case "output-available": {
|
||||||
|
const output = parseOutput(part.output);
|
||||||
|
if (!output) {
|
||||||
|
return `${actionPhrase}${queryText}`;
|
||||||
|
}
|
||||||
|
if (isNoResultsOutput(output)) {
|
||||||
|
return `No agents found${queryText}`;
|
||||||
|
}
|
||||||
|
if (isAgentsFoundOutput(output)) {
|
||||||
|
const count = output.count ?? output.agents?.length ?? 0;
|
||||||
|
return `Found ${count} agent${count === 1 ? "" : "s"}${queryText}`;
|
||||||
|
}
|
||||||
|
if (isErrorOutput(output)) {
|
||||||
|
return `Error finding agents${queryText}`;
|
||||||
|
}
|
||||||
|
return `${actionPhrase}${queryText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "output-error":
|
||||||
|
return `Error finding agents${queryText}`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return actionPhrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ToolIcon({
|
||||||
|
toolType,
|
||||||
|
isStreaming,
|
||||||
|
isError,
|
||||||
|
}: {
|
||||||
|
toolType?: FindAgentsToolType;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
const { source } = getSourceLabelFromToolType(toolType);
|
||||||
|
const IconComponent =
|
||||||
|
source === "library" ? MagnifyingGlassIcon : SquaresFourIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconComponent
|
||||||
|
size={14}
|
||||||
|
weight="regular"
|
||||||
|
className={
|
||||||
|
isError
|
||||||
|
? "text-red-500"
|
||||||
|
: isStreaming
|
||||||
|
? "text-neutral-500"
|
||||||
|
: "text-neutral-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||||
|
import type { BlockListResponse } from "@/app/api/__generated__/models/blockListResponse";
|
||||||
|
import { ToolUIPart } from "ai";
|
||||||
|
import { getAnimationText, ToolIcon } 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);
|
||||||
|
const isStreaming =
|
||||||
|
part.state === "input-streaming" || part.state === "input-available";
|
||||||
|
const isError = part.state === "output-error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
|
||||||
|
<ToolIcon isStreaming={isStreaming} isError={isError} />
|
||||||
|
<MorphingTextAnimation
|
||||||
|
text={text}
|
||||||
|
className={isError ? "text-red-500" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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 { PackageIcon } 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 {
|
||||||
|
const query = (part.input as FindBlockInput | undefined)?.query?.trim();
|
||||||
|
const queryText = query ? ` matching "${query}"` : "";
|
||||||
|
|
||||||
|
switch (part.state) {
|
||||||
|
case "input-streaming":
|
||||||
|
case "input-available":
|
||||||
|
return `Searching for blocks${queryText}`;
|
||||||
|
|
||||||
|
case "output-available": {
|
||||||
|
const parsed = parseOutput(part.output);
|
||||||
|
if (parsed) {
|
||||||
|
return `Found ${parsed.count} block${parsed.count === 1 ? "" : "s"}${queryText}`;
|
||||||
|
}
|
||||||
|
return `Searching for blocks${queryText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "output-error":
|
||||||
|
return `Error finding blocks${queryText}`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "Searching for blocks";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolIcon({
|
||||||
|
isStreaming,
|
||||||
|
isError,
|
||||||
|
}: {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PackageIcon
|
||||||
|
size={14}
|
||||||
|
weight="regular"
|
||||||
|
className={
|
||||||
|
isError
|
||||||
|
? "text-red-500"
|
||||||
|
: isStreaming
|
||||||
|
? "text-neutral-500"
|
||||||
|
: "text-neutral-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"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,
|
||||||
|
ToolIcon,
|
||||||
|
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 isStreaming =
|
||||||
|
part.state === "input-streaming" || part.state === "input-available";
|
||||||
|
|
||||||
|
const output = getRunAgentToolOutput(part);
|
||||||
|
const isError =
|
||||||
|
part.state === "output-error" ||
|
||||||
|
(!!output && isRunAgentErrorOutput(output));
|
||||||
|
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">
|
||||||
|
<ToolIcon isStreaming={isStreaming} isError={isError} />
|
||||||
|
<MorphingTextAnimation
|
||||||
|
text={text}
|
||||||
|
className={isError ? "text-red-500" : undefined}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import type { ToolUIPart } from "ai";
|
||||||
|
import { PlayIcon } 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 isSchedule = Boolean(
|
||||||
|
input?.schedule_name?.trim() || input?.cron?.trim(),
|
||||||
|
);
|
||||||
|
const actionPhrase = isSchedule
|
||||||
|
? "Scheduling the agent to run"
|
||||||
|
: "Running the agent";
|
||||||
|
const identifierText = agentIdentifier ? ` "${agentIdentifier}"` : "";
|
||||||
|
|
||||||
|
switch (part.state) {
|
||||||
|
case "input-streaming":
|
||||||
|
case "input-available":
|
||||||
|
return `${actionPhrase}${identifierText}`;
|
||||||
|
case "output-available": {
|
||||||
|
const output = parseOutput(part.output);
|
||||||
|
if (!output) return `${actionPhrase}${identifierText}`;
|
||||||
|
if (isRunAgentExecutionStartedOutput(output)) {
|
||||||
|
return `Started "${output.graph_name}"`;
|
||||||
|
}
|
||||||
|
if (isRunAgentAgentDetailsOutput(output)) {
|
||||||
|
return `Agent inputs needed for "${output.agent.name}"`;
|
||||||
|
}
|
||||||
|
if (isRunAgentSetupRequirementsOutput(output)) {
|
||||||
|
return `Setup needed for "${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 actionPhrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolIcon({
|
||||||
|
isStreaming,
|
||||||
|
isError,
|
||||||
|
}: {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PlayIcon
|
||||||
|
size={14}
|
||||||
|
weight="regular"
|
||||||
|
className={
|
||||||
|
isError
|
||||||
|
? "text-red-500"
|
||||||
|
: isStreaming
|
||||||
|
? "text-neutral-500"
|
||||||
|
: "text-neutral-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMaybeJson(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"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,
|
||||||
|
ToolIcon,
|
||||||
|
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 isStreaming =
|
||||||
|
part.state === "input-streaming" || part.state === "input-available";
|
||||||
|
|
||||||
|
const output = getRunBlockToolOutput(part);
|
||||||
|
const isError =
|
||||||
|
part.state === "output-error" ||
|
||||||
|
(!!output && isRunBlockErrorOutput(output));
|
||||||
|
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">
|
||||||
|
<ToolIcon isStreaming={isStreaming} isError={isError} />
|
||||||
|
<MorphingTextAnimation
|
||||||
|
text={text}
|
||||||
|
className={isError ? "text-red-500" : undefined}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { ToolUIPart } from "ai";
|
||||||
|
import { PlayIcon } 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 blockId = input?.block_id?.trim();
|
||||||
|
const blockText = blockId ? ` "${blockId}"` : "";
|
||||||
|
|
||||||
|
switch (part.state) {
|
||||||
|
case "input-streaming":
|
||||||
|
case "input-available":
|
||||||
|
return `Running the block${blockText}`;
|
||||||
|
case "output-available": {
|
||||||
|
const output = parseOutput(part.output);
|
||||||
|
if (!output) return `Running the block${blockText}`;
|
||||||
|
if (isRunBlockBlockOutput(output)) return `Ran "${output.block_name}"`;
|
||||||
|
if (isRunBlockSetupRequirementsOutput(output)) {
|
||||||
|
return `Setup needed for "${output.setup_info.agent_name}"`;
|
||||||
|
}
|
||||||
|
return "Error running block";
|
||||||
|
}
|
||||||
|
case "output-error":
|
||||||
|
return "Error running block";
|
||||||
|
default:
|
||||||
|
return "Running the block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolIcon({
|
||||||
|
isStreaming,
|
||||||
|
isError,
|
||||||
|
}: {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PlayIcon
|
||||||
|
size={14}
|
||||||
|
weight="regular"
|
||||||
|
className={
|
||||||
|
isError
|
||||||
|
? "text-red-500"
|
||||||
|
: isStreaming
|
||||||
|
? "text-neutral-500"
|
||||||
|
: "text-neutral-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMaybeJson(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"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,
|
||||||
|
ToolIcon,
|
||||||
|
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 isStreaming =
|
||||||
|
part.state === "input-streaming" || part.state === "input-available";
|
||||||
|
const isError =
|
||||||
|
part.state === "output-error" || (!!output && isErrorOutput(output));
|
||||||
|
|
||||||
|
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">
|
||||||
|
<ToolIcon
|
||||||
|
toolType={part.type}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isError={isError}
|
||||||
|
/>
|
||||||
|
<MorphingTextAnimation
|
||||||
|
text={text}
|
||||||
|
className={isError ? "text-red-500" : undefined}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { ToolUIPart } from "ai";
|
||||||
|
import { FileMagnifyingGlassIcon, FileTextIcon } 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": {
|
||||||
|
const query = (part.input as SearchDocsInput | undefined)?.query?.trim();
|
||||||
|
const queryText = query ? ` for "${query}"` : "";
|
||||||
|
|
||||||
|
switch (part.state) {
|
||||||
|
case "input-streaming":
|
||||||
|
case "input-available":
|
||||||
|
return `Searching documentation${queryText}`;
|
||||||
|
case "output-available": {
|
||||||
|
const output = parseOutput(part.output);
|
||||||
|
if (!output) return `Searching documentation${queryText}`;
|
||||||
|
if (isDocSearchResultsOutput(output)) {
|
||||||
|
const count = output.count ?? output.results.length;
|
||||||
|
return `Found ${count} result${count === 1 ? "" : "s"}${queryText}`;
|
||||||
|
}
|
||||||
|
if (isNoResultsOutput(output)) {
|
||||||
|
return `No results found${queryText}`;
|
||||||
|
}
|
||||||
|
return `Error searching documentation${queryText}`;
|
||||||
|
}
|
||||||
|
case "output-error":
|
||||||
|
return `Error searching documentation${queryText}`;
|
||||||
|
default:
|
||||||
|
return "Searching documentation";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "tool-get_doc_page": {
|
||||||
|
const path = (part.input as GetDocPageInput | undefined)?.path?.trim();
|
||||||
|
const pathText = path ? ` "${path}"` : "";
|
||||||
|
|
||||||
|
switch (part.state) {
|
||||||
|
case "input-streaming":
|
||||||
|
case "input-available":
|
||||||
|
return `Loading documentation page${pathText}`;
|
||||||
|
case "output-available": {
|
||||||
|
const output = parseOutput(part.output);
|
||||||
|
if (!output) return `Loading documentation page${pathText}`;
|
||||||
|
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 "Loading documentation page";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Processing";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolIcon({
|
||||||
|
toolType,
|
||||||
|
isStreaming,
|
||||||
|
isError,
|
||||||
|
}: {
|
||||||
|
toolType: DocsToolType;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
const IconComponent =
|
||||||
|
toolType === "tool-get_doc_page" ? FileTextIcon : FileMagnifyingGlassIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconComponent
|
||||||
|
size={14}
|
||||||
|
weight="regular"
|
||||||
|
className={
|
||||||
|
isError
|
||||||
|
? "text-red-500"
|
||||||
|
: isStreaming
|
||||||
|
? "text-neutral-500"
|
||||||
|
: "text-neutral-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDocsUrl(path: string): string {
|
||||||
|
const urlPath = path.includes(".")
|
||||||
|
? path.slice(0, path.lastIndexOf("."))
|
||||||
|
: path;
|
||||||
|
return `https://docs.agpt.co/${urlPath}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"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,
|
||||||
|
ToolIcon,
|
||||||
|
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 isStreaming =
|
||||||
|
part.state === "input-streaming" || part.state === "input-available";
|
||||||
|
|
||||||
|
const output = getViewAgentOutputToolOutput(part);
|
||||||
|
const isError =
|
||||||
|
part.state === "output-error" || (!!output && isErrorResponse(output));
|
||||||
|
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">
|
||||||
|
<ToolIcon isStreaming={isStreaming} isError={isError} />
|
||||||
|
<MorphingTextAnimation
|
||||||
|
text={text}
|
||||||
|
className={isError ? "text-red-500" : undefined}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { ToolUIPart } from "ai";
|
||||||
|
import { EyeIcon } 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);
|
||||||
|
const agentText = agent ? ` "${agent}"` : "";
|
||||||
|
|
||||||
|
switch (part.state) {
|
||||||
|
case "input-streaming":
|
||||||
|
case "input-available":
|
||||||
|
return `Retrieving agent output${agentText}`;
|
||||||
|
case "output-available": {
|
||||||
|
const output = parseOutput(part.output);
|
||||||
|
if (!output) return `Retrieving agent output${agentText}`;
|
||||||
|
if (isAgentOutputResponse(output)) {
|
||||||
|
if (output.execution)
|
||||||
|
return `Retrieved output (${output.execution.status})`;
|
||||||
|
return "Retrieved agent output";
|
||||||
|
}
|
||||||
|
if (isNoResultsResponse(output)) return "No outputs found";
|
||||||
|
return "Error loading agent output";
|
||||||
|
}
|
||||||
|
case "output-error":
|
||||||
|
return "Error loading agent output";
|
||||||
|
default:
|
||||||
|
return "Retrieving agent output";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolIcon({
|
||||||
|
isStreaming,
|
||||||
|
isError,
|
||||||
|
}: {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<EyeIcon
|
||||||
|
size={14}
|
||||||
|
weight="regular"
|
||||||
|
className={
|
||||||
|
isError
|
||||||
|
? "text-red-500"
|
||||||
|
: isStreaming
|
||||||
|
? "text-neutral-500"
|
||||||
|
: "text-neutral-400"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMaybeJson(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
getGetV2ListSessionsQueryKey,
|
||||||
|
useGetV2GetSession,
|
||||||
|
usePostV2CreateSession,
|
||||||
|
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { parseAsString, useQueryState } from "nuqs";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { convertChatSessionMessagesToUiMessages } from "./helpers/convertChatSessionToUiMessages";
|
||||||
|
|
||||||
|
export function useChatSession() {
|
||||||
|
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const sessionQuery = useGetV2GetSession(sessionId ?? "", {
|
||||||
|
query: {
|
||||||
|
staleTime: Infinity,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Memoize so the effect in useCopilotPage doesn't infinite-loop on a new
|
||||||
|
// array reference every render. Re-derives only when query data changes.
|
||||||
|
const hydratedMessages = useMemo(() => {
|
||||||
|
if (sessionQuery.data?.status !== 200 || !sessionId) return undefined;
|
||||||
|
return convertChatSessionMessagesToUiMessages(
|
||||||
|
sessionId,
|
||||||
|
sessionQuery.data.data.messages ?? [],
|
||||||
|
);
|
||||||
|
}, [sessionQuery.data, sessionId]);
|
||||||
|
|
||||||
|
const { mutateAsync: createSessionMutation, isPending: isCreatingSession } =
|
||||||
|
usePostV2CreateSession({
|
||||||
|
mutation: {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.status === 200 && response.data?.id) {
|
||||||
|
setSessionId(response.data.id);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: getGetV2ListSessionsQueryKey(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createSession() {
|
||||||
|
if (sessionId) return sessionId;
|
||||||
|
const response = await createSessionMutation();
|
||||||
|
if (response.status !== 200 || !response.data?.id) {
|
||||||
|
throw new Error("Failed to create session");
|
||||||
|
}
|
||||||
|
return response.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
setSessionId,
|
||||||
|
hydratedMessages,
|
||||||
|
isLoadingSession: sessionQuery.isLoading,
|
||||||
|
createSession,
|
||||||
|
isCreatingSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||||
|
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||||
|
import { useChat } from "@ai-sdk/react";
|
||||||
|
import { DefaultChatTransport } from "ai";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useChatSession } from "./useChatSession";
|
||||||
|
|
||||||
|
export function useCopilotPage() {
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
sessionId,
|
||||||
|
setSessionId,
|
||||||
|
hydratedMessages,
|
||||||
|
isLoadingSession,
|
||||||
|
createSession,
|
||||||
|
isCreatingSession,
|
||||||
|
} = useChatSession();
|
||||||
|
|
||||||
|
const breakpoint = useBreakpoint();
|
||||||
|
const isMobile =
|
||||||
|
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
||||||
|
|
||||||
|
const transport = sessionId
|
||||||
|
? 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// Resume uses GET on the same endpoint (no message param → backend resumes)
|
||||||
|
prepareReconnectToStreamRequest: () => ({
|
||||||
|
api: `/api/chat/sessions/${sessionId}/stream`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { messages, sendMessage, status, error, setMessages } = useChat({
|
||||||
|
id: sessionId ?? undefined,
|
||||||
|
transport: transport ?? undefined,
|
||||||
|
resume: !!sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydratedMessages || hydratedMessages.length === 0) return;
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.length > hydratedMessages.length) return prev;
|
||||||
|
return hydratedMessages;
|
||||||
|
});
|
||||||
|
}, [hydratedMessages, setMessages]);
|
||||||
|
|
||||||
|
// Clear messages when session is null
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) setMessages([]);
|
||||||
|
}, [sessionId, setMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId || !pendingMessage) return;
|
||||||
|
const msg = pendingMessage;
|
||||||
|
setPendingMessage(null);
|
||||||
|
sendMessage({ text: msg });
|
||||||
|
}, [sessionId, pendingMessage, sendMessage]);
|
||||||
|
|
||||||
|
async function onSend(message: string) {
|
||||||
|
const trimmed = message.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
sendMessage({ text: trimmed });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingMessage(trimmed);
|
||||||
|
await createSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: sessionsResponse, isLoading: isLoadingSessions } =
|
||||||
|
useGetV2ListSessions({ limit: 50 });
|
||||||
|
|
||||||
|
const sessions =
|
||||||
|
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
|
||||||
|
|
||||||
|
const handleOpenDrawer = useCallback(() => {
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDrawer = useCallback(() => {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrawerOpenChange = useCallback((open: boolean) => {
|
||||||
|
setIsDrawerOpen(open);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectSession = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setSessionId(id);
|
||||||
|
if (isMobile) setIsDrawerOpen(false);
|
||||||
|
},
|
||||||
|
[setSessionId, isMobile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNewChat = useCallback(() => {
|
||||||
|
setSessionId(null);
|
||||||
|
if (isMobile) setIsDrawerOpen(false);
|
||||||
|
}, [setSessionId, isMobile]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
messages,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
isLoadingSession,
|
||||||
|
isCreatingSession,
|
||||||
|
createSession,
|
||||||
|
onSend,
|
||||||
|
// Mobile drawer
|
||||||
|
isMobile,
|
||||||
|
isDrawerOpen,
|
||||||
|
sessions,
|
||||||
|
isLoadingSessions,
|
||||||
|
handleOpenDrawer,
|
||||||
|
handleCloseDrawer,
|
||||||
|
handleDrawerOpenChange,
|
||||||
|
handleSelectSession,
|
||||||
|
handleNewChat,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -88,39 +88,27 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy GET endpoint for backward compatibility
|
* Resume an active stream for a session.
|
||||||
|
*
|
||||||
|
* Called by the AI SDK's `useChat(resume: true)` on page load.
|
||||||
|
* Proxies to the backend which checks for an active stream and either
|
||||||
|
* replays it (200 + SSE) or returns 204 No Content.
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: Promise<{ sessionId: string }> },
|
{ params }: { params: Promise<{ sessionId: string }> },
|
||||||
) {
|
) {
|
||||||
const { sessionId } = await params;
|
const { sessionId } = await params;
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const message = searchParams.get("message");
|
|
||||||
const isUserMessage = searchParams.get("is_user_message");
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
return new Response("Missing message parameter", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get auth token from server-side session
|
|
||||||
const token = await getServerAuthToken();
|
const token = await getServerAuthToken();
|
||||||
|
|
||||||
// Build backend URL
|
|
||||||
const backendUrl = environment.getAGPTServerBaseUrl();
|
const backendUrl = environment.getAGPTServerBaseUrl();
|
||||||
const streamUrl = new URL(
|
const streamUrl = new URL(
|
||||||
`/api/chat/sessions/${sessionId}/stream`,
|
`/api/chat/sessions/${sessionId}/stream`,
|
||||||
backendUrl,
|
backendUrl,
|
||||||
);
|
);
|
||||||
streamUrl.searchParams.set("message", message);
|
|
||||||
|
|
||||||
// Pass is_user_message parameter if provided
|
|
||||||
if (isUserMessage !== null) {
|
|
||||||
streamUrl.searchParams.set("is_user_message", isUserMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward request to backend with auth header
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
@@ -136,6 +124,11 @@ export async function GET(
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 204 = no active stream to resume
|
||||||
|
if (response.status === 204) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.text();
|
const error = await response.text();
|
||||||
return new Response(error, {
|
return new Response(error, {
|
||||||
@@ -144,17 +137,17 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the SSE stream directly
|
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache, no-transform",
|
"Cache-Control": "no-cache, no-transform",
|
||||||
Connection: "keep-alive",
|
Connection: "keep-alive",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
|
"x-vercel-ai-ui-message-stream": "v1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("SSE proxy error:", error);
|
console.error("Resume stream proxy error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Failed to connect to chat service",
|
error: "Failed to connect to chat service",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
@source "../node_modules/streamdown/dist/*.js";
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
@@ -29,6 +30,14 @@
|
|||||||
--chart-3: 197 37% 24%;
|
--chart-3: 197 37% 24%;
|
||||||
--chart-4: 43 74% 66%;
|
--chart-4: 43 74% 66%;
|
||||||
--chart-5: 27 87% 67%;
|
--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 {
|
.dark {
|
||||||
@@ -56,6 +65,14 @@
|
|||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||||
|
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",
|
||||||
|
scrollbarStyles,
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
338
autogpt_platform/frontend/src/components/ai-elements/message.tsx
Normal file
338
autogpt_platform/frontend/src/components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
@@ -77,7 +77,7 @@ export function OverflowText(props: Props) {
|
|||||||
"block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap",
|
"block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Text variant={variant} className={className} {...restProps}>
|
<Text variant={variant} as="span" className={className} {...restProps}>
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,17 +6,19 @@ import {
|
|||||||
MicrophoneIcon,
|
MicrophoneIcon,
|
||||||
StopIcon,
|
StopIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
import { ChangeEvent, useCallback } from "react";
|
||||||
import { RecordingIndicator } from "./components/RecordingIndicator";
|
import { RecordingIndicator } from "./components/RecordingIndicator";
|
||||||
import { useChatInput } from "./useChatInput";
|
import { useChatInput } from "./useChatInput";
|
||||||
import { useVoiceRecording } from "./useVoiceRecording";
|
import { useVoiceRecording } from "./useVoiceRecording";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void | Promise<void>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
inputId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -26,14 +28,14 @@ export function ChatInput({
|
|||||||
onStop,
|
onStop,
|
||||||
placeholder = "Type your message...",
|
placeholder = "Type your message...",
|
||||||
className,
|
className,
|
||||||
|
inputId = "chat-input",
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const inputId = "chat-input";
|
|
||||||
const {
|
const {
|
||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
handleKeyDown: baseHandleKeyDown,
|
handleKeyDown: baseHandleKeyDown,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleChange,
|
handleChange: baseHandleChange,
|
||||||
hasMultipleLines,
|
hasMultipleLines,
|
||||||
} = useChatInput({
|
} = useChatInput({
|
||||||
onSend,
|
onSend,
|
||||||
@@ -60,6 +62,15 @@ export function ChatInput({
|
|||||||
inputId,
|
inputId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Block text changes when recording
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (isRecording) return;
|
||||||
|
baseHandleChange(e);
|
||||||
|
},
|
||||||
|
[isRecording, baseHandleChange],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className={cn("relative flex-1", className)}>
|
<form onSubmit={handleSubmit} className={cn("relative flex-1", className)}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function useChatInput({
|
|||||||
}: Args) {
|
}: Args) {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [hasMultipleLines, setHasMultipleLines] = useState(false);
|
const [hasMultipleLines, setHasMultipleLines] = useState(false);
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
function focusOnMount() {
|
function focusOnMount() {
|
||||||
@@ -100,34 +101,40 @@ export function useChatInput({
|
|||||||
}
|
}
|
||||||
}, [value, maxRows, inputId]);
|
}, [value, maxRows, inputId]);
|
||||||
|
|
||||||
const handleSend = () => {
|
async function handleSend() {
|
||||||
if (disabled || !value.trim()) return;
|
if (disabled || isSending || !value.trim()) return;
|
||||||
onSend(value.trim());
|
|
||||||
setValue("");
|
setIsSending(true);
|
||||||
setHasMultipleLines(false);
|
try {
|
||||||
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
await onSend(value.trim());
|
||||||
const wrapper = document.getElementById(
|
setValue("");
|
||||||
`${inputId}-wrapper`,
|
setHasMultipleLines(false);
|
||||||
) as HTMLDivElement;
|
const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
|
||||||
if (textarea) {
|
const wrapper = document.getElementById(
|
||||||
textarea.style.height = "auto";
|
`${inputId}-wrapper`,
|
||||||
|
) as HTMLDivElement;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = "auto";
|
||||||
|
}
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.style.height = "";
|
||||||
|
wrapper.style.maxHeight = "";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
if (wrapper) {
|
}
|
||||||
wrapper.style.height = "";
|
|
||||||
wrapper.style.maxHeight = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSend();
|
void handleSend();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
void handleSend();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChange(e: ChangeEvent<HTMLTextAreaElement>) {
|
function handleChange(e: ChangeEvent<HTMLTextAreaElement>) {
|
||||||
@@ -142,5 +149,6 @@ export function useChatInput({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleChange,
|
handleChange,
|
||||||
hasMultipleLines,
|
hasMultipleLines,
|
||||||
|
isSending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,13 @@ export function useVoiceRecording({
|
|||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const isRecordingRef = useRef(false);
|
const isRecordingRef = useRef(false);
|
||||||
|
|
||||||
const isSupported =
|
const [isSupported, setIsSupported] = useState(false);
|
||||||
typeof window !== "undefined" &&
|
|
||||||
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
useEffect(() => {
|
||||||
|
setIsSupported(
|
||||||
|
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clearTimer = useCallback(() => {
|
const clearTimer = useCallback(() => {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
@@ -214,17 +218,33 @@ export function useVoiceRecording({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (event.key === " " && !value.trim() && !isTranscribing) {
|
// Allow space to toggle recording (start when empty, stop when recording)
|
||||||
|
if (event.key === " " && !isTranscribing) {
|
||||||
|
if (isRecordingRef.current) {
|
||||||
|
// Stop recording on space
|
||||||
|
event.preventDefault();
|
||||||
|
stopRecording();
|
||||||
|
return;
|
||||||
|
} else if (!value.trim()) {
|
||||||
|
// Start recording on space when input is empty
|
||||||
|
event.preventDefault();
|
||||||
|
void startRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Block all key events when recording (except space handled above)
|
||||||
|
if (isRecordingRef.current) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleRecording();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
baseHandleKeyDown(event);
|
baseHandleKeyDown(event);
|
||||||
},
|
},
|
||||||
[value, isTranscribing, toggleRecording, baseHandleKeyDown],
|
[value, isTranscribing, stopRecording, startRecording, baseHandleKeyDown],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showMicButton = isSupported;
|
const showMicButton = isSupported;
|
||||||
|
// Don't include isRecording in disabled state - we need key events to work
|
||||||
|
// Text input is blocked via handleKeyDown instead
|
||||||
const isInputDisabled = disabled || isStreaming || isTranscribing;
|
const isInputDisabled = disabled || isStreaming || isTranscribing;
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function Navbar() {
|
|||||||
<PreviewBanner branchName={previewBranchName} />
|
<PreviewBanner branchName={previewBranchName} />
|
||||||
) : null}
|
) : null}
|
||||||
<nav
|
<nav
|
||||||
className="border-zinc-[#EFEFF0] inline-flex w-full items-center border border-[#EFEFF0] bg-[#F3F4F6]/20 p-3 backdrop-blur-[26px]"
|
className="inline-flex w-full items-center border border-none bg-[#FAFAFA] p-3 backdrop-blur-[26px]"
|
||||||
style={{ height: NAVBAR_HEIGHT_PX }}
|
style={{ height: NAVBAR_HEIGHT_PX }}
|
||||||
>
|
>
|
||||||
{/* Left section */}
|
{/* Left section */}
|
||||||
|
|||||||
83
autogpt_platform/frontend/src/components/ui/button-group.tsx
Normal file
83
autogpt_platform/frontend/src/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
59
autogpt_platform/frontend/src/components/ui/button.tsx
Normal file
59
autogpt_platform/frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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 };
|
||||||
22
autogpt_platform/frontend/src/components/ui/input.tsx
Normal file
22
autogpt_platform/frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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 };
|
||||||
31
autogpt_platform/frontend/src/components/ui/separator.tsx
Normal file
31
autogpt_platform/frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"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 };
|
||||||
143
autogpt_platform/frontend/src/components/ui/sheet.tsx
Normal file
143
autogpt_platform/frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"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,
|
||||||
|
};
|
||||||
778
autogpt_platform/frontend/src/components/ui/sidebar.tsx
Normal file
778
autogpt_platform/frontend/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,778 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
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";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SidebarSimpleIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
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>
|
||||||
|
>(({ onClick }, ref) => {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="trigger"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event);
|
||||||
|
toggleSidebar();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarSimpleIcon className="!size-5" />
|
||||||
|
<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,
|
||||||
|
};
|
||||||
18
autogpt_platform/frontend/src/components/ui/skeleton.tsx
Normal file
18
autogpt_platform/frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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 };
|
||||||
32
autogpt_platform/frontend/src/components/ui/tooltip.tsx
Normal file
32
autogpt_platform/frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"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 };
|
||||||
21
autogpt_platform/frontend/src/hooks/use-mobile.tsx
Normal file
21
autogpt_platform/frontend/src/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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.AGENT_FAVORITING]: false,
|
||||||
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,
|
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,
|
||||||
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
|
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
|
||||||
[Flag.CHAT]: false,
|
[Flag.CHAT]: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
type FlagValues = typeof defaultFlags;
|
type FlagValues = typeof defaultFlags;
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ const config = {
|
|||||||
"600": "#282828",
|
"600": "#282828",
|
||||||
"700": "#272727",
|
"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: {
|
spacing: {
|
||||||
"0": "0rem",
|
"0": "0rem",
|
||||||
|
|||||||
Reference in New Issue
Block a user